parking: parked calls live, on the dashboard and Home, pick up from Home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s

res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.

A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.

Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).

CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.

Task: M8-T2
This commit is contained in:
iamdoubz committed 2026-10-19 04:23:21 -05:00
1 parent 2d9f8ed99e
commit 863a9d121c
21 files changed
+635 -23

No files matched your search

+27
View File
@@ -45,6 +45,33 @@ describe('CallTracker', () => {
expect(t.list()).toEqual([]);
});
it('parked: from the parking events, not the channel app (a picked-up call talks)', () => {
const t = new CallTracker(() => undefined);
const caller = {
Channel: 'PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016',
Linkedid: '3.0',
Uniqueid: '3.0',
};
const picker = { Channel: 'PJSIP/103-00000018', Linkedid: '3.0', Uniqueid: '3.1' };
t.handle({
Event: 'Newchannel',
ChannelStateDesc: 'Up',
CallerIDNum: '13125559999',
...caller,
});
// a blind transfer to 700 parks it without a Park() Newexten
t.handle({ Event: 'ParkedCall', ParkeeLinkedid: '3.0', ParkingSpace: '701' });
expect(t.list()[0]!.state).toBe('parked');
t.handle({ Event: 'Newchannel', ChannelStateDesc: 'Up', ...picker });
t.handle({ Event: 'Newexten', Application: 'ParkedCall', ...picker });
t.handle({ Event: 'UnParkedCall', ParkeeLinkedid: '3.0', ParkingSpace: '701' });
t.handle({ Event: 'BridgeEnter', ...caller });
t.handle({ Event: 'BridgeEnter', ...picker });
expect(t.list()[0]!.state).toBe('talking');
t.handle({ Event: 'ParkedCall', ParkeeLinkedid: '9.9' }); // a call we don't know: ignored
expect(t.list()).toHaveLength(1);
});
it('loads calls already up from CoreShowChannels', () => {
const t = new CallTracker(() => undefined);
const ch = (Channel: string, extra: Record<string, string>) => ({
+17 -2
View File
@@ -35,7 +35,7 @@ export class CallTracker {
/** 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 }
{ did?: string; direction?: string; rec?: boolean; answeredAt?: number; parked?: boolean }
>();
private readonly emit: (linkedid: string, call: ActiveCall | null) => void;
@@ -127,6 +127,14 @@ export class CallTracker {
else x.rec = Boolean(value);
break;
}
// A blind transfer to 700 parks without running Park() in the dialplan, so the parking
// events (not the channel's application) say whether a call is parked.
case 'ParkedCall':
case 'UnParkedCall':
case 'ParkedCallTimeOut':
case 'ParkedCallGiveUp':
this.setParked(field(m, 'ParkeeLinkedid') ?? '', ev === 'ParkedCall');
return;
case 'Hangup': {
const c = this.channels.get(name);
if (!c) return;
@@ -146,6 +154,13 @@ export class CallTracker {
if (l) this.emit(l, this.call(l));
}
/** Also used after the initial load, with the ParkedCalls list. */
setParked(linkedid: string, parked: boolean): void {
if (!this.byLinkedid(linkedid).length) return;
this.extraOf(linkedid).parked = parked;
this.emit(linkedid, this.call(linkedid));
}
private extraOf(linkedid: string) {
let x = this.extra.get(linkedid);
if (!x) this.extra.set(linkedid, (x = {}));
@@ -204,7 +219,7 @@ export class CallTracker {
? 'ivr'
: apps.has('Queue')
? 'queued'
: apps.has('Park') || apps.has('ParkedCall')
: x.parked || apps.has('Park')
? 'parked'
: chans.filter((c) => c.bridged && c.state === 'Up').length >= 2
? 'talking'
+3 -1
View File
@@ -46,7 +46,9 @@ export async function liveRoutes(app: FastifyInstance) {
? `call:${m.data.linkedid}` // a removal replaces a pending update
: m.type === 'queue.updated' || m.type === 'queue.removed'
? `queue:${m.data.number}`
: m.type;
: m.type === 'parked.updated' || m.type === 'parked.removed'
? `parked:${m.data.slot}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new' || m.type === 'toast'
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Events trimmed from a real Asterisk 24 park and pickup (cdr-record.ts, park-pickup).
import { describe, expect, it } from 'vitest';
import type { ParkedLive } from '@selfphoneng/shared';
import { ParkingTracker } from './parking.ts';
const PARKEE = {
ParkeeChannel: 'PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016',
ParkeeCallerIDNum: '13125559999',
ParkeeCallerIDName: 'Mobile',
ParkeeUniqueid: '1792400815.54',
ParkeeLinkedid: '1792400815.54',
ParkerDialString: 'PJSIP/101',
Parkinglot: 'default',
ParkingSpace: '701',
};
describe('ParkingTracker', () => {
it('follows a call from parked to picked up', () => {
const sent: [string, ParkedLive | null][] = [];
const t = new ParkingTracker((slot, p) => sent.push([slot, p]));
t.handle({ Event: 'ParkedCall', ...PARKEE, ParkingTimeout: '120', ParkingDuration: '0' });
expect(t.list()).toEqual([
{
slot: '701',
linkedid: '1792400815.54',
callerNumber: '13125559999',
callerName: 'Mobile',
since: expect.any(String),
parkedBy: '101',
},
]);
t.handle({ Event: 'UnParkedCall', ...PARKEE, RetrieverChannel: 'PJSIP/103-00000018' });
expect(t.list()).toEqual([]);
expect(sent.map(([s, p]) => [s, p === null])).toEqual([
['701', false],
['701', true],
]);
t.handle({ Event: 'ParkedCallGiveUp', ParkingSpace: '701' }); // already gone: nothing
expect(sent).toHaveLength(2);
});
it('loads ParkedCalls, with how long each has waited', () => {
const sent: (ParkedLive | null)[] = [];
const t = new ParkingTracker((_s, p) => sent.push(p));
t.handle({ Event: 'ParkedCall', ...PARKEE, ParkingSpace: '705' });
t.load([
{ Event: 'ParkedCall', ...PARKEE, ParkingDuration: '30' },
{ Event: 'ParkedCallsComplete', Total: '1' },
]);
expect(t.list().map((p) => p.slot)).toEqual(['701']);
expect(Date.now() - Date.parse(t.list()[0]!.since)).toBeGreaterThanOrEqual(29_000);
expect(sent.at(-2)).toBeNull(); // 705 went away while we weren't looking
});
});
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Parked calls (04 §5.1): loaded from ParkedCalls on connect, then ParkedCall adds a slot and
// UnParkedCall (picked up), ParkedCallTimeOut (rang back) and ParkedCallGiveUp (hung up) free it.
import type { ParkedLive } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
const FREED = new Set(['UnParkedCall', 'ParkedCallTimeOut', 'ParkedCallGiveUp']);
export class ParkingTracker {
private readonly slots = new Map<string, ParkedLive>();
private readonly emit: (slot: string, p: ParkedLive | null) => void;
constructor(emit: (slot: string, p: ParkedLive | null) => void) {
this.emit = emit;
}
list(): ParkedLive[] {
return [...this.slots.values()].sort((a, b) => a.slot.localeCompare(b.slot));
}
/** ParkedCalls items (initial load). */
load(items: AmiMessage[]): void {
const before = new Set(this.slots.keys());
this.slots.clear();
for (const m of items) if (field(m, 'Event') === 'ParkedCall') this.park(m);
for (const s of before) if (!this.slots.has(s)) this.emit(s, null);
for (const p of this.slots.values()) this.emit(p.slot, p);
}
private park(m: AmiMessage): ParkedLive | null {
const slot = field(m, 'ParkingSpace') ?? '';
if (!slot) return null;
const parker = /^(?:PJSIP|SIP)\/(\d+)/.exec(field(m, 'ParkerDialString') ?? '')?.[1];
const p: ParkedLive = {
slot,
linkedid: field(m, 'ParkeeLinkedid') ?? field(m, 'ParkeeUniqueid') ?? '',
callerNumber: field(m, 'ParkeeCallerIDNum') ?? '',
callerName: field(m, 'ParkeeCallerIDName') || undefined,
since: new Date(Date.now() - Number(field(m, 'ParkingDuration') ?? 0) * 1000).toISOString(),
parkedBy: parker,
};
this.slots.set(slot, p);
return p;
}
handle(m: AmiMessage): void {
const ev = field(m, 'Event') ?? '';
if (ev === 'ParkedCall') {
const p = this.park(m);
if (p) this.emit(p.slot, p);
} else if (FREED.has(ev)) {
const slot = field(m, 'ParkingSpace') ?? '';
if (this.slots.delete(slot)) this.emit(slot, null);
}
}
}
+15 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// In-memory live state (04 §5.1). Rebuilt from Asterisk on every AMI connect, then kept current
// from AMI events: extension states and registered devices, calls, queues. Parking with M8-T2.
// from AMI events: extension states and registered devices, calls, queues, parked calls.
import { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type {
@@ -16,6 +16,7 @@ import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, queues, trunks, userRuntimeState } from '../db/schema.ts';
import { CallTracker } from './calls.ts';
import { ParkingTracker } from './parking.ts';
import { QueueTracker } from './queues.ts';
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
@@ -77,6 +78,13 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
q ? { type: 'queue.updated', data: q } : { type: 'queue.removed', data: { number } },
),
);
/** Parked calls (everyone). */
readonly parking = new ParkingTracker((slot, p) =>
this.emit(
'message',
p ? { type: 'parked.updated', data: p } : { type: 'parked.removed', data: { slot } },
),
);
private readonly db: Db;
private readonly ami: Ami;
@@ -95,11 +103,11 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
queues: this.queues
.list()
.filter((q) => admin || q.members.some((m) => myExts.includes(m.ext))),
parked: this.parking.list(),
...(admin
? {
trunks: [...this.trunks.values()].map((t) => ({ ...t })),
calls: this.calls.list(),
parked: [],
}
: {}),
ami: { connected: this.amiConnected },
@@ -259,12 +267,15 @@ 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, channels] = await Promise.all([
const [states, contacts, channels, parked] = await Promise.all([
this.ami.actionList({ Action: 'ExtensionStateList' }),
this.ami.actionList({ Action: 'PJSIPShowContacts' }),
this.ami.actionList({ Action: 'CoreShowChannels' }),
this.ami.actionList({ Action: 'ParkedCalls' }).catch(() => []), // errors when none
]);
this.calls.load(channels);
this.parking.load(parked);
for (const p of this.parking.list()) this.calls.setParked(p.linkedid, true);
await this.refreshQueues();
for (const m of states) this.onExtensionStatus(m, false);
for (const m of contacts) {
@@ -288,6 +299,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
handleEvent(m: AmiMessage): void {
this.calls.handle(m);
this.queues.handle(m);
this.parking.handle(m);
switch (field(m, 'Event')) {
case 'ExtensionStatus':
return this.onExtensionStatus(m, true);
+18 -1
View File
@@ -5,7 +5,7 @@
// 103 Carol auto-answer 104 Dave no phone, no voicemail
// Each scenario's Cdr events go to apps/api/test/fixtures/cdr/<name>.json.
// Queue 550 has Alice (answers); queue 551 has Bob (never answers) and sends callers who wait
// 8 s to his voicemail. Parking arrives with M8-T2.
// 8 s to his voicemail. Park: Alice parks a caller on 700 (→ 701), Carol picks it up.
// Usage: node --conditions=source apps/api/test/asterisk/cdr-record.ts
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
@@ -444,6 +444,23 @@ async function main() {
await sleep(6000);
});
await scenario('park-pickup', async () => {
fromMobile('3125551234');
await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-'));
await sleep(2000);
await ami.action({
Action: 'BlindTransfer',
Channel: pbxChannel('PJSIP/101-')!,
Context: 'sp-from-ext',
Exten: '700',
});
await waitFor('parked on 701', () => /701/.test(cli(PBX, 'parking show default')), 20);
await sleep(2000);
await fromPhone('103', '701', 'Carol');
await waitFor('103 picks up', () => !!pbxChannel('PJSIP/103-'), 20);
await sleep(4000);
});
mkdirSync(OUT, { recursive: true });
for (const [name, evs] of Object.entries(recorded)) {
if (!evs.length) throw new Error(`${name}: no Cdr events`);
+2 -2
View File
@@ -56,8 +56,8 @@ describe.skipIf(!hasTestDb)('call history API', () => {
destination: { type: 'hangup' },
label: n === '1234' ? 'Main line' : null,
});
// The M7 scenarios; queue calls (M8) are checked in cdr.test.ts and queues.test.ts.
for (const f of readdirSync(FIXTURES).filter((f) => !f.startsWith('queue-'))) {
// The M7 scenarios; queue and park calls (M8) are checked in cdr.test.ts.
for (const f of readdirSync(FIXTURES).filter((f) => !/^(queue|park)-/.test(f))) {
const events: AmiMessage[] = JSON.parse(readFileSync(new URL(f, FIXTURES), 'utf8'));
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
await aggregateCall(t.db, String(events[0]!.LinkedID));
+11
View File
@@ -143,6 +143,17 @@ const SCENARIOS: Record<string, Expected> = {
rang: ['102'], // rang as an agent, then took the call in his mailbox
talkSeconds: 0,
},
'park-pickup': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551234',
did: '13125551234',
answeredBy: '101', // parked it on 701; Carol (103) picked it up there
rang: ['101', '103'],
talkSeconds: 4,
},
};
describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
+106
View File
@@ -0,0 +1,106 @@
[
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "103",
"Destination": "701",
"DestinationContext": "sp-from-ext",
"CallerID": "\"Carol\" <103>",
"Channel": "PJSIP/103-00000018",
"DestinationChannel": "",
"LastApplication": "ParkedCall",
"LastData": "default,701",
"StartTime": "2026-10-19 09:06:59",
"AnswerTime": "2026-10-19 09:06:59",
"EndTime": "2026-10-19 09:06:59",
"Duration": "0",
"BillableSeconds": "0",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400819.56",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "",
"sp_direction": "",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "PJSIP/101-00000017",
"LastApplication": "Dial",
"LastData": "PJSIP/101/sip:101-0x5987fe8320b0@172.21.0.4:5070,25,tx",
"StartTime": "2026-10-19 09:06:55",
"AnswerTime": "2026-10-19 09:06:55",
"EndTime": "2026-10-19 09:06:57",
"Duration": "2",
"BillableSeconds": "2",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "",
"LastApplication": "Park",
"LastData": "default:701",
"StartTime": "2026-10-19 09:06:57",
"AnswerTime": "2026-10-19 09:06:57",
"EndTime": "2026-10-19 09:06:59",
"Duration": "2",
"BillableSeconds": "2",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "PJSIP/103-00000018",
"LastApplication": "Dial",
"LastData": "PJSIP/101/sip:101-0x5987fe8320b0@172.21.0.4:5070,25,tx",
"StartTime": "2026-10-19 09:06:59",
"AnswerTime": "2026-10-19 09:06:59",
"EndTime": "2026-10-19 09:07:03",
"Duration": "4",
"BillableSeconds": "4",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
}
]
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Calls waiting in parking slots (701–720), live. Anyone picks one up by dialing its slot.
import { useLive } from '../live/store.ts';
import { fmtNumber } from '../pages/admin/CallsPage.tsx';
import { since, useNow } from '../pages/admin/DashboardPage.tsx';
export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void }) {
const parked = Object.values(useLive((s) => s.parked)).sort((a, b) =>
a.slot.localeCompare(b.slot),
);
const now = useNow();
return (
<section aria-labelledby="parked-h" className="flex flex-col gap-2">
<h2 id="parked-h" className="font-medium">
Parked calls
</h2>
{parked.length === 0 ? (
<p className="text-sm text-zinc-500">
None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p>
) : (
<ul
aria-label="Parked calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
>
{parked.map((p) => (
<li
key={p.slot}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
</span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && (
<button
type="button"
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
onClick={() => onPickUp(p.slot)}
>
Pick up {p.slot}
</button>
)}
</li>
))}
</ul>
)}
</section>
);
}
+11
View File
@@ -6,6 +6,7 @@ import type {
EmergencyCall,
ExtensionLive,
LiveMessage,
ParkedLive,
QueueLive,
TrunkLive,
} from '@selfphoneng/shared';
@@ -21,6 +22,8 @@ type LiveStore = {
calls: Record<string, ActiveCall>;
/** By number. Admins: all; users: the queues they're an agent of. */
queues: Record<string, QueueLive>;
/** By slot (everyone sees them). */
parked: Record<string, ParkedLive>;
/** 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. */
@@ -36,6 +39,7 @@ export const useLive = create<LiveStore>(() => ({
trunks: {},
calls: {},
queues: {},
parked: {},
emergencies: [],
voicemailSeq: 0,
toasts: [],
@@ -52,6 +56,7 @@ function apply(m: LiveMessage) {
trunks: Object.fromEntries((m.data.trunks ?? []).map((t) => [t.trunkId, t])),
calls: Object.fromEntries((m.data.calls ?? []).map((c) => [c.linkedid, c])),
queues: Object.fromEntries(m.data.queues.map((q) => [q.number, q])),
parked: Object.fromEntries(m.data.parked.map((p) => [p.slot, p])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -77,6 +82,12 @@ function apply(m: LiveMessage) {
const { [m.data.number]: _gone, ...rest } = s.queues;
return { queues: rest };
}
case 'parked.updated':
return { parked: { ...s.parked, [m.data.slot]: m.data } };
case 'parked.removed': {
const { [m.data.slot]: _gone, ...rest } = s.parked;
return { parked: rest };
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
case 'toast':
+4 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles and extensions online. Parked calls arrive with M8-T2.
// queue tiles, parked calls and extensions online.
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
@@ -11,6 +11,7 @@ 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';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
@@ -66,6 +67,8 @@ export function DashboardPage() {
<LiveCalls />
<ParkedCalls />
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
+17
View File
@@ -14,6 +14,7 @@ import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -126,6 +127,7 @@ function Home({ user }: { user: AuthUser }) {
<p>Hi {user.displayName}.</p>
{user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />}
{user.extension && <ParkedOnHome />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
@@ -216,6 +218,21 @@ function QueueLogins({ ext }: { ext: string }) {
);
}
/** Parked calls with a Pick up button: my phone (or softphone) dials the slot. */
function ParkedOnHome() {
const { call, note } = useCallBack();
return (
<div className="flex flex-col gap-1">
<ParkedCalls onPickUp={call} />
{note && (
<p role="status" className="text-sm text-zinc-500">
{note}
</p>
)}
</div>
);
}
/** Missed calls from the last page of my history (05 §4 Home). */
function MissedCalls() {
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });