reports: calls per day, missed, busiest hours, queue table
GET /api/reports/summary and /api/reports/queues (company-time-zone days, last 30 by default, a year at most) and the Reports page: totals, stacked bars per day, missed per day, a weekday x hour heatmap and the queue table. Plain divs, no chart library. Task: M11-T6
This commit is contained in:
1 parent
60846afddd
commit
29ee4ba105
13 files changed
+714
-3
No files matched your search
@@ -37,6 +37,7 @@ import { callRoutes } from './routes/calls.ts';
|
||||
import { recordingRoutes } from './routes/recordings.ts';
|
||||
import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
import { reportRoutes } from './routes/reports.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -147,6 +148,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(recordingRoutes);
|
||||
app.register(liveCallRoutes);
|
||||
app.register(integrationRoutes);
|
||||
app.register(reportRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
ReportRangeSchema,
|
||||
type ListResponse,
|
||||
type QueueReportRow,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, queues } from '../db/schema.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
const addDays = (d: string, n: number) =>
|
||||
new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
/** Whole days in the company time zone; the last 30 days by default, a year at most. */
|
||||
async function range(db: Db, query: unknown) {
|
||||
const r = ReportRangeSchema.parse(query);
|
||||
const tz = await getSetting(db, 'general.timezone');
|
||||
const to = r.to ?? (r.from ? addDays(r.from, 29) : todayIn(tz));
|
||||
const from = r.from ?? addDays(to, -29);
|
||||
if (addDays(from, 366) <= to) throw badRequest('Pick at most a year');
|
||||
return { tz, from, to };
|
||||
}
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
||||
const { tz, from, to } = await range(db, req.query);
|
||||
const where = and(
|
||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
||||
);
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
const [perDay, perHour, [t]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
date: sql<string>`to_char(${local}, 'YYYY-MM-DD')`,
|
||||
direction: calls.direction,
|
||||
n: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
.groupBy(sql`1`, calls.direction),
|
||||
db
|
||||
.select({
|
||||
dow: sql<number>`extract(dow from ${local})::int`,
|
||||
hour: sql<number>`extract(hour from ${local})::int`,
|
||||
n: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
.groupBy(sql`1`, sql`2`),
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where),
|
||||
]);
|
||||
const days: ReportSummary['days'] = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||
const rows = perDay.filter((r) => r.date === d);
|
||||
days.push({
|
||||
date: d,
|
||||
...(Object.fromEntries(
|
||||
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
|
||||
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
|
||||
missed: rows.reduce((s, r) => s + r.missed, 0),
|
||||
});
|
||||
}
|
||||
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
|
||||
for (const h of perHour) hours[h.dow]![h.hour] = h.n;
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
days,
|
||||
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
|
||||
hours,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
return {
|
||||
nextCursor: null,
|
||||
items: await Promise.all(
|
||||
qs.map(async (q) => ({
|
||||
id: q.id,
|
||||
number: q.number,
|
||||
name: q.name,
|
||||
slaSeconds: q.slaSeconds,
|
||||
...(await queueStats(db, q, { from, to })),
|
||||
})),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let get: (url: string, cookie?: string) => Promise<LightMyRequestResponse>;
|
||||
let admin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
get = (url, cookie = admin) => t.app.inject({ method: 'GET', url, headers: { ...H, cookie } });
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago'); // UTC-5 in May
|
||||
const call = (
|
||||
linkedid: string,
|
||||
at: string,
|
||||
direction: 'inbound' | 'outbound' | 'internal',
|
||||
status: 'answered' | 'missed' | 'voicemail' | 'busy',
|
||||
talkSeconds = 0,
|
||||
) => ({
|
||||
linkedid,
|
||||
direction,
|
||||
status,
|
||||
startedAt: new Date(at),
|
||||
endedAt: new Date(at),
|
||||
talkSeconds,
|
||||
});
|
||||
await t.db.insert(calls).values([
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
|
||||
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
|
||||
]);
|
||||
const [q] = await t.db
|
||||
.insert(queues)
|
||||
.values({ number: '550', name: 'Sales', timeoutDestination: { type: 'hangup' } })
|
||||
.returning();
|
||||
await t.db.insert(queueCalls).values([
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '1',
|
||||
linkedid: '1',
|
||||
joinedAt: new Date('2026-05-04T14:00:00Z'),
|
||||
outcome: 'answered',
|
||||
waitSeconds: 5,
|
||||
},
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '2',
|
||||
linkedid: '2',
|
||||
joinedAt: new Date('2026-05-04T14:05:00Z'),
|
||||
outcome: 'abandoned',
|
||||
waitSeconds: 40,
|
||||
},
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '3',
|
||||
linkedid: '3',
|
||||
joinedAt: new Date('2026-05-09T14:05:00Z'),
|
||||
outcome: 'answered',
|
||||
waitSeconds: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('summary: per day by direction, missed, average talk, busiest hours', async () => {
|
||||
const r = await get('/api/reports/summary?from=2026-05-04&to=2026-05-06');
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
|
||||
expect(s.hours).toHaveLength(7);
|
||||
expect(s.hours[1][9]).toBe(2);
|
||||
expect(s.hours[1][10]).toBe(1);
|
||||
expect(s.hours[1][22]).toBe(1);
|
||||
expect(s.hours[3][8]).toBe(1);
|
||||
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(5);
|
||||
});
|
||||
|
||||
it('queues: offered, answered, abandoned, wait and service level', async () => {
|
||||
const r = await get('/api/reports/queues?from=2026-05-04&to=2026-05-06');
|
||||
expect(r.json().items).toEqual([
|
||||
expect.objectContaining({
|
||||
number: '550',
|
||||
name: 'Sales',
|
||||
offered: 2,
|
||||
answered: 1,
|
||||
abandoned: 1,
|
||||
avgWaitSeconds: 5,
|
||||
withinSlaPercent: 50,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to the last 30 days; bad ranges and non-admins are refused', async () => {
|
||||
const s = (await get('/api/reports/summary')).json();
|
||||
expect(s.days).toHaveLength(30);
|
||||
expect(s.days.at(-1).date).toBe(s.to);
|
||||
expect((await get('/api/reports/summary?from=2026-05-06&to=2026-05-04')).statusCode).toBe(400);
|
||||
expect((await get('/api/reports/summary?from=2025-01-01&to=2026-05-04')).statusCode).toBe(400);
|
||||
expect((await get('/api/reports/summary?from=nope')).statusCode).toBe(400);
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const pat = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type {
|
||||
QueueReportRow,
|
||||
ReportRange,
|
||||
ReportSummary,
|
||||
ApiTokenDto,
|
||||
WebhookDeliveryDto,
|
||||
WebhookDto,
|
||||
@@ -396,7 +399,7 @@ export const blockedApi = {
|
||||
};
|
||||
|
||||
/** Filters as a query string (empty values left out). */
|
||||
export const callQuery = (f: CallFilter) =>
|
||||
export const callQuery = (f: CallFilter | ReportRange) =>
|
||||
new URLSearchParams(
|
||||
Object.entries(f).filter(([, v]) => v !== undefined && v !== '') as [string, string][],
|
||||
).toString();
|
||||
@@ -407,6 +410,14 @@ export const callsApi = {
|
||||
csvUrl: (f: CallFilter) => `/api/calls/export.csv?${callQuery({ ...f, cursor: undefined })}`,
|
||||
};
|
||||
|
||||
export const reportsApi = {
|
||||
summary: (r: ReportRange) => api<ReportSummary>('GET', `/api/reports/summary?${callQuery(r)}`),
|
||||
queues: (r: ReportRange) =>
|
||||
api<ListResponse<QueueReportRow>>('GET', `/api/reports/queues?${callQuery(r)}`).then(
|
||||
(x) => x.items,
|
||||
),
|
||||
};
|
||||
|
||||
export const recordingsApi = {
|
||||
list: (cursor?: string) =>
|
||||
api<ListResponse<RecordingDto>>('GET', `/api/recordings${cursor ? `?cursor=${cursor}` : ''}`),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
|
||||
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
|
||||
import { CallsPage } from './pages/admin/CallsPage.tsx';
|
||||
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
|
||||
import { ReportsPage } from './pages/admin/ReportsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
@@ -75,6 +76,8 @@ const router = createBrowserRouter([
|
||||
<CallsPage />
|
||||
) : p.path === 'recordings' ? (
|
||||
<RecordingsPage />
|
||||
) : p.path === 'reports' ? (
|
||||
<ReportsPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
|
||||
{ path: 'calls', label: 'Call history', milestone: null },
|
||||
{ path: 'recordings', label: 'Recordings', milestone: null },
|
||||
{ path: 'reports', label: 'Reports', milestone: 'M11' },
|
||||
{ path: 'reports', label: 'Reports', milestone: null },
|
||||
{ path: 'sms', label: 'SMS', milestone: 'M9' },
|
||||
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
|
||||
{ path: 'settings', label: 'Settings', milestone: null },
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Reports (05 §2): calls per day by direction, missed per day, busiest hours, queue table.
|
||||
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
|
||||
import { reportsApi } from '../../api/client.ts';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { fmtDuration } from './CallsPage.tsx';
|
||||
|
||||
const SERIES = [
|
||||
['inbound', 'In', 'bg-sky-500'],
|
||||
['outbound', 'Out', 'bg-violet-500'],
|
||||
['internal', 'Internal', 'bg-zinc-400'],
|
||||
] as const;
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const shortDate = (d: string) =>
|
||||
new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
function Bars({
|
||||
label,
|
||||
days,
|
||||
segments,
|
||||
}: {
|
||||
label: string;
|
||||
days: ReportSummary['days'];
|
||||
segments: readonly (readonly [keyof ReportSummary['days'][number], string, string])[];
|
||||
}) {
|
||||
const total = (d: ReportSummary['days'][number]) =>
|
||||
segments.reduce((s, [k]) => s + Number(d[k]), 0);
|
||||
const max = Math.max(1, ...days.map(total));
|
||||
return (
|
||||
<figure className="flex flex-col gap-1">
|
||||
<figcaption className="flex flex-wrap items-center gap-3 font-medium">
|
||||
{label}
|
||||
{segments.length > 1 &&
|
||||
segments.map(([k, text, color]) => (
|
||||
<span key={k} className="flex items-center gap-1 text-xs font-normal text-zinc-500">
|
||||
<span className={`inline-block size-2.5 rounded-sm ${color}`} />
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs font-normal text-zinc-500">most in a day: {max}</span>
|
||||
</figcaption>
|
||||
<div className="flex h-40 items-end gap-px border-b border-zinc-300 dark:border-zinc-700">
|
||||
{days.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
role="img"
|
||||
aria-label={`${shortDate(d.date)}: ${segments.map(([k, text]) => `${d[k]} ${text.toLowerCase()}`).join(', ')}`}
|
||||
title={`${shortDate(d.date)}: ${segments.map(([k, text]) => `${d[k]} ${text.toLowerCase()}`).join(', ')}`}
|
||||
className="flex h-full min-w-0 flex-1 flex-col-reverse hover:opacity-80"
|
||||
>
|
||||
{segments.map(([k, , color]) => (
|
||||
<div key={k} className={color} style={{ height: `${(100 * Number(d[k])) / max}%` }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-zinc-500">
|
||||
<span>{shortDate(days[0]!.date)}</span>
|
||||
<span>{shortDate(days.at(-1)!.date)}</span>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Heatmap({ hours }: { hours: number[][] }) {
|
||||
const max = Math.max(1, ...hours.flat());
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Busiest hours" className="border-separate border-spacing-0.5 text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
{hours[0]!.map((_, h) => (
|
||||
<th key={h} scope="col" className="w-6 font-normal text-zinc-500">
|
||||
{h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hours.map((row, d) => (
|
||||
<tr key={d}>
|
||||
<th scope="row" className="pr-1 text-left font-normal text-zinc-500">
|
||||
{WEEKDAYS[d]}
|
||||
</th>
|
||||
{row.map((n, h) => (
|
||||
<td
|
||||
key={h}
|
||||
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`}
|
||||
className="h-6 w-6 rounded-sm bg-zinc-100 dark:bg-zinc-800"
|
||||
style={
|
||||
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
|
||||
}
|
||||
>
|
||||
<span className="sr-only">{n}</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const [draft, setDraft] = useState<ReportRange>({});
|
||||
const [range, setRange] = useState<ReportRange>({});
|
||||
const summary = useQuery({
|
||||
queryKey: ['reports', 'summary', range],
|
||||
queryFn: () => reportsApi.summary(range),
|
||||
});
|
||||
const queues = useQuery({
|
||||
queryKey: ['reports', 'queues', range],
|
||||
queryFn: () => reportsApi.queues(range),
|
||||
});
|
||||
const s = summary.data;
|
||||
const date = (k: 'from' | 'to', label: string) => (
|
||||
<Field label={label}>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={draft[k] ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value || undefined }))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<h1 className="text-xl font-semibold">Reports</h1>
|
||||
<form
|
||||
aria-label="Dates"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setRange(draft);
|
||||
}}
|
||||
>
|
||||
<div className="w-40">{date('from', 'From')}</div>
|
||||
<div className="w-40">{date('to', 'To')}</div>
|
||||
<Button type="submit" variant="primary">
|
||||
Show
|
||||
</Button>
|
||||
{s && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
{shortDate(s.from)} – {shortDate(s.to)}
|
||||
{!range.from && !range.to && ' (last 30 days)'}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{summary.error && <Alert>{summary.error.message}</Alert>}
|
||||
|
||||
{s && (
|
||||
<>
|
||||
<dl aria-label="Totals" className="grid grid-cols-3 gap-3 text-sm sm:max-w-xl">
|
||||
{(
|
||||
[
|
||||
['Calls', s.totals.calls],
|
||||
['Missed', s.totals.missed],
|
||||
[
|
||||
'Average talk time',
|
||||
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
||||
],
|
||||
] as const
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<dt className="text-zinc-500">{k}</dt>
|
||||
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Bars label="Calls per day" days={s.days} segments={SERIES} />
|
||||
<Bars
|
||||
label="Missed per day"
|
||||
days={s.days}
|
||||
segments={[['missed', 'Missed', 'bg-red-500']]}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-medium">Busiest hours</h2>
|
||||
<Heatmap hours={s.hours} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Queues</h2>
|
||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>}
|
||||
{!!queues.data?.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||
<thead className="text-zinc-500">
|
||||
<tr>
|
||||
{[
|
||||
'Queue',
|
||||
'Calls',
|
||||
'Answered',
|
||||
'Hung up waiting',
|
||||
'Timed out',
|
||||
'Average wait',
|
||||
'Answered in time',
|
||||
].map((h) => (
|
||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{queues.data.map((q) => (
|
||||
<tr key={q.id} className="tabular-nums">
|
||||
<td className="px-2 py-1">
|
||||
<span className="font-mono">{q.number}</span> {q.name}
|
||||
</td>
|
||||
<td className="px-2 py-1">{q.offered}</td>
|
||||
<td className="px-2 py-1">{q.answered}</td>
|
||||
<td className="px-2 py-1">{q.abandoned}</td>
|
||||
<td className="px-2 py-1">{q.timedOut}</td>
|
||||
<td className="px-2 py-1">
|
||||
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
|
||||
</td>
|
||||
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
|
||||
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -410,6 +410,9 @@ Reports, history, system:
|
||||
| GET | `/api/recordings`, `/api/recordings/:id/audio`, DELETE `/api/recordings/:id` | |
|
||||
| GET | `/api/reports/summary?from&to` | totals per day, per direction, missed count, average talk time |
|
||||
| GET | `/api/reports/queues?from&to` | per queue: offered, answered, abandoned, avg wait, % within SLA |
|
||||
|
||||
As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days, inclusive; without them the last 30 days, at most a year (400). The summary is `{from, to, days: [{date, inbound, outbound, internal, missed}], totals: {calls, missed, avgTalkSeconds}, hours}`: every day in the range (empty ones too), missed = missed + voicemail + abandoned, average talk over answered calls, and `hours[weekday][hour]` with 0 = Sunday. The queue rows are `queueStats` (the queue page's numbers) for each queue, with `id, number, name, slaSeconds`.
|
||||
|
||||
| GET | `/api/audit-log` | |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
|
||||
@@ -44,7 +44,7 @@ Left sidebar navigation, in this order:
|
||||
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
|
||||
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
|
||||
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
|
||||
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table |
|
||||
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page |
|
||||
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
|
||||
| Blocked numbers | `/admin/blocked` | |
|
||||
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens |
|
||||
|
||||
@@ -78,3 +78,19 @@ export type RecordingDto = {
|
||||
toNumber: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** GET /api/reports/summary and /api/reports/queues (04 §7.3). Both default to the last 30 days. */
|
||||
export const ReportRangeSchema = z
|
||||
.object({ from: z.iso.date().optional(), to: z.iso.date().optional() })
|
||||
.refine((r) => !r.from || !r.to || r.from <= r.to, { message: 'From is after To' });
|
||||
export type ReportRange = z.input<typeof ReportRangeSchema>;
|
||||
|
||||
export type ReportSummary = {
|
||||
from: string;
|
||||
to: string;
|
||||
/** Every day in the range, empty ones too. Missed = missed, voicemail or abandoned. */
|
||||
days: { date: string; inbound: number; outbound: number; internal: number; missed: number }[];
|
||||
totals: { calls: number; missed: number; avgTalkSeconds: number | null };
|
||||
/** Calls by weekday (0 = Sunday) and hour, in the company time zone: hours[day][hour]. */
|
||||
hours: number[][];
|
||||
};
|
||||
@@ -81,3 +81,11 @@ export type QueueStats = {
|
||||
*/
|
||||
withinSlaPercent: number | null;
|
||||
};
|
||||
|
||||
/** GET /api/reports/queues: one row per queue. */
|
||||
export type QueueReportRow = QueueStats & {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
slaSeconds: number;
|
||||
};
|
||||
@@ -244,3 +244,10 @@ Webhooks to a receiver on this host is refused as a private address until that's
|
||||
the test arrives signed. A real call to 201 (Local channel, voicemail answers) delivers
|
||||
`call.started`, `call.ended` and `voicemail.new`, all with valid signatures; the receiver fails
|
||||
the first one with a 500 and it comes again a minute later. About 2 minutes.
|
||||
|
||||
`m11-t6.mjs` (m2-live with a voicemail dir, after `m1-t4.mjs`) is the M11-T6 acceptance. Three real
|
||||
calls: one answered by phone 260, one to 261's voicemail and one through queue 560 that 260
|
||||
answers. `/api/reports/summary` agrees with call history (today's bars, missed, this hour in the
|
||||
heatmap) and `/api/reports/queues` shows 560 with 1 offered, 1 answered, 100% in time. The Reports
|
||||
page shows the same at 1280 px and 390 px, without scrolling sideways on the phone. About 3
|
||||
minutes.
|
||||
@@ -0,0 +1,182 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T6: the Reports page on real calls. Three calls through Asterisk: one answered by phone
|
||||
// 260, one that goes to 261's voicemail (missed) and one through queue 560 that agent 260
|
||||
// answers. The summary counts them like call history does (today, this hour, one missed), the
|
||||
// queue table shows 560 with 1 offered and 1 answered, and the page shows it all, also at phone
|
||||
// width without the page scrolling sideways.
|
||||
// Runs on m2-live with a voicemail dir, after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m11-t6.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { originate } from './ami.mjs';
|
||||
import { launch } from './cdp.mjs';
|
||||
import { startPhones } from './phones.mjs';
|
||||
|
||||
const NET = 'sp-live';
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
const until = async (what, ms, fn) => {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
assert.ok(Date.now() < end, `timed out: ${what}`);
|
||||
await sleep(500);
|
||||
}
|
||||
};
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(';')[0])
|
||||
.join('; ');
|
||||
const api = async (method, url, body) => {
|
||||
const r = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
|
||||
body: body && JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
assert.ok(r.ok, `${method} ${url}: ${r.status} ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
};
|
||||
|
||||
const ann = await api('POST', '/api/extensions', { number: '260', name: 'Ann Agent' });
|
||||
await api('POST', '/api/extensions', { number: '261', name: 'Vic Voicemail' });
|
||||
const pass = (await api('GET', `/api/extensions/${ann.id}/sip-credentials`)).password;
|
||||
await api('POST', '/api/queues', {
|
||||
number: '560',
|
||||
name: 'Support',
|
||||
maxWaitSeconds: 30,
|
||||
members: [{ extensionId: ann.id, penalty: 0 }],
|
||||
timeoutDestination: { type: 'hangup' },
|
||||
});
|
||||
const phones = startPhones({
|
||||
name: 'sp-live-phones-m11t6',
|
||||
net: NET,
|
||||
ast: AST,
|
||||
phones: [{ ext: '260', password: pass, answer: 'auto' }],
|
||||
});
|
||||
const browsers = [];
|
||||
try {
|
||||
await until('phone 260 registered', 180_000, () =>
|
||||
/260\/sip:.*Avail/.test(cli('pjsip show contacts')),
|
||||
);
|
||||
await until(
|
||||
'mailbox 261 and queue 560 in Asterisk',
|
||||
30_000,
|
||||
() => /261/.test(cli('voicemail show users')) && /560/.test(cli('queue show 560')),
|
||||
);
|
||||
|
||||
// The three calls, one after another so 260 is free for the queue call.
|
||||
const before = (await api('GET', '/api/calls')).items.length;
|
||||
const call = (Channel, data, n) =>
|
||||
originate({ Channel, Application: 'Wait', Data: data, CallerID: `"Caller" <${n}>` });
|
||||
await call('Local/260@sp-from-ext', '4', '3125550101');
|
||||
await until(
|
||||
'call 1 in history',
|
||||
60_000,
|
||||
async () => (await api('GET', '/api/calls')).items.length === before + 1,
|
||||
);
|
||||
cli(
|
||||
'channel originate Local/261@sp-vm-direct application Playback silence/10&silence/5&tt-weasels',
|
||||
);
|
||||
await until(
|
||||
'call 2 in history',
|
||||
90_000,
|
||||
async () => (await api('GET', '/api/calls')).items.length === before + 2,
|
||||
);
|
||||
await call('Local/560@sp-from-ext', '6', '3125550103');
|
||||
await until(
|
||||
'call 3 in history',
|
||||
90_000,
|
||||
async () => (await api('GET', '/api/calls')).items.length === before + 3,
|
||||
);
|
||||
const history = (await api('GET', '/api/calls')).items;
|
||||
console.log(
|
||||
`calls: ${history
|
||||
.slice(0, 3)
|
||||
.map((c) => `${c.toNumber} ${c.status}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
// The API agrees with call history.
|
||||
const s = await api('GET', '/api/reports/summary');
|
||||
assert.equal(s.days.length, 30);
|
||||
const today = s.days.at(-1);
|
||||
assert.equal(today.date, s.to);
|
||||
const missed = history.filter((c) => ['missed', 'voicemail', 'abandoned'].includes(c.status));
|
||||
assert.equal(s.totals.calls, history.length);
|
||||
assert.equal(s.totals.missed, missed.length);
|
||||
assert.ok(missed.length >= 1, 'the voicemail call counts as missed');
|
||||
assert.equal(today.inbound + today.outbound + today.internal, history.length);
|
||||
assert.equal(today.missed, missed.length);
|
||||
assert.equal(
|
||||
s.hours.flat().reduce((a, b) => a + b),
|
||||
history.length,
|
||||
);
|
||||
const [qr] = (await api('GET', '/api/reports/queues')).items;
|
||||
assert.deepEqual(
|
||||
{ number: qr.number, offered: qr.offered, answered: qr.answered, sla: qr.withinSlaPercent },
|
||||
{ number: '560', offered: 1, answered: 1, sla: 100 },
|
||||
);
|
||||
console.log(
|
||||
`summary: ${s.totals.calls} calls, ${s.totals.missed} missed, avg talk ${s.totals.avgTalkSeconds} s; queue 560: 1 offered, 1 answered, 100% in time`,
|
||||
);
|
||||
|
||||
// The page.
|
||||
for (const width of [1280, 390]) {
|
||||
const p = await launch({ width, height: 900, shotsDir });
|
||||
browsers.push(p);
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname.startsWith('/admin')`, 'signed in');
|
||||
await p.goto(`${base}/admin/reports`);
|
||||
await p.waitFor(
|
||||
`!!document.querySelector('[aria-label="Queue report"]') && !!document.querySelector('[aria-label="Totals"]')`,
|
||||
'reports page',
|
||||
);
|
||||
const totals = await p.evaluate(`document.querySelector('[aria-label="Totals"]').innerText`);
|
||||
assert.match(totals, new RegExp(`Calls\\s+${s.totals.calls}\\b`));
|
||||
assert.match(totals, new RegExp(`Missed\\s+${s.totals.missed}\\b`));
|
||||
const bars = await p.evaluate(
|
||||
`[...document.querySelectorAll('figure [role=img]')].map((b) => b.getAttribute('aria-label'))`,
|
||||
);
|
||||
assert.equal(bars.length, 60, 'two charts of 30 days');
|
||||
assert.match(
|
||||
bars[29],
|
||||
new RegExp(`${today.inbound} in, ${today.outbound} out, ${today.internal} internal$`),
|
||||
);
|
||||
assert.match(bars[59], new RegExp(`${today.missed} missed$`));
|
||||
const busiest = await p.evaluate(
|
||||
`[...document.querySelectorAll('[aria-label="Busiest hours"] td')].reduce((s, td) => s + Number(td.textContent), 0)`,
|
||||
);
|
||||
assert.equal(busiest, history.length);
|
||||
const row = await p.evaluate(
|
||||
`document.querySelector('[aria-label="Queue report"] tbody tr').innerText`,
|
||||
);
|
||||
assert.match(row, /560\s+Support\s+1\s+1\s+0\s+0\s+\d+ s\s+100%/);
|
||||
const sideways = await p.evaluate(
|
||||
'document.documentElement.scrollWidth > document.documentElement.clientWidth',
|
||||
);
|
||||
assert.equal(sideways, false, `page scrolls sideways at ${width}px`);
|
||||
await p.shot(`m11-t6-reports-${width}`);
|
||||
}
|
||||
console.log(
|
||||
'page: totals, both charts, busiest hours and the queue row match; no sideways scroll at 390px',
|
||||
);
|
||||
console.log('M11-T6: PASS');
|
||||
} finally {
|
||||
for (const b of browsers) await b.close();
|
||||
phones.stop();
|
||||
}
|
||||
Reference in new issue
Block a user