parking: parked calls live, on the dashboard and Home, pick up from Home
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:
1 parent
2d9f8ed99e
commit
863a9d121c
21 files changed
+635
-23
No files matched your search
@@ -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>) => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in new issue
Block a user