Files
selfphoneng/apps/web/src/pages/admin/ReportsPage.tsx
T
iamdoubz 338ab8d7f1 reports: total minutes, talk time, answered; outside minutes per day; voip.ms balance
Owner asked for total minutes, call time and missed calls in one place, and the money left on
voip.ms. Reports now totals answered calls, talk time and outside minutes (in from arrival, out
from the answer: roughly what the provider bills), charts outside minutes per day, and shows each
voip.ms trunk's balance (getBalance; verify with a real account).
2026-10-21 13:38:23 -05:00

275 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, 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'];
/** 75 → "1 min", 4000 → "1 h 7 min": totals, rounded to the minute. */
const fmtMinutes = (s: number) => {
const m = Math.round(s / 60);
return m >= 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`;
};
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 balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
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-2 gap-3 text-sm sm:grid-cols-4">
{(
[
['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null],
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
[
'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
null,
],
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
[
'Outside minutes',
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
],
...(balance.data ?? []).map(
(b) =>
[
balance.data!.length > 1 ? `Balance (${b.trunkName})` : 'voip.ms balance',
b.balance === null ? '—' : b.balance.toFixed(2),
b.error ?? "now, in your voip.ms account's currency",
] as const,
),
] as const
).map(([k, v, note]) => (
<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>
{note && <dd className="text-xs text-zinc-500">{note}</dd>}
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
<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>
);
}