Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
|
import { deesCatalog } from '../plugins.js';
|
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
|
import { appState, type IAppState, type ICallStatus, type ICallHistoryEntry, type ILegStatus, type IDeviceStatus } from '../state/appstate.js';
|
|
import { viewHostCss } from './shared/index.js';
|
|
|
|
const STATE_LABELS: Record<string, string> = {
|
|
'setting-up': 'Setting Up',
|
|
'ringing': 'Ringing',
|
|
'connected': 'Connected',
|
|
'on-hold': 'On Hold',
|
|
'transferring': 'Transferring',
|
|
'terminating': 'Hanging Up',
|
|
'terminated': 'Ended',
|
|
};
|
|
|
|
function stateBadgeStyle(s: string): string {
|
|
if (s.includes('ringing') || s === 'setting-up') return 'background:#854d0e;color:#fbbf24';
|
|
if (s === 'connected') return 'background:#166534;color:#4ade80';
|
|
if (s === 'on-hold') return 'background:#1e3a5f;color:#38bdf8';
|
|
return 'background:#7f1d1d;color:#f87171';
|
|
}
|
|
|
|
function legTypeBadgeStyle(type: string): string {
|
|
if (type === 'sip-device') return 'background:#1e3a5f;color:#38bdf8';
|
|
if (type === 'sip-provider') return 'background:#4a1d7a;color:#c084fc';
|
|
if (type === 'webrtc') return 'background:#065f46;color:#34d399';
|
|
return 'background:#374151;color:#9ca3af';
|
|
}
|
|
|
|
const LEG_TYPE_LABELS: Record<string, string> = {
|
|
'sip-device': 'SIP Device',
|
|
'sip-provider': 'SIP Provider',
|
|
'webrtc': 'WebRTC',
|
|
};
|
|
|
|
function directionIcon(dir: string): string {
|
|
if (dir === 'inbound') return '\u2199';
|
|
if (dir === 'outbound') return '\u2197';
|
|
return '\u2194';
|
|
}
|
|
|
|
function fmtDuration(sec: number): string {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
function fmtTime(ts: number): string {
|
|
const d = new Date(ts);
|
|
return d.toLocaleString(undefined, {
|
|
month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
});
|
|
}
|
|
|
|
@customElement('sipproxy-view-calls')
|
|
export class SipproxyViewCalls extends DeesElement {
|
|
@state() accessor appData: IAppState = appState.getState();
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
viewHostCss,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
padding: 16px;
|
|
}
|
|
|
|
.view-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.calls-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
/* Active call tile content */
|
|
.call-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.direction-icon {
|
|
font-size: 1.1rem;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 6px;
|
|
background: var(--dees-color-bg-primary, #0f172a);
|
|
border: 1px solid var(--dees-color-border-default, #334155);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.call-parties {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 0.85rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.call-parties .label {
|
|
color: var(--dees-color-text-secondary, #64748b);
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.call-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
font-size: 0.6rem;
|
|
padding: 2px 7px;
|
|
border-radius: 3px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.02em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.call-duration {
|
|
font-size: 0.8rem;
|
|
color: var(--dees-color-text-secondary, #94a3b8);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.call-id {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.65rem;
|
|
color: #475569;
|
|
padding: 0 16px 8px;
|
|
}
|
|
|
|
.call-body {
|
|
padding: 12px 16px 16px;
|
|
}
|
|
|
|
.legs-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.75rem;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.legs-table th {
|
|
text-align: left;
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
font-size: 0.65rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 6px 8px;
|
|
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
|
}
|
|
|
|
.legs-table td {
|
|
padding: 8px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.7rem;
|
|
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.legs-table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 6px 14px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn:hover { opacity: 0.85; }
|
|
.btn:active { opacity: 0.7; }
|
|
.btn-danger { background: #dc2626; color: #fff; }
|
|
.btn-primary { background: #2563eb; color: #fff; }
|
|
.btn-secondary { background: #334155; color: #e2e8f0; }
|
|
|
|
.btn-remove {
|
|
background: transparent;
|
|
color: #f87171;
|
|
border: 1px solid #7f1d1d;
|
|
padding: 3px 10px;
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 16px;
|
|
color: #64748b;
|
|
}
|
|
.empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
|
|
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
|
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
|
|
`,
|
|
];
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.rxSubscriptions.push({
|
|
unsubscribe: appState.subscribe((s) => {
|
|
this.appData = s;
|
|
}),
|
|
} as any);
|
|
}
|
|
|
|
private async handleAddParticipant(call: ICallStatus) {
|
|
const devices = this.appData.devices?.filter((d) => d.connected) || [];
|
|
let selectedDeviceId = devices.length > 0 ? devices[0].id : '';
|
|
|
|
await deesCatalog.DeesModal.createAndShow({
|
|
heading: 'Add Participant',
|
|
width: 'small',
|
|
showCloseButton: true,
|
|
content: html`
|
|
<div style="padding: 0.5rem 0;">
|
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
|
Select Device
|
|
</div>
|
|
<select
|
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
|
@change=${(e: Event) => {
|
|
selectedDeviceId = (e.target as HTMLSelectElement).value;
|
|
}}
|
|
>
|
|
${devices.map(
|
|
(d) => html`<option value=${d.id}>${d.displayName}${d.isBrowser ? ' (Browser)' : ''}</option>`,
|
|
)}
|
|
</select>
|
|
${devices.length === 0
|
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No connected devices available.</div>`
|
|
: ''}
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Add',
|
|
iconName: 'lucide:plus',
|
|
action: async (modalRef: any) => {
|
|
if (!selectedDeviceId) return;
|
|
const res = await appState.apiAddLeg(call.id, selectedDeviceId);
|
|
if (res.ok) {
|
|
deesCatalog.DeesToast.success('Participant added');
|
|
} else {
|
|
deesCatalog.DeesToast.error('Failed to add participant');
|
|
}
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalRef: any) => {
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async handleAddExternal(call: ICallStatus) {
|
|
const providers = this.appData.providers?.filter((p) => p.registered) || [];
|
|
let number = '';
|
|
let selectedProviderId = providers.length > 0 ? providers[0].id : '';
|
|
|
|
await deesCatalog.DeesModal.createAndShow({
|
|
heading: 'Add External Participant',
|
|
width: 'small',
|
|
showCloseButton: true,
|
|
content: html`
|
|
<div style="padding: 0.5rem 0;">
|
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
|
Phone Number
|
|
</div>
|
|
<input
|
|
type="tel"
|
|
placeholder="Enter number to dial..."
|
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.95rem;font-family:'JetBrains Mono',monospace;outline:none;box-sizing:border-box;margin-bottom:12px;"
|
|
@input=${(e: InputEvent) => { number = (e.target as HTMLInputElement).value; }}
|
|
>
|
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
|
Via Provider
|
|
</div>
|
|
<select
|
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
|
@change=${(e: Event) => { selectedProviderId = (e.target as HTMLSelectElement).value; }}
|
|
>
|
|
${providers.map(
|
|
(p) => html`<option value=${p.id}>${p.displayName}</option>`,
|
|
)}
|
|
</select>
|
|
${providers.length === 0
|
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No registered providers available.</div>`
|
|
: ''}
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Dial',
|
|
iconName: 'lucide:phoneOutgoing',
|
|
action: async (modalRef: any) => {
|
|
if (!number.trim()) {
|
|
deesCatalog.DeesToast.error('Enter a phone number');
|
|
return;
|
|
}
|
|
const res = await appState.apiAddExternal(call.id, number.trim(), selectedProviderId || undefined);
|
|
if (res.ok) {
|
|
deesCatalog.DeesToast.success(`Dialing ${number}...`);
|
|
} else {
|
|
deesCatalog.DeesToast.error('Failed to dial external number');
|
|
}
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalRef: any) => {
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async handleRemoveLeg(call: ICallStatus, leg: ILegStatus) {
|
|
const res = await appState.apiRemoveLeg(call.id, leg.id);
|
|
if (res.ok) {
|
|
deesCatalog.DeesToast.success('Leg removed');
|
|
} else {
|
|
deesCatalog.DeesToast.error('Failed to remove leg');
|
|
}
|
|
}
|
|
|
|
private async handleTransfer(call: ICallStatus) {
|
|
let targetCallId = '';
|
|
let targetLegId = '';
|
|
|
|
const otherCalls =
|
|
this.appData.calls?.filter((c) => c.id !== call.id && c.state !== 'terminated') || [];
|
|
|
|
await deesCatalog.DeesModal.createAndShow({
|
|
heading: 'Transfer Call',
|
|
width: 'small',
|
|
showCloseButton: true,
|
|
content: html`
|
|
<div style="padding: 0.5rem 0;">
|
|
<div style="margin-bottom: 1rem;">
|
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
|
Target Call ID
|
|
</div>
|
|
<select
|
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
|
@change=${(e: Event) => {
|
|
targetCallId = (e.target as HTMLSelectElement).value;
|
|
}}
|
|
>
|
|
<option value="">Select a call...</option>
|
|
${otherCalls.map(
|
|
(c) =>
|
|
html`<option value=${c.id}>
|
|
${c.direction} - ${c.callerNumber || '?'} -> ${c.calleeNumber || '?'}
|
|
</option>`,
|
|
)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
|
Leg ID to transfer
|
|
</div>
|
|
<select
|
|
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
|
@change=${(e: Event) => {
|
|
targetLegId = (e.target as HTMLSelectElement).value;
|
|
}}
|
|
>
|
|
<option value="">Select a leg...</option>
|
|
${call.legs.map(
|
|
(l) =>
|
|
html`<option value=${l.id}>${LEG_TYPE_LABELS[l.type] || l.type} - ${l.state}</option>`,
|
|
)}
|
|
</select>
|
|
</div>
|
|
${otherCalls.length === 0
|
|
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.75rem;">
|
|
No other active calls to transfer to.
|
|
</div>`
|
|
: ''}
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Transfer',
|
|
iconName: 'lucide:arrow-right-left',
|
|
action: async (modalRef: any) => {
|
|
if (!targetCallId || !targetLegId) {
|
|
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
|
return;
|
|
}
|
|
const res = await appState.apiTransfer(call.id, targetLegId, targetCallId);
|
|
if (res.ok) {
|
|
deesCatalog.DeesToast.success('Transfer initiated');
|
|
} else {
|
|
deesCatalog.DeesToast.error('Transfer failed');
|
|
}
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalRef: any) => {
|
|
modalRef.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async handleHangup(call: ICallStatus) {
|
|
await appState.apiHangup(call.id);
|
|
}
|
|
|
|
private getHistoryColumns() {
|
|
return [
|
|
{
|
|
key: 'direction',
|
|
header: 'Direction',
|
|
renderer: (val: string) => {
|
|
return html`<span style="margin-right:6px">${directionIcon(val)}</span><span class="badge" style="background:#374151;color:#9ca3af">${val}</span>`;
|
|
},
|
|
},
|
|
{
|
|
key: 'callerNumber',
|
|
header: 'From',
|
|
renderer: (val: string | null) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
|
},
|
|
{
|
|
key: 'calleeNumber',
|
|
header: 'To',
|
|
renderer: (val: string | null) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
|
},
|
|
{
|
|
key: 'providerUsed',
|
|
header: 'Provider',
|
|
renderer: (val: string | null) => val || '-',
|
|
},
|
|
{
|
|
key: 'startedAt',
|
|
header: 'Time',
|
|
renderer: (val: number) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtTime(val)}</span>`,
|
|
},
|
|
{
|
|
key: 'duration',
|
|
header: 'Duration',
|
|
renderer: (val: number) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtDuration(val)}</span>`,
|
|
},
|
|
];
|
|
}
|
|
|
|
private renderCallCard(call: ICallStatus): TemplateResult {
|
|
return html`
|
|
<dees-tile>
|
|
<div slot="header">
|
|
<div class="call-header">
|
|
<div class="direction-icon">${directionIcon(call.direction)}</div>
|
|
<div class="call-parties">
|
|
${call.callerNumber
|
|
? html`<span class="label">From</span>${call.callerNumber}`
|
|
: ''}
|
|
${call.callerNumber && call.calleeNumber ? html` → ` : ''}
|
|
${call.calleeNumber
|
|
? html`<span class="label">To</span>${call.calleeNumber}`
|
|
: ''}
|
|
</div>
|
|
<div class="call-meta">
|
|
<span class="badge" style="${stateBadgeStyle(call.state)}">
|
|
${STATE_LABELS[call.state] || call.state}
|
|
</span>
|
|
${call.providerUsed
|
|
? html`<span class="badge" style="background:#374151;color:#9ca3af">${call.providerUsed}</span>`
|
|
: ''}
|
|
<span class="call-duration">${fmtDuration(call.duration)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="call-id">${call.id}</div>
|
|
<div class="call-body">
|
|
${call.legs.length
|
|
? html`
|
|
<table class="legs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>State</th>
|
|
<th>Remote</th>
|
|
<th>Port</th>
|
|
<th>Codec</th>
|
|
<th>Pkts In</th>
|
|
<th>Pkts Out</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${call.legs.map(
|
|
(leg) => html`
|
|
<tr>
|
|
<td>
|
|
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
|
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
|
${leg.state}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${leg.remoteMedia
|
|
? `${leg.remoteMedia.address}:${leg.remoteMedia.port}`
|
|
: '--'}
|
|
</td>
|
|
<td>${leg.rtpPort ?? '--'}</td>
|
|
<td>
|
|
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
|
</td>
|
|
<td>${leg.pktReceived}</td>
|
|
<td>${leg.pktSent}</td>
|
|
<td>
|
|
<button
|
|
class="btn btn-remove"
|
|
@click=${() => this.handleRemoveLeg(call, leg)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`,
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
`
|
|
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
|
No legs
|
|
</div>`}
|
|
|
|
<div class="card-actions">
|
|
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
|
Add Device
|
|
</button>
|
|
<button class="btn btn-primary" @click=${() => this.handleAddExternal(call)}>
|
|
Add External
|
|
</button>
|
|
<button class="btn btn-secondary" @click=${() => this.handleTransfer(call)}>
|
|
Transfer
|
|
</button>
|
|
<button class="btn btn-danger" @click=${() => this.handleHangup(call)}>
|
|
Hang Up
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dees-tile>
|
|
`;
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
const { appData } = this;
|
|
const activeCalls = appData.calls?.filter((c) => c.state !== 'terminated') || [];
|
|
const history = appData.callHistory || [];
|
|
const inboundCount = activeCalls.filter((c) => c.direction === 'inbound').length;
|
|
const outboundCount = activeCalls.filter((c) => c.direction === 'outbound').length;
|
|
|
|
const tiles: IStatsTile[] = [
|
|
{
|
|
id: 'active',
|
|
title: 'Active Calls',
|
|
value: activeCalls.length,
|
|
type: 'number',
|
|
icon: 'lucide:phone',
|
|
color: 'hsl(142.1 76.2% 36.3%)',
|
|
description: activeCalls.length === 1 ? '1 call' : `${activeCalls.length} calls`,
|
|
},
|
|
{
|
|
id: 'inbound',
|
|
title: 'Inbound',
|
|
value: inboundCount,
|
|
type: 'number',
|
|
icon: 'lucide:phone-incoming',
|
|
description: 'Incoming calls',
|
|
},
|
|
{
|
|
id: 'outbound',
|
|
title: 'Outbound',
|
|
value: outboundCount,
|
|
type: 'number',
|
|
icon: 'lucide:phone-outgoing',
|
|
description: 'Outgoing calls',
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<div class="view-section">
|
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
|
</div>
|
|
|
|
<div class="view-section">
|
|
<div class="calls-list">
|
|
${activeCalls.length > 0
|
|
? activeCalls.map((call) => this.renderCallCard(call))
|
|
: html`
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📞</div>
|
|
<div class="empty-state-text">No active calls</div>
|
|
<div class="empty-state-sub">
|
|
Calls will appear here when they are in progress
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="view-section">
|
|
<dees-table
|
|
heading1="Call History"
|
|
heading2="${history.length} calls"
|
|
dataName="calls"
|
|
.data=${history}
|
|
.rowKey=${'id'}
|
|
.searchable=${true}
|
|
.columns=${this.getHistoryColumns()}
|
|
></dees-table>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|