web: my reports, the calls made to me
Users get a Reports tab: the admin summary limited to calls that rang or were answered by them. Answered and talk time are the calls they picked up.
This commit is contained in:
1 parent
19ca4ffc91
commit
2bc7d5cde5
7 files changed
+168
-27
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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.
|
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
|
||||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
// but /api/me/reports/summary: the same summary of the calls made to me.
|
||||||
|
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import {
|
import {
|
||||||
CALL_DIRECTIONS,
|
CALL_DIRECTIONS,
|
||||||
@@ -10,12 +11,12 @@ import {
|
|||||||
type ProviderBalanceDto,
|
type ProviderBalanceDto,
|
||||||
type ReportSummary,
|
type ReportSummary,
|
||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import { requireAdmin } from '../auth/guards.ts';
|
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { decrypt } from '../crypto/secrets.ts';
|
import { decrypt } from '../crypto/secrets.ts';
|
||||||
import { calls, queues, trunks } from '../db/schema.ts';
|
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
|
||||||
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
||||||
import { badRequest } from '../errors.ts';
|
import { badRequest, notFound } from '../errors.ts';
|
||||||
import { todayIn } from '../generator/load.ts';
|
import { todayIn } from '../generator/load.ts';
|
||||||
import { queueStats } from '../services/queuestats.ts';
|
import { queueStats } from '../services/queuestats.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
|
|||||||
|
|
||||||
export async function reportRoutes(app: FastifyInstance) {
|
export async function reportRoutes(app: FastifyInstance) {
|
||||||
const { db } = app;
|
const { db } = app;
|
||||||
app.addHook('preHandler', requireAdmin);
|
const admin = { preHandler: requireAdmin };
|
||||||
|
|
||||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
|
||||||
const { tz, from, to } = await range(db, req.query);
|
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
|
||||||
|
const { tz, from, to } = await range(db, query);
|
||||||
const where = and(
|
const where = and(
|
||||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||||
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
||||||
|
toExt
|
||||||
|
? inArray(
|
||||||
|
calls.id,
|
||||||
|
db
|
||||||
|
.select({ id: callExtensions.callId })
|
||||||
|
.from(callExtensions)
|
||||||
|
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
|
||||||
|
const answered = toExt
|
||||||
|
? sql`${calls.answeredByExtensionId} = ${toExt}`
|
||||||
|
: sql`${calls.status} = 'answered'`;
|
||||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||||
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
calls: sql<number>`count(*)::int`,
|
calls: sql<number>`count(*)::int`,
|
||||||
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
|
answered: sql<number>`count(*) filter (where ${answered})::int`,
|
||||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
|
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
|
||||||
lineIn: sql<number>`round(${lineIn})::int`,
|
lineIn: sql<number>`round(${lineIn})::int`,
|
||||||
lineOut: sql<number>`${lineOut}::int`,
|
lineOut: sql<number>`${lineOut}::int`,
|
||||||
avgTalk: sql<
|
avgTalk: sql<
|
||||||
number | null
|
number | null
|
||||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
|
||||||
})
|
})
|
||||||
.from(calls)
|
.from(calls)
|
||||||
.where(where),
|
.where(where),
|
||||||
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
hours,
|
hours,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/reports/summary', admin, (req) => summary(req.query));
|
||||||
|
|
||||||
|
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
|
||||||
|
const [ext] = await db
|
||||||
|
.select({ id: extensions.id })
|
||||||
|
.from(extensions)
|
||||||
|
.where(eq(extensions.userId, req.user!.id));
|
||||||
|
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||||
|
return summary(req.query, ext.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
||||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(trunks)
|
.from(trunks)
|
||||||
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
return { items, nextCursor: null };
|
return { items, nextCursor: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||||
const { from, to } = await range(db, req.query);
|
const { from, to } = await range(db, req.query);
|
||||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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.
|
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
|
||||||
|
// /api/me/reports/summary, the calls made to me.
|
||||||
import type { LightMyRequestResponse } from 'fastify';
|
import type { LightMyRequestResponse } from 'fastify';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { calls, queueCalls, queues } from '../src/db/schema.ts';
|
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
|
||||||
|
import { encrypt } from '../src/crypto/secrets.ts';
|
||||||
import { setSetting } from '../src/services/settings.ts';
|
import { setSetting } from '../src/services/settings.ts';
|
||||||
import { hasTestDb } from './db.ts';
|
import { hasTestDb } from './db.ts';
|
||||||
import { ADMIN, H, createTestApp } from './app.ts';
|
import { ADMIN, H, createTestApp } from './app.ts';
|
||||||
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
|||||||
await t.addUser('pat@example.com', 'pat-password-123');
|
await t.addUser('pat@example.com', 'pat-password-123');
|
||||||
const pat = await t.login('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);
|
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
|
||||||
|
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
|
||||||
|
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
||||||
|
const [mine, other] = await t.db
|
||||||
|
.insert(extensions)
|
||||||
|
.values(
|
||||||
|
(['103', '104'] as const).map((number) => ({
|
||||||
|
number,
|
||||||
|
name: number,
|
||||||
|
sipPasswordEnc: encrypt('x'),
|
||||||
|
voicemailPinEnc: encrypt('4826'),
|
||||||
|
userId: number === '103' ? sam.id : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
await t.db.insert(calls).values({
|
||||||
|
linkedid: 'g',
|
||||||
|
direction: 'internal',
|
||||||
|
status: 'answered',
|
||||||
|
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
|
||||||
|
endedAt: new Date('2026-05-05T15:00:45Z'),
|
||||||
|
talkSeconds: 40,
|
||||||
|
answeredByExtensionId: mine!.id,
|
||||||
|
});
|
||||||
|
const ids = Object.fromEntries(
|
||||||
|
(
|
||||||
|
await t.db
|
||||||
|
.select({ id: calls.id, linkedid: calls.linkedid })
|
||||||
|
.from(calls)
|
||||||
|
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
|
||||||
|
).map((c) => [c.linkedid, c.id]),
|
||||||
|
);
|
||||||
|
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
|
||||||
|
await t.db
|
||||||
|
.update(calls)
|
||||||
|
.set({ answeredByExtensionId: other!.id })
|
||||||
|
.where(inArray(calls.id, [ids.a!]));
|
||||||
|
await t.db.insert(callExtensions).values([
|
||||||
|
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
|
||||||
|
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
|
||||||
|
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
|
||||||
|
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
|
||||||
|
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
|
||||||
|
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
|
||||||
|
]);
|
||||||
|
const cookie = await t.login('sam@example.com', 'sam-password-123');
|
||||||
|
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
|
||||||
|
expect(r.statusCode, r.body).toBe(200);
|
||||||
|
const s = r.json();
|
||||||
|
expect(s.days).toEqual([
|
||||||
|
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
|
||||||
|
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
||||||
|
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
||||||
|
]);
|
||||||
|
expect(s.totals).toEqual({
|
||||||
|
calls: 3,
|
||||||
|
answered: 1,
|
||||||
|
missed: 1,
|
||||||
|
avgTalkSeconds: 40,
|
||||||
|
talkSeconds: 40,
|
||||||
|
outsideSeconds: { inbound: 95, outbound: 0 },
|
||||||
|
});
|
||||||
|
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
|
||||||
|
// The company summary is unchanged by who rang.
|
||||||
|
expect(
|
||||||
|
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
|
||||||
|
).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -306,6 +306,8 @@ export const systemApi = {
|
|||||||
export const meApi = {
|
export const meApi = {
|
||||||
calls: (cursor?: string) =>
|
calls: (cursor?: string) =>
|
||||||
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
||||||
|
/** The summary of the calls made to me. */
|
||||||
|
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
|
||||||
get: () => api<MeDto>('GET', '/api/me'),
|
get: () => api<MeDto>('GET', '/api/me'),
|
||||||
/** 14 §5.1: only for a caller ringing me now. */
|
/** 14 §5.1: only for a caller ringing me now. */
|
||||||
callerCheck: (number: string) =>
|
callerCheck: (number: string) =>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
|
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
|
||||||
// day, missed per day, busiest hours, queue table.
|
// day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
|
||||||
|
// the company's outside minutes, balance and queues.
|
||||||
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
|
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
|
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
|
||||||
import { reportsApi } from '../../api/client.ts';
|
import { meApi, reportsApi } from '../../api/client.ts';
|
||||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||||
import { fmtDuration } from './CallsPage.tsx';
|
import { fmtDuration } from './CallsPage.tsx';
|
||||||
|
|
||||||
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportsPage() {
|
export function ReportsPage({ mine = false }: { mine?: boolean }) {
|
||||||
const [draft, setDraft] = useState<ReportRange>({});
|
const [draft, setDraft] = useState<ReportRange>({});
|
||||||
const [range, setRange] = useState<ReportRange>({});
|
const [range, setRange] = useState<ReportRange>({});
|
||||||
const summary = useQuery({
|
const summary = useQuery({
|
||||||
queryKey: ['reports', 'summary', range],
|
queryKey: [mine ? 'me' : 'reports', 'summary', range],
|
||||||
queryFn: () => reportsApi.summary(range),
|
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
|
||||||
});
|
});
|
||||||
const queues = useQuery({
|
const queues = useQuery({
|
||||||
queryKey: ['reports', 'queues', range],
|
queryKey: ['reports', 'queues', range],
|
||||||
queryFn: () => reportsApi.queues(range),
|
queryFn: () => reportsApi.queues(range),
|
||||||
|
enabled: !mine,
|
||||||
|
});
|
||||||
|
const balance = useQuery({
|
||||||
|
queryKey: ['reports', 'balance'],
|
||||||
|
queryFn: reportsApi.balance,
|
||||||
|
enabled: !mine,
|
||||||
});
|
});
|
||||||
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
|
|
||||||
const s = summary.data;
|
const s = summary.data;
|
||||||
const date = (k: 'from' | 'to', label: string) => (
|
const date = (k: 'from' | 'to', label: string) => (
|
||||||
<Field label={label}>
|
<Field label={label}>
|
||||||
@@ -145,7 +151,8 @@ export function ReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
<h1 className="text-xl font-semibold">Reports</h1>
|
<h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
|
||||||
|
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
|
||||||
<form
|
<form
|
||||||
aria-label="Dates"
|
aria-label="Dates"
|
||||||
className="flex flex-wrap items-end gap-3"
|
className="flex flex-wrap items-end gap-3"
|
||||||
@@ -174,19 +181,33 @@ export function ReportsPage() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['Calls', s.totals.calls, null],
|
['Calls', s.totals.calls, null],
|
||||||
['Answered', s.totals.answered, null],
|
[
|
||||||
|
mine ? 'You answered' : 'Answered',
|
||||||
|
s.totals.answered,
|
||||||
|
mine ? 'not the ones a coworker picked up' : null,
|
||||||
|
],
|
||||||
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
|
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
|
||||||
[
|
[
|
||||||
'Average talk time',
|
'Average talk time',
|
||||||
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
||||||
null,
|
null,
|
||||||
],
|
],
|
||||||
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
|
[
|
||||||
|
'Talk time',
|
||||||
|
fmtMinutes(s.totals.talkSeconds),
|
||||||
|
mine ? null : 'people talking, inside calls too',
|
||||||
|
],
|
||||||
|
...(mine
|
||||||
|
? []
|
||||||
|
: ([
|
||||||
[
|
[
|
||||||
'Outside minutes',
|
'Outside minutes',
|
||||||
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
|
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`,
|
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
|
||||||
],
|
],
|
||||||
|
] as const)),
|
||||||
...(balance.data ?? []).map(
|
...(balance.data ?? []).map(
|
||||||
(b) =>
|
(b) =>
|
||||||
[
|
[
|
||||||
@@ -204,12 +225,18 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<Bars label="Calls per day" days={s.days} segments={SERIES} />
|
<Bars
|
||||||
|
label="Calls per day"
|
||||||
|
days={s.days}
|
||||||
|
segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
|
||||||
|
/>
|
||||||
|
{!mine && (
|
||||||
<Bars
|
<Bars
|
||||||
label="Outside minutes per day"
|
label="Outside minutes per day"
|
||||||
days={s.days}
|
days={s.days}
|
||||||
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
|
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Bars
|
<Bars
|
||||||
label="Missed per day"
|
label="Missed per day"
|
||||||
days={s.days}
|
days={s.days}
|
||||||
@@ -222,6 +249,7 @@ export function ReportsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!mine && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="font-medium">Queues</h2>
|
<h2 className="font-medium">Queues</h2>
|
||||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||||
@@ -269,6 +297,7 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
|
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
|
||||||
|
// softphone.
|
||||||
import {
|
import {
|
||||||
BookUser,
|
BookUser,
|
||||||
|
ChartColumn,
|
||||||
House,
|
House,
|
||||||
Info,
|
Info,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
|
|||||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||||
|
import { ReportsPage } from '../admin/ReportsPage.tsx';
|
||||||
|
|
||||||
const link = 'text-link underline';
|
const link = 'text-link underline';
|
||||||
|
|
||||||
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{user.extension && (
|
||||||
|
<NavLink to="/me/reports" className={tab} onClick={close}>
|
||||||
|
<ChartColumn className={icon} />
|
||||||
|
Reports
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<NavLink to="/me/settings" className={tab} onClick={close}>
|
<NavLink to="/me/settings" className={tab} onClick={close}>
|
||||||
<Settings className={icon} />
|
<Settings className={icon} />
|
||||||
Settings
|
Settings
|
||||||
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
<Route path="calls" element={<MyCallsPage />} />
|
<Route path="calls" element={<MyCallsPage />} />
|
||||||
<Route path="voicemail" element={<VoicemailPage />} />
|
<Route path="voicemail" element={<VoicemailPage />} />
|
||||||
<Route path="contacts" element={<ContactsPage />} />
|
<Route path="contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="reports" element={<ReportsPage mine />} />
|
||||||
<Route path="settings" element={<MeSettingsPage />} />
|
<Route path="settings" element={<MeSettingsPage />} />
|
||||||
<Route path="people" element={<PresencePanel me={me} />} />
|
<Route path="people" element={<PresencePanel me={me} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ Auth & self:
|
|||||||
| GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) |
|
| GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) |
|
||||||
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
|
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
|
||||||
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
|
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
|
||||||
|
| GET | `/api/me/reports/summary?from&to` | user | `/api/reports/summary` of the calls that rang or were answered by me; `answered` and talk time are the ones I picked up |
|
||||||
| GET | `/api/me/voicemail` | user | list |
|
| GET | `/api/me/voicemail` | user | list |
|
||||||
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
|
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
|
||||||
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
|
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ Implementation notes (M3):
|
|||||||
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
|
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
|
||||||
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
|
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
|
||||||
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
|
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
|
||||||
|
| Reports | `/me/reports` | The admin Reports' summary of the calls made to me (rang or answered me, not the ones I made): calls, answered by me, missed, talk time, calls and missed per day, busiest hours. No outside minutes, balance or queues. Shown only with an extension |
|
||||||
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
|
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
|
||||||
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |
|
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user