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', icon: 'lucide:list-tree', description: 'IVR menu definitions', }, { id: 'entry-menu', title: 'Entry Menu', value: entryMenu?.name || '(none)', type: 'text' as any, icon: 'lucide:door-open', description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set', }, { id: 'status', title: 'Status', value: ivr.enabled ? 'Enabled' : 'Disabled', type: 'text' as any, icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle', 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` ${val} ${isEntry ? html`entry` : ''} `; }, }, { key: 'promptText', header: 'Prompt', renderer: (val: string) => { const truncated = val && val.length > 60 ? val.slice(0, 60) + '...' : val || '--'; return html`${truncated}`; }, }, { key: 'entries', header: 'Digits', renderer: (_val: any, row: IIvrMenu) => { const digits = (row.entries || []).map((e) => e.digit).join(', '); return html`${digits || '(none)'}`; }, }, { key: 'timeoutAction', header: 'Timeout Action', renderer: (_val: any, row: IIvrMenu) => { return html`${describeAction(row.timeoutAction)}`; }, }, ]; } // ---- 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', iconName: 'lucide:door-open' as any, type: ['inRow'] as any, actionFunc: async ({ item }: { item: IIvrMenu }) => { await this.setEntryMenu(item.id); }, }, { name: 'Delete', iconName: 'lucide:trash-2' as any, 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`
Are you sure you want to delete ${menu.name}? This action cannot be undone.
`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalRef: any) => { modalRef.destroy(); }, }, { name: 'Delete', iconName: 'lucide:trash-2', 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`
${label}
{ 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; } }} > ${action.type === 'route-extension' ? html` ({ option: `${d.displayName} (${d.extension})`, key: d.extension }))} @selectedOption=${(e: CustomEvent) => { onChange({ ...action, extensionId: e.detail.key }); }} > ` : ''} ${action.type === 'route-voicemail' ? html` { onChange({ ...action, boxId: (e.target as any).value }); }} > ` : ''} ${action.type === 'submenu' ? html` 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 }); }} > ` : ''} ${action.type === 'play-message' ? html` { onChange({ ...action, promptId: (e.target as any).value }); }} > ` : ''} ${action.type === 'transfer' ? html` { onChange({ ...action, number: (e.target as any).value }); }} > ({ option: p.displayName || p.id, key: p.id })), ]} @selectedOption=${(e: CustomEvent) => { onChange({ ...action, providerId: e.detail.key || undefined }); }} > ` : ''}
`; } // ---- 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`
{ formData.name = (e.target as any).value; if (!existing) { formData.id = slugify(formData.name); } }} > { formData.id = (e.target as any).value; }} > { formData.promptText = (e.target as any).value; }} > v.key === formData.promptVoice) || VOICE_OPTIONS[0]} .options=${VOICE_OPTIONS} @selectedOption=${(e: CustomEvent) => { formData.promptVoice = e.detail.key; }} >
Digit Entries
{ 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
${formData.entries.length === 0 ? html`
No digit entries configured.
` : formData.entries.map((entry, idx) => html`
({ option: d, key: d }))} @selectedOption=${(e: CustomEvent) => { formData.entries[idx].digit = e.detail.key; }} >
{ formData.entries = formData.entries.filter((_, i) => i !== idx); rerenderContent(); }} >Remove
${this.renderActionEditor( entry.action, (a) => { formData.entries[idx].action = a; rerenderContent(); }, 'Action', cfg, )}
`) }
Timeout Settings
{ formData.timeoutSec = parseInt((e.target as any).value, 10) || 5; }} > { formData.maxRetries = parseInt((e.target as any).value, 10) || 3; }} >
${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, )}
`; await DeesModal.createAndShow({ heading: existing ? `Edit Menu: ${existing.name}` : 'New IVR Menu', width: 'small', showCloseButton: true, content: html`
${buildContent()}
`, 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`
{ this.toggleEnabled(); }} >
`; } }