live: calls in progress, dashboard list, admin hang up and transfer

CallTracker groups channels by Linkedid from channel events (loaded from
CoreShowChannels on connect); call.updated/call.removed go to admins.
The dashboard lists calls now with state and timer, hang up (confirm)
and transfer to an internal number (AMI Redirect of the caller), both
audited. e2e m7-t4: 3 concurrent calls match core show channels.

Task: M7-T4
This commit is contained in:
iamdoubz committed 2026-10-18 15:06:57 -05:00
1 parent 4b6b95090a
commit f7fc4ed70e
14 files changed
+744 -12

No files matched your search

+2
View File
@@ -29,6 +29,7 @@ import { announcementRoutes } from './routes/announcements.ts';
import { blockedRoutes } from './routes/blocked.ts';
import { callRoutes } from './routes/calls.ts';
import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -109,6 +110,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(blockedRoutes);
app.register(callRoutes);
app.register(recordingRoutes);
app.register(liveCallRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T4: CallTracker on the recorded M2 session (102 calls 101, they talk, hang up) and on a
// CoreShowChannels load.
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import type { ActiveCall } from '@selfphoneng/shared';
import { AmiParser, field } from '../ami/events.ts';
import { CallTracker, extOfChannel } from './calls.ts';
const MSGS = new AmiParser().push(
readFileSync(new URL('../../test/fixtures/ami/register-and-call.raw', import.meta.url), 'latin1'),
);
describe('CallTracker', () => {
it('follows a whole call: ringing, talking, gone', () => {
const seen: (ActiveCall | null)[] = [];
const t = new CallTracker((_l, c) => seen.push(c));
let talking: ActiveCall | undefined;
for (const m of MSGS) {
t.handle(m);
if (field(m, 'Event') === 'BridgeEnter') talking = t.list()[0];
}
const ringing = seen.find((c) => c?.channels.length === 2 && c.state === 'ringing');
expect(ringing).toBeDefined();
expect(talking).toMatchObject({
direction: 'internal',
state: 'talking',
from: { number: '102', name: 'Bob' },
to: { number: '101' },
extensions: ['102', '101'],
channels: ['PJSIP/102-00000000', 'PJSIP/101-00000001'],
recording: false,
});
expect(talking!.answeredAt).toBeDefined();
expect(t.list()).toEqual([]);
expect(seen.at(-1)).toBeNull();
});
it('loads calls already up from CoreShowChannels', () => {
const t = new CallTracker(() => undefined);
const ch = (Channel: string, extra: Record<string, string>) => ({
Event: 'CoreShowChannel',
Channel,
Linkedid: '1.0',
ChannelStateDesc: 'Up',
Duration: '00:01:05',
BridgeId: 'b1',
...extra,
});
t.load([
ch('PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000a', {
CallerIDNum: '13125559999',
CallerIDName: 'Mobile',
Application: 'Dial',
Uniqueid: '1.0',
}),
ch('PJSIP/101-0000000b', { CallerIDNum: '101', Application: 'AppDial', Uniqueid: '1.1' }),
{ Event: 'CoreShowChannelsComplete' },
]);
const [c] = t.list();
expect(c).toMatchObject({
direction: 'inbound',
state: 'talking',
from: { number: '13125559999', name: 'Mobile' },
to: { number: '101' },
});
expect(Date.now() - Date.parse(c!.startedAt)).toBeGreaterThan(64_000);
expect(t.channelsOf('1.0')).toHaveLength(2);
});
it('maps channel names to extensions', () => {
expect(extOfChannel('PJSIP/101-0000001a')).toBe('101');
expect(extOfChannel('PJSIP/101-web-0000001a')).toBe('101');
expect(extOfChannel('Local/102@sp-rg-member-00000003;2')).toBe('102');
expect(extOfChannel('PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000a')).toBeNull();
});
});
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Calls in progress (04 §5.1): every channel Asterisk tells us about, grouped by Linkedid. Loaded
// from CoreShowChannels on connect, then kept current from channel events.
import type { ActiveCall } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
type Channel = {
name: string;
linkedid: string;
/** ChannelStateDesc: Down, Ring, Ringing, Up, Busy… */
state: string;
app: string;
callerNum: string;
callerName: string;
connectedNum: string;
exten: string;
held: boolean;
bridged: boolean;
createdAt: number;
/** Uniqueid "epoch.seq": seq orders channels created in the same second. */
seq: number;
};
const isTrunk = (name: string) => /^PJSIP\/trunk-/.test(name);
/** PJSIP/101-…, PJSIP/101-web-…, Local/101@sp-rg-member-… → 101. */
export const extOfChannel = (name: string) =>
/^PJSIP\/(\d+)(?:-web)?-[0-9a-f]+$/.exec(name)?.[1] ??
/^Local\/(\d+)@sp-(?:rg|queue)-member-/.exec(name)?.[1] ??
null;
const unknown = (v: string | undefined) => (!v || v === '<unknown>' ? '' : v);
const IVR_APPS = new Set(['BackGround', 'Background', 'WaitExten', 'Read']);
export class CallTracker {
private readonly channels = new Map<string, Channel>();
/** Per linkedid: dialplan variables (SP_DID…) and when it was first answered. */
private readonly extra = new Map<
string,
{ did?: string; direction?: string; rec?: boolean; answeredAt?: number }
>();
private readonly emit: (linkedid: string, call: ActiveCall | null) => void;
constructor(emit: (linkedid: string, call: ActiveCall | null) => void) {
this.emit = emit;
}
list(): ActiveCall[] {
return [...new Set([...this.channels.values()].map((c) => c.linkedid))]
.map((l) => this.call(l))
.filter((c): c is ActiveCall => c !== null)
.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
}
channelsOf(linkedid: string): string[] {
return this.byLinkedid(linkedid).map((c) => c.name);
}
/** CoreShowChannel items (initial load). Duration is "HH:MM:SS". */
load(items: AmiMessage[]): void {
const before = new Set([...this.channels.values()].map((c) => c.linkedid));
this.channels.clear();
for (const m of items) {
if (field(m, 'Event') !== 'CoreShowChannel') continue;
const [h = 0, mi = 0, s = 0] = (field(m, 'Duration') ?? '0:0:0').split(':').map(Number);
this.upsert(m, { createdAt: Date.now() - ((h * 60 + mi) * 60 + s) * 1000 });
const c = this.channels.get(field(m, 'Channel') ?? '');
if (c) {
c.app = field(m, 'Application') ?? '';
c.bridged = Boolean(field(m, 'BridgeId'));
}
}
const now = new Set([...this.channels.values()].map((c) => c.linkedid));
for (const l of before) if (!now.has(l)) this.emit(l, null);
for (const l of now) this.emit(l, this.call(l));
}
handle(m: AmiMessage): void {
const ev = field(m, 'Event');
const name = field(m, 'Channel') ?? '';
const linkedid = field(m, 'Linkedid') ?? '';
switch (ev) {
case 'Newchannel':
this.upsert(m, { createdAt: Date.now() });
break;
case 'Newstate':
case 'NewCallerid':
case 'NewConnectedLine':
this.upsert(m);
break;
case 'Newexten': {
const c = this.channels.get(name);
if (c) c.app = field(m, 'Application') ?? c.app;
else return;
break;
}
case 'BridgeEnter':
case 'BridgeLeave': {
const c = this.channels.get(name);
if (!c) return;
c.bridged = ev === 'BridgeEnter';
const x = this.extraOf(c.linkedid);
if (
c.bridged &&
x.answeredAt === undefined &&
this.byLinkedid(c.linkedid).filter((y) => y.bridged).length >= 2
)
x.answeredAt = Date.now();
break;
}
case 'Hold':
case 'Unhold': {
const c = this.channels.get(name);
if (!c) return;
c.held = ev === 'Hold';
break;
}
case 'VarSet': {
const v = field(m, 'Variable') ?? '';
const value = field(m, 'Value') ?? '';
if (!/^(?:__)?SP_(?:DID|DIRECTION|REC)$/.test(v) || !linkedid) return;
const x = this.extraOf(linkedid);
if (v.endsWith('DID')) x.did = value || x.did;
else if (v.endsWith('DIRECTION'))
x.direction = x.direction ?? value; // the first one wins
else x.rec = Boolean(value);
break;
}
case 'Hangup': {
const c = this.channels.get(name);
if (!c) return;
this.channels.delete(name);
if (!this.byLinkedid(c.linkedid).length) {
this.extra.delete(c.linkedid);
this.emit(c.linkedid, null);
return;
}
this.emit(c.linkedid, this.call(c.linkedid));
return;
}
default:
return;
}
const l = this.channels.get(name)?.linkedid ?? linkedid;
if (l) this.emit(l, this.call(l));
}
private extraOf(linkedid: string) {
let x = this.extra.get(linkedid);
if (!x) this.extra.set(linkedid, (x = {}));
return x;
}
private byLinkedid(linkedid: string) {
return [...this.channels.values()]
.filter((c) => c.linkedid === linkedid)
.sort((a, b) => a.createdAt - b.createdAt || a.seq - b.seq);
}
private upsert(m: AmiMessage, init: Partial<Channel> = {}) {
const name = field(m, 'Channel') ?? '';
if (!name) return;
const prev = this.channels.get(name);
this.channels.set(name, {
name,
linkedid: field(m, 'Linkedid') ?? prev?.linkedid ?? name,
state: field(m, 'ChannelStateDesc') ?? prev?.state ?? 'Down',
app: prev?.app ?? '',
callerNum: unknown(field(m, 'CallerIDNum')) || (prev?.callerNum ?? ''),
callerName: unknown(field(m, 'CallerIDName')) || (prev?.callerName ?? ''),
connectedNum: unknown(field(m, 'ConnectedLineNum')) || (prev?.connectedNum ?? ''),
exten: field(m, 'Exten') ?? prev?.exten ?? '',
held: prev?.held ?? false,
bridged: prev?.bridged ?? false,
createdAt: prev?.createdAt ?? init.createdAt ?? Date.now(),
seq: prev?.seq ?? Number(field(m, 'Uniqueid')?.split('.')[1] ?? 0),
});
}
private call(linkedid: string): ActiveCall | null {
const chans = this.byLinkedid(linkedid);
const first = chans[0];
if (!first) return null;
const x = this.extra.get(linkedid) ?? {};
const exts = [
...new Set(chans.map((c) => extOfChannel(c.name)).filter((e): e is string => !!e)),
];
const direction =
(x.direction as ActiveCall['direction'] | undefined) ??
(isTrunk(first.name)
? 'inbound'
: chans.some((c) => isTrunk(c.name))
? 'outbound'
: 'internal');
const callerExt = extOfChannel(first.name);
const otherExt = exts.find((e) => e !== callerExt);
const apps = new Set(chans.map((c) => c.app));
const state: ActiveCall['state'] = chans.some((c) => c.held)
? 'hold'
: apps.has('VoiceMail')
? 'voicemail'
: [...apps].some((a) => IVR_APPS.has(a))
? 'ivr'
: apps.has('Queue')
? 'queued'
: apps.has('Park') || apps.has('ParkedCall')
? 'parked'
: chans.filter((c) => c.bridged && c.state === 'Up').length >= 2
? 'talking'
: 'ringing';
return {
linkedid,
startedAt: new Date(first.createdAt).toISOString(),
...(x.answeredAt ? { answeredAt: new Date(x.answeredAt).toISOString() } : {}),
direction,
from: {
number: callerExt ?? first.callerNum,
...(first.callerName ? { name: first.callerName } : {}),
},
to: {
number:
(direction === 'inbound' ? x.did : undefined) ??
otherExt ??
(first.connectedNum || first.exten),
},
...(x.did ? { did: x.did } : {}),
state,
extensions: exts,
channels: chans.map((c) => c.name),
recording: Boolean(x.rec),
};
}
}
+4 -2
View File
@@ -38,7 +38,9 @@ export async function liveRoutes(app: FastifyInstance) {
? `voicemail:${m.data.id}`
: m.type === 'toast'
? `toast:${m.data.message}`
: m.type;
: m.type === 'call.updated' || m.type === 'call.removed'
? `call:${m.data.linkedid}` // a removal replaces a pending update
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new' || m.type === 'toast'
@@ -46,7 +48,7 @@ export async function liveRoutes(app: FastifyInstance) {
: c.admin ||
(m.type === 'emergency.call'
? m.data.audienceUserIds.includes(c.userId)
: !m.type.startsWith('trunk.'));
: !m.type.startsWith('trunk.') && !m.type.startsWith('call.'));
app.live.on('message', (m) => {
for (const c of clients) if (maySee(c, m)) c.pending.set(keyOf(m), m);
+20 -2
View File
@@ -15,6 +15,7 @@ import type { AmiClient } from '../ami/client.ts';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, trunks, userRuntimeState } from '../db/schema.ts';
import { CallTracker } from './calls.ts';
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
export function stateFromStatus(status: number): ExtensionState {
@@ -61,6 +62,13 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
private readonly exts = new Map<string, ExtensionLive & { contacts: ContactLive[] }>();
private readonly trunks = new Map<string, TrunkLive>();
amiConnected = false;
/** Calls in progress (admins see them on the dashboard). */
readonly calls = new CallTracker((linkedid, call) =>
this.emit(
'message',
call ? { type: 'call.updated', data: call } : { type: 'call.removed', data: { linkedid } },
),
);
private readonly db: Db;
private readonly ami: Ami;
@@ -75,7 +83,14 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
snapshot(admin: boolean): LiveSnapshot {
return {
extensions: [...this.exts.values()].map((e) => this.view(e, admin)),
...(admin ? { trunks: [...this.trunks.values()].map((t) => ({ ...t })) } : {}),
...(admin
? {
trunks: [...this.trunks.values()].map((t) => ({ ...t })),
calls: this.calls.list(),
queues: [],
parked: [],
}
: {}),
ami: { connected: this.amiConnected },
};
}
@@ -221,10 +236,12 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
await this.refreshRegistrations();
await Promise.all([...this.trunks.keys()].map((id) => this.refreshReachable(id)));
for (const e of this.exts.values()) e.contacts = [];
const [states, contacts] = await Promise.all([
const [states, contacts, channels] = await Promise.all([
this.ami.actionList({ Action: 'ExtensionStateList' }),
this.ami.actionList({ Action: 'PJSIPShowContacts' }),
this.ami.actionList({ Action: 'CoreShowChannels' }),
]);
this.calls.load(channels);
for (const m of states) this.onExtensionStatus(m, false);
for (const m of contacts) {
const endpoint = field(m, 'Endpoint') ?? field(m, 'EndpointName') ?? '';
@@ -245,6 +262,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
}
handleEvent(m: AmiMessage): void {
this.calls.handle(m);
switch (field(m, 'Event')) {
case 'ExtensionStatus':
return this.onExtensionStatus(m, true);
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin actions on calls in progress (04 §7.3): hang up, transfer. Admin only.
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { InternalNumber } from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { HttpError, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { ownerOf } from '../services/numbering.ts';
const Params = z.object({ linkedid: z.string().regex(/^[\d.]+$/, 'Not a call id') });
const TransferBody = z.object({ target: InternalNumber });
export async function liveCallRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const callOf = (linkedid: string) => {
if (!app.ami.isConnected())
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
const call = app.live.calls.list().find((c) => c.linkedid === linkedid);
if (!call) throw notFound('That call has ended');
return call;
};
app.post('/api/live/calls/:linkedid/hangup', async (req) => {
const { linkedid } = Params.parse(req.params);
const call = callOf(linkedid);
for (const channel of call.channels)
await app.ami.action({ Action: 'Hangup', Channel: channel }).catch(() => undefined); // gone already
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'call_hangup',
summary: `Hung up a call ${call.from.number} → ${call.to.number}`,
});
return { ok: true };
});
/** Sends the calling party (the call's first channel) to an internal number. */
app.post('/api/live/calls/:linkedid/transfer', async (req) => {
const { linkedid } = Params.parse(req.params);
const { target } = TransferBody.parse(req.body);
const call = callOf(linkedid);
if (!(await ownerOf(db, target))) throw notFound(`${target} isn't an extension or group here`);
await app.ami.action({
Action: 'Redirect',
Channel: call.channels[0]!,
Context: 'sp-local-numbers',
Exten: target,
Priority: '1',
});
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'call_transfer',
summary: `Transferred ${call.from.number} (call to ${call.to.number}) to ${target}`,
});
return { ok: true };
});
}
+8 -1
View File
@@ -160,7 +160,14 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
await until(() => admin.msgs.length === 1 && user.msgs.length === 1);
expect(admin.msgs[0]).toEqual({
type: 'snapshot',
data: { extensions: [], trunks: [], ami: { connected: false } },
data: {
extensions: [],
trunks: [],
calls: [],
queues: [],
parked: [],
ami: { connected: false },
},
});
// A config change adds an extension: one extension.updated per client, contacts only for admins.
+6
View File
@@ -290,3 +290,9 @@ export const recordingsApi = {
`/api/recordings/${id}/audio${download ? '?download=1' : ''}`,
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/recordings/${id}`),
};
export const liveApi = {
hangup: (linkedid: string) => api<{ ok: true }>('POST', `/api/live/calls/${linkedid}/hangup`, {}),
transfer: (linkedid: string, target: string) =>
api<{ ok: true }>('POST', `/api/live/calls/${linkedid}/transfer`, { target }),
};
+17 -1
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
import { create } from 'zustand';
import type { EmergencyCall, ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
import type {
ActiveCall,
EmergencyCall,
ExtensionLive,
LiveMessage,
TrunkLive,
} from '@selfphoneng/shared';
type LiveStore = {
/** WebSocket state: 'connecting' before the first snapshot. */
@@ -10,6 +16,8 @@ type LiveStore = {
extensions: Record<string, ExtensionLive>;
/** Admins only (empty for users). */
trunks: Record<string, TrunkLive>;
/** Admins only: calls in progress by linkedid. */
calls: Record<string, ActiveCall>;
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
emergencies: EmergencyCall[];
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
@@ -23,6 +31,7 @@ export const useLive = create<LiveStore>(() => ({
amiConnected: false,
extensions: {},
trunks: {},
calls: {},
emergencies: [],
voicemailSeq: 0,
toasts: [],
@@ -37,6 +46,7 @@ function apply(m: LiveMessage) {
amiConnected: m.data.ami.connected,
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
trunks: Object.fromEntries((m.data.trunks ?? []).map((t) => [t.trunkId, t])),
calls: Object.fromEntries((m.data.calls ?? []).map((c) => [c.linkedid, c])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -50,6 +60,12 @@ function apply(m: LiveMessage) {
const { [m.data.trunkId]: _gone, ...rest } = s.trunks;
return { trunks: rest };
}
case 'call.updated':
return { calls: { ...s.calls, [m.data.linkedid]: m.data } };
case 'call.removed': {
const { [m.data.linkedid]: _gone, ...rest } = s.calls;
return { calls: rest };
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
case 'toast':
+129 -5
View File
@@ -1,10 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks and extensions online. Calls and queues
// arrive with their features (M7, M8).
import { useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// and extensions online. Queues and parked calls arrive with M8.
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { settingsApi } from '../../api/client.ts';
import { liveApi, settingsApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
@@ -58,6 +62,8 @@ export function DashboardPage() {
</Card>
</div>
<LiveCalls />
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
@@ -121,3 +127,121 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
</ul>
);
}
const STATE_TEXT: Record<ActiveCall['state'], string> = {
ringing: 'Ringing',
talking: 'Talking',
hold: 'On hold',
queued: 'In a queue',
ivr: 'In the menu',
voicemail: 'Leaving voicemail',
parked: 'Parked',
};
function useNow() {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
return now;
}
const since = (iso: string, now: number) => {
const s = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
/** Calls in progress, from the live socket (04 §5.1). */
function LiveCalls() {
const calls = Object.values(useLive((s) => s.calls)).sort((a, b) =>
a.startedAt.localeCompare(b.startedAt),
);
const now = useNow();
const [hangingUp, setHangingUp] = useState<ActiveCall | null>(null);
const [transferTo, setTransferTo] = useState<Record<string, string>>({});
const hangup = useMutation({
mutationFn: (c: ActiveCall) => liveApi.hangup(c.linkedid),
onSuccess: () => setHangingUp(null),
});
const transfer = useMutation({
mutationFn: ({ c, target }: { c: ActiveCall; target: string }) =>
liveApi.transfer(c.linkedid, target),
});
return (
<div>
<h2 className="mb-2 font-medium">Calls now ({calls.length})</h2>
{transfer.error && <Alert>{transfer.error.message}</Alert>}
{calls.length === 0 ? (
<p className="text-sm text-zinc-500">No calls right now.</p>
) : (
<ul
aria-label="Calls now"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
>
{calls.map((c) => (
<li
key={c.linkedid}
data-linkedid={c.linkedid}
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm"
>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span>
{c.from.name && <span className="text-zinc-500"> {fmtNumber(c.from.number)}</span>}
{' → '}
{fmtNumber(c.to.number)}
<span className="text-zinc-500">
{' '}
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
{c.recording && ' · recording'}
</span>
<span className="block font-mono text-xs text-zinc-500" data-channels>
{c.channels.join(' ')}
</span>
</span>
<form
className="flex gap-1"
onSubmit={(e) => {
e.preventDefault();
const target = transferTo[c.linkedid]?.trim();
if (target) transfer.mutate({ c, target });
}}
>
<label htmlFor={`xfer-${c.linkedid}`} className="sr-only">
Transfer to
</label>
<input
id={`xfer-${c.linkedid}`}
className={`${inputClass} w-24`}
inputMode="numeric"
placeholder="Ext"
value={transferTo[c.linkedid] ?? ''}
onChange={(e) => setTransferTo({ ...transferTo, [c.linkedid]: e.target.value })}
/>
<Button type="submit">Transfer</Button>
</form>
<Button variant="danger" onClick={() => setHangingUp(c)}>
Hang up
</Button>
</li>
))}
</ul>
)}
<ConfirmDialog
open={hangingUp !== null}
title="Hang up this call?"
message={
<p>
{hangingUp &&
`${hangingUp.from.name ?? fmtNumber(hangingUp.from.number)} → ${fmtNumber(hangingUp.to.number)}`}
</p>
}
confirmLabel="Hang up"
busy={hangup.isPending}
error={hangup.error?.message}
onCancel={() => setHangingUp(null)}
onConfirm={() => hangingUp && hangup.mutate(hangingUp)}
/>
</div>
);
}