feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management

This commit is contained in:
2026-04-10 08:54:46 +00:00
parent 6ecd3f434c
commit e6bd64a534
25 changed files with 3892 additions and 10 deletions

View File

@@ -5,8 +5,10 @@ export * from './sipproxy-view-calls.js';
export * from './sipproxy-view-phone.js';
export * from './sipproxy-view-contacts.js';
export * from './sipproxy-view-providers.js';
export * from './sipproxy-view-voicemail.js';
export * from './sipproxy-view-log.js';
export * from './sipproxy-view-routes.js';
export * from './sipproxy-view-ivr.js';
// Sub-components (used within views)
export * from './sipproxy-devices.js';

View File

@@ -9,12 +9,16 @@ import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
import { SipproxyViewLog } from './sipproxy-view-log.js';
import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
import { SipproxyViewIvr } from './sipproxy-view-ivr.js';
const VIEW_TABS = [
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr },
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },

View File

@@ -0,0 +1,657 @@
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`
<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',
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`
<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',
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`
<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>
`;
}
}

View File

@@ -0,0 +1,446 @@
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';
// ---------------------------------------------------------------------------
// Voicemail message shape (mirrors server IVoicemailMessage)
// ---------------------------------------------------------------------------
interface IVoicemailMessage {
id: string;
boxId: string;
callerNumber: string;
callerName?: string;
timestamp: number;
durationMs: number;
fileName: string;
heard: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(ms: number): string {
const totalSec = Math.round(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
function formatDateTime(ts: number): string {
const d = new Date(ts);
const date = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
}
// ---------------------------------------------------------------------------
// View element
// ---------------------------------------------------------------------------
@customElement('sipproxy-view-voicemail')
export class SipproxyViewVoicemail extends DeesElement {
@state() accessor appData: IAppState = appState.getState();
@state() accessor messages: IVoicemailMessage[] = [];
@state() accessor voiceboxIds: string[] = [];
@state() accessor selectedBoxId: string = '';
@state() accessor playingMessageId: string | null = null;
@state() accessor loading: boolean = false;
private audioElement: HTMLAudioElement | null = null;
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
:host {
display: block;
padding: 16px;
}
.view-section {
margin-bottom: 24px;
}
.box-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.box-selector label {
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.audio-player {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #1e293b;
border-radius: 8px;
margin-top: 16px;
}
.audio-player audio {
flex: 1;
height: 32px;
}
.audio-player .close-btn {
cursor: pointer;
color: #94a3b8;
font-size: 1.1rem;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s;
}
.audio-player .close-btn:hover {
background: #334155;
color: #e2e8f0;
}
.empty-state {
text-align: center;
padding: 48px 16px;
color: #64748b;
font-size: 0.9rem;
}
.empty-state .icon {
font-size: 2.5rem;
margin-bottom: 12px;
opacity: 0.5;
}
`,
];
// ---- lifecycle -----------------------------------------------------------
connectedCallback() {
super.connectedCallback();
this.rxSubscriptions.push({
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
} as any);
this.loadVoiceboxes();
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopAudio();
}
// ---- data loading --------------------------------------------------------
private async loadVoiceboxes() {
try {
const cfg = await appState.apiGetConfig();
const boxes: { id: string }[] = cfg.voiceboxes || [];
this.voiceboxIds = boxes.map((b) => b.id);
if (this.voiceboxIds.length > 0 && !this.selectedBoxId) {
this.selectedBoxId = this.voiceboxIds[0];
await this.loadMessages();
}
} catch {
// Config unavailable.
}
}
private async loadMessages() {
if (!this.selectedBoxId) {
this.messages = [];
return;
}
this.loading = true;
try {
const res = await fetch(`/api/voicemail/${encodeURIComponent(this.selectedBoxId)}`);
const data = await res.json();
this.messages = data.messages || [];
} catch {
this.messages = [];
}
this.loading = false;
}
private async selectBox(boxId: string) {
this.selectedBoxId = boxId;
this.stopAudio();
await this.loadMessages();
}
// ---- audio playback ------------------------------------------------------
private playMessage(msg: IVoicemailMessage) {
this.stopAudio();
const url = `/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/audio`;
const audio = new Audio(url);
this.audioElement = audio;
this.playingMessageId = msg.id;
audio.addEventListener('ended', () => {
this.playingMessageId = null;
// Auto-mark as heard after playback completes.
if (!msg.heard) {
this.markHeard(msg);
}
});
audio.addEventListener('error', () => {
this.playingMessageId = null;
deesCatalog.DeesToast.error('Failed to play audio');
});
audio.play().catch(() => {
this.playingMessageId = null;
});
}
private stopAudio() {
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.src = '';
this.audioElement = null;
}
this.playingMessageId = null;
}
// ---- message actions -----------------------------------------------------
private async markHeard(msg: IVoicemailMessage) {
try {
await fetch(`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/heard`, {
method: 'POST',
});
// Update local state without full reload.
this.messages = this.messages.map((m) =>
m.id === msg.id ? { ...m, heard: true } : m,
);
} catch {
deesCatalog.DeesToast.error('Failed to mark message as heard');
}
}
private async deleteMessage(msg: IVoicemailMessage) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Delete Voicemail',
width: 'small',
showCloseButton: true,
content: html`
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
Are you sure you want to delete the voicemail from
<strong style="color:#f87171;">${msg.callerName || msg.callerNumber}</strong>
(${formatDateTime(msg.timestamp)})?
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalRef: any) => { modalRef.destroy(); },
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalRef: any) => {
try {
await fetch(
`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}`,
{ method: 'DELETE' },
);
if (this.playingMessageId === msg.id) {
this.stopAudio();
}
this.messages = this.messages.filter((m) => m.id !== msg.id);
modalRef.destroy();
deesCatalog.DeesToast.success('Voicemail deleted');
} catch {
deesCatalog.DeesToast.error('Failed to delete voicemail');
}
},
},
],
});
}
// ---- stats tiles ---------------------------------------------------------
private getStatsTiles(): IStatsTile[] {
const total = this.messages.length;
const unheard = this.messages.filter((m) => !m.heard).length;
return [
{
id: 'total',
title: 'Total Messages',
value: total,
type: 'number',
icon: 'lucide:voicemail',
description: this.selectedBoxId ? `Box: ${this.selectedBoxId}` : 'No box selected',
},
{
id: 'unheard',
title: 'Unheard Messages',
value: unheard,
type: 'number',
icon: 'lucide:bell-ring',
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
description: unheard > 0 ? 'Needs attention' : 'All caught up',
},
];
}
// ---- table columns -------------------------------------------------------
private getColumns() {
return [
{
key: 'callerNumber',
header: 'Caller',
sortable: true,
renderer: (_val: string, row: IVoicemailMessage) => {
const display = row.callerName
? html`<span>${row.callerName}</span><br><span style="font-size:.75rem;color:#64748b">${row.callerNumber}</span>`
: html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${row.callerNumber}</span>`;
return html`<div>${display}</div>`;
},
},
{
key: 'timestamp',
header: 'Date/Time',
sortable: true,
value: (row: IVoicemailMessage) => formatDateTime(row.timestamp),
renderer: (val: string) =>
html`<span style="font-size:.85rem">${val}</span>`,
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
value: (row: IVoicemailMessage) => formatDuration(row.durationMs),
renderer: (val: string) =>
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${val}</span>`,
},
{
key: 'heard',
header: 'Status',
renderer: (val: boolean, row: IVoicemailMessage) => {
const isPlaying = this.playingMessageId === row.id;
if (isPlaying) {
return html`
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">Playing</span>
`;
}
const heard = val;
const color = heard ? '#71717a' : '#f59e0b';
const bg = heard ? '#3f3f46' : '#422006';
const label = heard ? 'Heard' : 'New';
return html`
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${label}</span>
`;
},
},
];
}
// ---- table actions -------------------------------------------------------
private getDataActions() {
return [
{
name: 'Play',
iconName: 'lucide:play',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
const msg = actionData.item as IVoicemailMessage;
if (this.playingMessageId === msg.id) {
this.stopAudio();
} else {
this.playMessage(msg);
}
},
},
{
name: 'Mark Heard',
iconName: 'lucide:check',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
const msg = actionData.item as IVoicemailMessage;
if (!msg.heard) {
await this.markHeard(msg);
deesCatalog.DeesToast.success('Marked as heard');
}
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
await this.deleteMessage(actionData.item as IVoicemailMessage);
},
},
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
type: ['header'] as any,
actionFunc: async () => {
await this.loadMessages();
deesCatalog.DeesToast.success('Messages refreshed');
},
},
];
}
// ---- render --------------------------------------------------------------
public render(): TemplateResult {
return html`
${this.voiceboxIds.length > 1 ? html`
<div class="box-selector">
<label>Voicebox</label>
<dees-input-dropdown
.key=${'voicebox'}
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
></dees-input-dropdown>
</div>
` : ''}
<div class="view-section">
<dees-statsgrid
.tiles=${this.getStatsTiles()}
.minTileWidth=${220}
.gap=${16}
></dees-statsgrid>
</div>
${this.messages.length === 0 && !this.loading ? html`
<div class="empty-state">
<div class="icon">&#9993;</div>
<div>No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}</div>
</div>
` : html`
<div class="view-section">
<dees-table
heading1="Voicemail"
heading2="${this.messages.length} message${this.messages.length !== 1 ? 's' : ''}"
dataName="voicemail"
.data=${this.messages}
.rowKey=${'id'}
.searchable=${true}
.columns=${this.getColumns()}
.dataActions=${this.getDataActions()}
></dees-table>
</div>
`}
${this.playingMessageId ? html`
<div class="audio-player">
<span style="color:#60a5fa;font-size:.8rem;font-weight:600;">Now playing</span>
<span style="flex:1"></span>
<span class="close-btn" @click=${() => this.stopAudio()}>&#10005;</span>
</div>
` : ''}
`;
}
}