2026-04-10 08:54:46 +00:00
|
|
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
|
|
|
|
import { deesCatalog } from '../plugins.js';
|
|
|
|
|
import { appState, type IAppState } from '../state/appstate.js';
|
|
|
|
|
import { viewHostCss } from './shared/index.js';
|
|
|
|
|
import type { IStatsTile } from '@design.estate/dees-catalog';
|
|
|
|
|
|
|
|
|
|
const { DeesModal, DeesToast } = deesCatalog;
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// IVR types (mirrors ts/config.ts)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type TIvrAction =
|
|
|
|
|
| { type: 'route-extension'; extensionId: string }
|
|
|
|
|
| { type: 'route-voicemail'; boxId: string }
|
|
|
|
|
| { type: 'submenu'; menuId: string }
|
|
|
|
|
| { type: 'play-message'; promptId: string }
|
|
|
|
|
| { type: 'transfer'; number: string; providerId?: string }
|
|
|
|
|
| { type: 'repeat' }
|
|
|
|
|
| { type: 'hangup' };
|
|
|
|
|
|
|
|
|
|
interface IIvrMenuEntry {
|
|
|
|
|
digit: string;
|
|
|
|
|
action: TIvrAction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface IIvrMenu {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
promptText: string;
|
|
|
|
|
promptVoice?: string;
|
|
|
|
|
entries: IIvrMenuEntry[];
|
|
|
|
|
timeoutSec?: number;
|
|
|
|
|
maxRetries?: number;
|
|
|
|
|
timeoutAction: TIvrAction;
|
|
|
|
|
invalidAction: TIvrAction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface IIvrConfig {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
menus: IIvrMenu[];
|
|
|
|
|
entryMenuId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function slugify(name: string): string {
|
|
|
|
|
return name
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
|
|
|
.replace(/^-|-$/g, '') || `menu-${Date.now()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const VOICE_OPTIONS = [
|
|
|
|
|
{ option: 'af_bella (Female)', key: 'af_bella' },
|
|
|
|
|
{ option: 'af_sarah (Female)', key: 'af_sarah' },
|
|
|
|
|
{ option: 'am_adam (Male)', key: 'am_adam' },
|
|
|
|
|
{ option: 'bf_alice (Female)', key: 'bf_alice' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const ACTION_TYPE_OPTIONS = [
|
|
|
|
|
{ option: 'Route to Extension', key: 'route-extension' },
|
|
|
|
|
{ option: 'Route to Voicemail', key: 'route-voicemail' },
|
|
|
|
|
{ option: 'Submenu', key: 'submenu' },
|
|
|
|
|
{ option: 'Play Message', key: 'play-message' },
|
|
|
|
|
{ option: 'Transfer', key: 'transfer' },
|
|
|
|
|
{ option: 'Repeat', key: 'repeat' },
|
|
|
|
|
{ option: 'Hangup', key: 'hangup' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DIGIT_OPTIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
|
|
|
|
|
|
|
|
|
|
function describeAction(action: TIvrAction): string {
|
|
|
|
|
switch (action.type) {
|
|
|
|
|
case 'route-extension': return `Extension: ${action.extensionId}`;
|
|
|
|
|
case 'route-voicemail': return `Voicemail: ${action.boxId}`;
|
|
|
|
|
case 'submenu': return `Submenu: ${action.menuId}`;
|
|
|
|
|
case 'play-message': return `Play: ${action.promptId}`;
|
|
|
|
|
case 'transfer': return `Transfer: ${action.number}${action.providerId ? ` (${action.providerId})` : ''}`;
|
|
|
|
|
case 'repeat': return 'Repeat';
|
|
|
|
|
case 'hangup': return 'Hangup';
|
|
|
|
|
default: return 'Unknown';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeDefaultAction(): TIvrAction {
|
|
|
|
|
return { type: 'hangup' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// View element
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@customElement('sipproxy-view-ivr')
|
|
|
|
|
export class SipproxyViewIvr extends DeesElement {
|
|
|
|
|
@state() accessor appData: IAppState = appState.getState();
|
|
|
|
|
@state() accessor config: any = null;
|
|
|
|
|
|
|
|
|
|
public static styles = [
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
viewHostCss,
|
|
|
|
|
css`
|
|
|
|
|
.view-section { margin-bottom: 24px; }
|
|
|
|
|
`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---- lifecycle -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
connectedCallback() {
|
|
|
|
|
super.connectedCallback();
|
|
|
|
|
this.rxSubscriptions.push({
|
|
|
|
|
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
|
|
|
|
} as any);
|
|
|
|
|
this.loadConfig();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadConfig() {
|
|
|
|
|
try {
|
|
|
|
|
this.config = await appState.apiGetConfig();
|
|
|
|
|
} catch {
|
|
|
|
|
// Will show empty state.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getIvrConfig(): IIvrConfig {
|
|
|
|
|
return this.config?.ivr || { enabled: false, menus: [], entryMenuId: '' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- stats tiles ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private getStatsTiles(): IStatsTile[] {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const entryMenu = ivr.menus.find((m) => m.id === ivr.entryMenuId);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
id: 'total-menus',
|
|
|
|
|
title: 'Total Menus',
|
|
|
|
|
value: ivr.menus.length,
|
|
|
|
|
type: 'number',
|
2026-04-11 08:24:47 +00:00
|
|
|
icon: 'lucide:ListTree',
|
2026-04-10 08:54:46 +00:00
|
|
|
description: 'IVR menu definitions',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'entry-menu',
|
|
|
|
|
title: 'Entry Menu',
|
|
|
|
|
value: entryMenu?.name || '(none)',
|
|
|
|
|
type: 'text' as any,
|
2026-04-11 08:24:47 +00:00
|
|
|
icon: 'lucide:DoorOpen',
|
2026-04-10 08:54:46 +00:00
|
|
|
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'status',
|
|
|
|
|
title: 'Status',
|
|
|
|
|
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
|
|
|
|
type: 'text' as any,
|
2026-04-11 08:24:47 +00:00
|
|
|
icon: ivr.enabled ? 'lucide:CheckCircle' : 'lucide:XCircle',
|
2026-04-10 08:54:46 +00:00
|
|
|
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
|
|
|
|
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- table columns -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private getColumns() {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
key: 'name',
|
|
|
|
|
header: 'Name',
|
|
|
|
|
sortable: true,
|
|
|
|
|
renderer: (val: string, row: IIvrMenu) => {
|
|
|
|
|
const isEntry = row.id === ivr.entryMenuId;
|
|
|
|
|
return html`
|
|
|
|
|
<span>${val}</span>
|
|
|
|
|
${isEntry ? html`<span style="display:inline-block;margin-left:8px;padding:1px 6px;border-radius:4px;font-size:.65rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">entry</span>` : ''}
|
|
|
|
|
`;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'promptText',
|
|
|
|
|
header: 'Prompt',
|
|
|
|
|
renderer: (val: string) => {
|
|
|
|
|
const truncated = val && val.length > 60 ? val.slice(0, 60) + '...' : val || '--';
|
|
|
|
|
return html`<span style="font-size:.82rem;color:#94a3b8">${truncated}</span>`;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'entries',
|
|
|
|
|
header: 'Digits',
|
|
|
|
|
renderer: (_val: any, row: IIvrMenu) => {
|
|
|
|
|
const digits = (row.entries || []).map((e) => e.digit).join(', ');
|
|
|
|
|
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${digits || '(none)'}</span>`;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'timeoutAction',
|
|
|
|
|
header: 'Timeout Action',
|
|
|
|
|
renderer: (_val: any, row: IIvrMenu) => {
|
|
|
|
|
return html`<span style="font-size:.82rem;color:#94a3b8">${describeAction(row.timeoutAction)}</span>`;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- table actions -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private getDataActions() {
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
name: 'Add Menu',
|
|
|
|
|
iconName: 'lucide:plus' as any,
|
|
|
|
|
type: ['header'] as any,
|
|
|
|
|
actionFunc: async () => {
|
|
|
|
|
await this.openMenuEditor(null);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Edit',
|
|
|
|
|
iconName: 'lucide:pencil' as any,
|
|
|
|
|
type: ['inRow'] as any,
|
|
|
|
|
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
|
|
|
|
await this.openMenuEditor(item);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Set as Entry',
|
2026-04-11 08:24:47 +00:00
|
|
|
iconName: 'lucide:DoorOpen' as any,
|
2026-04-10 08:54:46 +00:00
|
|
|
type: ['inRow'] as any,
|
|
|
|
|
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
|
|
|
|
await this.setEntryMenu(item.id);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Delete',
|
2026-04-11 08:24:47 +00:00
|
|
|
iconName: 'lucide:Trash2' as any,
|
2026-04-10 08:54:46 +00:00
|
|
|
type: ['inRow'] as any,
|
|
|
|
|
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
|
|
|
|
await this.confirmDeleteMenu(item);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- toggle enabled ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private async toggleEnabled() {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const updated: IIvrConfig = { ...ivr, enabled: !ivr.enabled };
|
|
|
|
|
const result = await appState.apiSaveConfig({ ivr: updated });
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
DeesToast.success(updated.enabled ? 'IVR enabled' : 'IVR disabled');
|
|
|
|
|
await this.loadConfig();
|
|
|
|
|
} else {
|
|
|
|
|
DeesToast.error('Failed to update IVR status');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- set entry menu ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private async setEntryMenu(menuId: string) {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const updated: IIvrConfig = { ...ivr, entryMenuId: menuId };
|
|
|
|
|
const result = await appState.apiSaveConfig({ ivr: updated });
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
DeesToast.success('Entry menu updated');
|
|
|
|
|
await this.loadConfig();
|
|
|
|
|
} else {
|
|
|
|
|
DeesToast.error('Failed to set entry menu');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- delete menu ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private async confirmDeleteMenu(menu: IIvrMenu) {
|
|
|
|
|
await DeesModal.createAndShow({
|
|
|
|
|
heading: 'Delete IVR Menu',
|
|
|
|
|
width: 'small',
|
|
|
|
|
showCloseButton: true,
|
|
|
|
|
content: html`
|
|
|
|
|
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
|
|
|
|
Are you sure you want to delete
|
|
|
|
|
<strong style="color:#f87171;">${menu.name}</strong>?
|
|
|
|
|
This action cannot be undone.
|
|
|
|
|
</div>
|
|
|
|
|
`,
|
|
|
|
|
menuOptions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Cancel',
|
|
|
|
|
iconName: 'lucide:x',
|
|
|
|
|
action: async (modalRef: any) => { modalRef.destroy(); },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Delete',
|
2026-04-11 08:24:47 +00:00
|
|
|
iconName: 'lucide:Trash2',
|
2026-04-10 08:54:46 +00:00
|
|
|
action: async (modalRef: any) => {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
|
|
|
|
const updated: IIvrConfig = {
|
|
|
|
|
...ivr,
|
|
|
|
|
menus,
|
|
|
|
|
entryMenuId: ivr.entryMenuId === menu.id ? '' : ivr.entryMenuId,
|
|
|
|
|
};
|
|
|
|
|
const result = await appState.apiSaveConfig({ ivr: updated });
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
modalRef.destroy();
|
|
|
|
|
DeesToast.success(`Menu "${menu.name}" deleted`);
|
|
|
|
|
await this.loadConfig();
|
|
|
|
|
} else {
|
|
|
|
|
DeesToast.error('Failed to delete menu');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- action editor helper ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private renderActionEditor(
|
|
|
|
|
action: TIvrAction,
|
|
|
|
|
onChange: (a: TIvrAction) => void,
|
|
|
|
|
label: string,
|
|
|
|
|
cfg: any,
|
|
|
|
|
): TemplateResult {
|
|
|
|
|
const devices = cfg?.devices || [];
|
|
|
|
|
const menus: IIvrMenu[] = cfg?.ivr?.menus || [];
|
|
|
|
|
const providers = cfg?.providers || [];
|
|
|
|
|
|
|
|
|
|
const currentType = ACTION_TYPE_OPTIONS.find((o) => o.key === action.type) || ACTION_TYPE_OPTIONS[ACTION_TYPE_OPTIONS.length - 1];
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
<div style="margin-bottom:12px;">
|
|
|
|
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;">${label}</div>
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.label=${'Action Type'}
|
|
|
|
|
.selectedOption=${currentType}
|
|
|
|
|
.options=${ACTION_TYPE_OPTIONS}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
|
|
|
const type = e.detail.key;
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'route-extension': onChange({ type, extensionId: devices[0]?.extension || '100' }); break;
|
|
|
|
|
case 'route-voicemail': onChange({ type, boxId: '' }); break;
|
|
|
|
|
case 'submenu': onChange({ type, menuId: menus[0]?.id || '' }); break;
|
|
|
|
|
case 'play-message': onChange({ type, promptId: '' }); break;
|
|
|
|
|
case 'transfer': onChange({ type, number: '' }); break;
|
|
|
|
|
case 'repeat': onChange({ type }); break;
|
|
|
|
|
case 'hangup': onChange({ type }); break;
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
|
|
|
|
|
${action.type === 'route-extension' ? html`
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.label=${'Extension'}
|
|
|
|
|
.selectedOption=${{ option: action.extensionId, key: action.extensionId }}
|
|
|
|
|
.options=${devices.map((d: any) => ({ option: `${d.displayName} (${d.extension})`, key: d.extension }))}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, extensionId: e.detail.key }); }}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
${action.type === 'route-voicemail' ? html`
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.label=${'Voicemail Box ID'}
|
|
|
|
|
.value=${action.boxId}
|
|
|
|
|
@input=${(e: Event) => { onChange({ ...action, boxId: (e.target as any).value }); }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
${action.type === 'submenu' ? html`
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.label=${'Menu'}
|
|
|
|
|
.selectedOption=${menus.find((m) => m.id === action.menuId)
|
|
|
|
|
? { option: menus.find((m) => m.id === action.menuId)!.name, key: action.menuId }
|
|
|
|
|
: { option: '(select)', key: '' }}
|
|
|
|
|
.options=${menus.map((m) => ({ option: m.name, key: m.id }))}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, menuId: e.detail.key }); }}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
${action.type === 'play-message' ? html`
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.label=${'Prompt ID'}
|
|
|
|
|
.value=${action.promptId}
|
|
|
|
|
@input=${(e: Event) => { onChange({ ...action, promptId: (e.target as any).value }); }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
${action.type === 'transfer' ? html`
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.label=${'Transfer Number'}
|
|
|
|
|
.value=${action.number}
|
|
|
|
|
@input=${(e: Event) => { onChange({ ...action, number: (e.target as any).value }); }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.label=${'Provider (optional)'}
|
|
|
|
|
.selectedOption=${action.providerId
|
|
|
|
|
? { option: action.providerId, key: action.providerId }
|
|
|
|
|
: { option: '(default)', key: '' }}
|
|
|
|
|
.options=${[
|
|
|
|
|
{ option: '(default)', key: '' },
|
|
|
|
|
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
|
|
|
|
]}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, providerId: e.detail.key || undefined }); }}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- menu editor modal ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private async openMenuEditor(existing: IIvrMenu | null) {
|
|
|
|
|
const cfg = this.config;
|
|
|
|
|
|
|
|
|
|
const formData: IIvrMenu = existing
|
|
|
|
|
? JSON.parse(JSON.stringify(existing))
|
|
|
|
|
: {
|
|
|
|
|
id: '',
|
|
|
|
|
name: '',
|
|
|
|
|
promptText: '',
|
|
|
|
|
promptVoice: 'af_bella',
|
|
|
|
|
entries: [],
|
|
|
|
|
timeoutSec: 5,
|
|
|
|
|
maxRetries: 3,
|
|
|
|
|
timeoutAction: { type: 'hangup' as const },
|
|
|
|
|
invalidAction: { type: 'repeat' as const },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// For re-rendering the modal content on state changes we track a version counter.
|
|
|
|
|
let version = 0;
|
|
|
|
|
const modalContentId = `ivr-modal-${Date.now()}`;
|
|
|
|
|
|
|
|
|
|
const rerenderContent = () => {
|
|
|
|
|
version++;
|
|
|
|
|
const container = document.querySelector(`#${modalContentId}`) as HTMLElement
|
|
|
|
|
|| document.getElementById(modalContentId);
|
|
|
|
|
if (container) {
|
|
|
|
|
// Force a re-render by removing and re-adding the modal content.
|
|
|
|
|
// We can't use lit's render directly here, so we close and reopen.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildContent = (): TemplateResult => html`
|
|
|
|
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'name'} .label=${'Menu Name'} .value=${formData.name}
|
|
|
|
|
@input=${(e: Event) => {
|
|
|
|
|
formData.name = (e.target as any).value;
|
|
|
|
|
if (!existing) {
|
|
|
|
|
formData.id = slugify(formData.name);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'id'} .label=${'Menu ID'} .value=${formData.id}
|
|
|
|
|
.description=${'Auto-generated from name. Editable for custom IDs.'}
|
|
|
|
|
@input=${(e: Event) => { formData.id = (e.target as any).value; }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'promptText'} .label=${'Prompt Text (TTS)'}
|
|
|
|
|
.value=${formData.promptText}
|
|
|
|
|
.description=${'Text that will be read aloud to the caller.'}
|
|
|
|
|
@input=${(e: Event) => { formData.promptText = (e.target as any).value; }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.key=${'promptVoice'} .label=${'Voice'}
|
|
|
|
|
.selectedOption=${VOICE_OPTIONS.find((v) => v.key === formData.promptVoice) || VOICE_OPTIONS[0]}
|
|
|
|
|
.options=${VOICE_OPTIONS}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => { formData.promptVoice = e.detail.key; }}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
|
|
|
|
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
|
|
|
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;font-weight:600;">
|
|
|
|
|
Digit Entries
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
style="font-size:.75rem;color:#60a5fa;cursor:pointer;user-select:none;"
|
|
|
|
|
@click=${() => {
|
|
|
|
|
const usedDigits = new Set(formData.entries.map((e) => e.digit));
|
|
|
|
|
const nextDigit = DIGIT_OPTIONS.find((d) => !usedDigits.has(d)) || '1';
|
|
|
|
|
formData.entries = [...formData.entries, { digit: nextDigit, action: makeDefaultAction() }];
|
|
|
|
|
rerenderContent();
|
|
|
|
|
}}
|
|
|
|
|
>+ Add Digit</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${formData.entries.length === 0
|
|
|
|
|
? html`<div style="font-size:.82rem;color:#64748b;font-style:italic;margin-bottom:8px;">No digit entries configured.</div>`
|
|
|
|
|
: formData.entries.map((entry, idx) => html`
|
|
|
|
|
<div style="padding:8px;margin-bottom:8px;border:1px solid #334155;border-radius:6px;background:#0f172a;">
|
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.label=${'Digit'}
|
|
|
|
|
.selectedOption=${{ option: entry.digit, key: entry.digit }}
|
|
|
|
|
.options=${DIGIT_OPTIONS.map((d) => ({ option: d, key: d }))}
|
|
|
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
|
|
|
formData.entries[idx].digit = e.detail.key;
|
|
|
|
|
}}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
<div
|
|
|
|
|
style="font-size:.75rem;color:#f87171;cursor:pointer;user-select:none;margin-left:12px;padding:4px 8px;"
|
|
|
|
|
@click=${() => {
|
|
|
|
|
formData.entries = formData.entries.filter((_, i) => i !== idx);
|
|
|
|
|
rerenderContent();
|
|
|
|
|
}}
|
|
|
|
|
>Remove</div>
|
|
|
|
|
</div>
|
|
|
|
|
${this.renderActionEditor(
|
|
|
|
|
entry.action,
|
|
|
|
|
(a) => { formData.entries[idx].action = a; rerenderContent(); },
|
|
|
|
|
'Action',
|
|
|
|
|
cfg,
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
`)
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
|
|
|
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">
|
|
|
|
|
Timeout Settings
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display:flex;gap:12px;">
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'timeoutSec'} .label=${'Timeout (sec)'}
|
|
|
|
|
.value=${String(formData.timeoutSec ?? 5)}
|
|
|
|
|
@input=${(e: Event) => { formData.timeoutSec = parseInt((e.target as any).value, 10) || 5; }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'maxRetries'} .label=${'Max Retries'}
|
|
|
|
|
.value=${String(formData.maxRetries ?? 3)}
|
|
|
|
|
@input=${(e: Event) => { formData.maxRetries = parseInt((e.target as any).value, 10) || 3; }}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
|
|
|
|
${this.renderActionEditor(
|
|
|
|
|
formData.timeoutAction,
|
|
|
|
|
(a) => { formData.timeoutAction = a; rerenderContent(); },
|
|
|
|
|
'Timeout Action (no digit pressed)',
|
|
|
|
|
cfg,
|
|
|
|
|
)}
|
|
|
|
|
${this.renderActionEditor(
|
|
|
|
|
formData.invalidAction,
|
|
|
|
|
(a) => { formData.invalidAction = a; rerenderContent(); },
|
|
|
|
|
'Invalid Digit Action',
|
|
|
|
|
cfg,
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
await DeesModal.createAndShow({
|
|
|
|
|
heading: existing ? `Edit Menu: ${existing.name}` : 'New IVR Menu',
|
|
|
|
|
width: 'small',
|
|
|
|
|
showCloseButton: true,
|
|
|
|
|
content: html`<div id="${modalContentId}">${buildContent()}</div>`,
|
|
|
|
|
menuOptions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Cancel',
|
|
|
|
|
iconName: 'lucide:x',
|
|
|
|
|
action: async (modalRef: any) => { modalRef.destroy(); },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Save',
|
|
|
|
|
iconName: 'lucide:check',
|
|
|
|
|
action: async (modalRef: any) => {
|
|
|
|
|
if (!formData.name.trim()) {
|
|
|
|
|
DeesToast.error('Menu name is required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!formData.id.trim()) {
|
|
|
|
|
DeesToast.error('Menu ID is required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!formData.promptText.trim()) {
|
|
|
|
|
DeesToast.error('Prompt text is required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const menus = [...ivr.menus];
|
|
|
|
|
const idx = menus.findIndex((m) => m.id === (existing?.id || formData.id));
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
menus[idx] = formData;
|
|
|
|
|
} else {
|
|
|
|
|
menus.push(formData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated: IIvrConfig = {
|
|
|
|
|
...ivr,
|
|
|
|
|
menus,
|
|
|
|
|
// Auto-set entry menu if this is the first menu.
|
|
|
|
|
entryMenuId: ivr.entryMenuId || formData.id,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await appState.apiSaveConfig({ ivr: updated });
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
modalRef.destroy();
|
|
|
|
|
DeesToast.success(existing ? 'Menu updated' : 'Menu created');
|
|
|
|
|
await this.loadConfig();
|
|
|
|
|
} else {
|
|
|
|
|
DeesToast.error('Failed to save menu');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- render --------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
|
|
|
|
const ivr = this.getIvrConfig();
|
|
|
|
|
const menus = ivr.menus || [];
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
<div class="view-section">
|
|
|
|
|
<dees-statsgrid
|
|
|
|
|
.tiles=${this.getStatsTiles()}
|
|
|
|
|
.minTileWidth=${220}
|
|
|
|
|
.gap=${16}
|
|
|
|
|
></dees-statsgrid>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="view-section" style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
|
|
|
|
<dees-input-checkbox
|
|
|
|
|
.key=${'ivr-enabled'}
|
|
|
|
|
.label=${'Enable IVR System'}
|
|
|
|
|
.value=${ivr.enabled}
|
|
|
|
|
@newValue=${() => { this.toggleEnabled(); }}
|
|
|
|
|
></dees-input-checkbox>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="view-section">
|
|
|
|
|
<dees-table
|
|
|
|
|
heading1="IVR Menus"
|
|
|
|
|
heading2="${menus.length} configured"
|
|
|
|
|
dataName="menus"
|
|
|
|
|
.data=${menus}
|
|
|
|
|
.rowKey=${'id'}
|
|
|
|
|
.columns=${this.getColumns()}
|
|
|
|
|
.dataActions=${this.getDataActions()}
|
|
|
|
|
></dees-table>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
}
|