// What each admin report destination draws, taken from its approved deck frame. The server sends counts by // key; the words, tones and order live here. import type { LucideIcon } from 'lucide-react'; import { drillPeriodParams } from '../../../utils/reportDrillPeriod'; import { formatRange } from './reportPeriod'; import { Clock, CreditCard, FlaskConical, ListTodo, MessageSquareText, Share2, ShieldCheck } from 'lucide-react'; import { formatValue, type PracticeReport, type PracticeReportKind, type ReportColumn, type ValueFormat } from './practiceReport'; import type { ReportFilters } from './reportPeriod'; export type Tone = 'good' | 'warn' | 'bad' | ''; export interface ReportMeasure extends ReportColumn { tone: Tone; // Which direction is good news; volume is neither. better?: 'up' | 'down'; percent?: boolean; } export interface ChartLine { key: string; label: string; value: number; tone: Tone; text: string; } interface ChartBase { title: string; meta: string; legend: Array<[string, Tone]>; } export type ReportChartSpec = | (ChartBase & { kind: 'lines'; lines: (report: PracticeReport) => ChartLine[] }) | (ChartBase & { kind: 'weeks'; format: ValueFormat; series: Array<{ key: string; label: string; tone: Tone }> }); /** The chart's figures as rows, so an export carries what the chart draws. */ export const chartTableOf = (chart: ReportChartSpec, report: PracticeReport): string[][] => (chart.kind === 'weeks' ? [['Week', ...chart.series.map((series) => series.label)], ...(report.weeks ?? []).map((week) => [ formatRange(week.start, week.end), ...chart.series.map((series) => formatValue(week.values[series.key] ?? 0, chart.format)), ])] : [[chart.title, 'Value'], ...chart.lines(report).map((line) => [line.label, line.text])]); export interface ReportDefinition { kind: PracticeReportKind; title: string; subtitle: string; icon: LucideIcon; about: string; overview: ReportMeasure[]; measures: ReportMeasure[]; chart: ReportChartSpec; // person: the first column names a staff member, drawn with their initials. // drill: where a row's count opens, carrying the report's filters; absent rows stay plain text. table: { first: string; columns: ReportColumn[]; empty: string; person?: boolean; drill?: (rowKey: string, filters: ReportFilters, timezone: string) => string | undefined }; footer: { text: string; action: string; to: string }; // Set when the records behind the report carry no location, so a chosen location cannot narrow it. practiceWide?: string; note?: string; } const billing: ReportDefinition = { kind: 'billing', title: 'Billing', subtitle: 'Invoices issued and payments recorded in the selected period.', icon: CreditCard, about: 'Invoices sent, payments and balances', overview: [ { key: 'invoiced', label: 'invoiced', tone: '', format: 'money' }, { key: 'collected', label: 'collected', tone: 'good', format: 'money' }, { key: 'remaining', label: 'remaining', tone: 'warn', format: 'money' }, ], measures: [ { key: 'invoiced', label: 'invoiced', tone: '', format: 'money' }, { key: 'collected', label: 'collected', tone: 'good', format: 'money', better: 'up', percent: true }, { key: 'remaining', label: 'remaining', tone: 'warn', format: 'money', better: 'down' }, { key: 'pastDue', label: 'past due', tone: 'bad', format: 'money', better: 'down' }, ], chart: { kind: 'weeks', title: 'Invoiced and collected by week', meta: 'Payments may settle after the invoice week', legend: [['Invoiced', ''], ['Collected', 'good']], format: 'money', series: [ { key: 'invoiced', label: 'Invoiced', tone: '' }, { key: 'collected', label: 'Collected', tone: 'good' }, ], }, table: { first: 'Status', columns: [ { key: 'invoices', label: 'Invoices' }, { key: 'billed', label: 'Billed', format: 'money' }, { key: 'collected', label: 'Collected', format: 'money' }, { key: 'remaining', label: 'Remaining', format: 'money' }, ], empty: 'No invoices were issued in this period.', // The list has no partially-paid filter; a part-paid invoice is still sent work, so it opens there. drill: (rowKey, filters, timezone) => '/dashboard/billing/invoices?' + new URLSearchParams({ filter: rowKey === 'paid' ? 'paid' : 'sent', ...drillPeriodParams(filters, timezone), }).toString(), }, footer: { text: 'Need patient-level invoices or the biller handoff?', action: 'Open Billing', to: '/dashboard/billing/overview', }, practiceWide: 'Invoices carry no location, so this report covers the whole practice.', }; const DOT = ' ' + String.fromCharCode(0x00b7) + ' '; const amount = (value: unknown) => (typeof value === 'number' ? value : 0); const staffTime: ReportDefinition = { kind: 'staff-time', title: 'Staff time', subtitle: 'Scheduled coverage and completed appointment time in the selected period.', icon: Clock, about: 'Scheduled coverage and completed appointment time', overview: [ { key: 'scheduledMinutes', label: 'scheduled coverage', tone: '', format: 'hours' }, { key: 'appointmentMinutes', label: 'completed appointment time', tone: '', format: 'hours' }, { key: 'staffScheduled', label: 'staff scheduled', tone: '' }, ], measures: [ { key: 'scheduledMinutes', label: 'scheduled coverage', tone: '', format: 'hours' }, { key: 'appointmentMinutes', label: 'completed appointment time', tone: '', format: 'hours', percent: true }, { key: 'staffScheduled', label: 'staff scheduled', tone: '' }, { key: 'locations', label: 'locations', tone: '' }, ], chart: { kind: 'lines', title: 'Coverage and appointment time', meta: 'By location for the selected period', legend: [['Scheduled coverage', ''], ['Appointment time', 'good']], // The server supplies a No location recorded row; totals and location rows are not additive. lines: (report) => (report.chart ?? []).flatMap((row) => [ { key: row.key + '-scheduled', label: row.label + DOT + 'scheduled coverage', value: amount(row.values.scheduled), tone: '' as const, text: formatValue(amount(row.values.scheduled), 'hours') }, { key: row.key + '-appointment', label: row.label + DOT + 'appointment time', value: amount(row.values.visits), tone: 'good' as const, text: formatValue(amount(row.values.visits), 'hours') }, ]), }, table: { first: 'Staff member', person: true, columns: [ { key: 'role', label: 'Role', format: 'text' }, { key: 'scheduled', label: 'Scheduled coverage', format: 'hours' }, { key: 'visits', label: 'Completed visits' }, { key: 'appointment', label: 'Appointment time', format: 'hours' }, ], empty: 'No staff were scheduled or saw patients in this period.', drill: (rowKey, filters) => '/dashboard/schedules?' + new URLSearchParams({ role: rowKey, start: filters.start, end: filters.end, }).toString(), }, footer: { text: 'Review coverage: opening Schedules keeps the selected location and period.', action: 'Open Schedules', to: '/dashboard/schedules', }, note: 'This is not a payroll timecard. Scheduled coverage comes from staff schedules. Completed appointment time is shown only where completed visits have start and end times.', }; const compliance: ReportDefinition = { kind: 'compliance', title: 'Access & compliance', subtitle: 'Who accessed records, what authority changed and whether required agreements are current.', icon: ShieldCheck, about: 'Access events, role changes and agreements', overview: [ { key: 'accessEvents', label: 'access events', tone: '' }, { key: 'roleChanges', label: 'role changes', tone: '' }, { key: 'baa', label: 'BAA', tone: 'good', format: 'text' }, ], measures: [ { key: 'accessEvents', label: 'record access events', tone: '' }, { key: 'roleChanges', label: 'role changes', tone: '' }, { key: 'baa', label: 'business associate agreement', tone: 'good', format: 'text' }, { key: 'practiceAgreements', label: 'patient agreements written by the practice', tone: '', format: 'text' }, ], chart: { kind: 'lines', title: 'Record access by action', meta: 'What each recorded chart access did', legend: [], lines: (report) => (report.chart ?? []).map((row) => ({ key: row.key, label: row.label, value: amount(row.values.events), tone: '' as const, text: formatValue(amount(row.values.events)), })), }, table: { first: 'Recorded activity', columns: [ { key: 'events', label: 'Events' }, { key: 'mostRecent', label: 'Most recent', format: 'datetime' }, { key: 'review', label: 'Where to review', format: 'text' }, ], empty: 'Nothing was recorded in this period.', // Recorded events open the access log on the period; agreements have no log entries to open. drill: (rowKey, filters, timezone) => (rowKey === 'record-access' || rowKey === 'role-change' ? '/dashboard/audit-events?' + new URLSearchParams(drillPeriodParams(filters, timezone)).toString() : undefined), }, footer: { text: 'Review the evidence: the Practice Access Log keeps who acted, when and on which record.', action: 'Open Practice Access Log', to: '/dashboard/audit-events', }, practiceWide: 'Access events and agreements belong to the whole practice, so a location does not narrow this report.', }; const ROUTE_SHORT: Record = { 'inbound-akashic': 'Inbound' + DOT + 'Akashic', 'inbound-external': 'Inbound' + DOT + 'external', 'outbound-akashic': 'Outbound' + DOT + 'Akashic', 'outbound-external': 'Outbound' + DOT + 'external', }; const referrals: ReportDefinition = { kind: 'referrals', title: 'Referrals', subtitle: 'Where referrals came from, where they went and what still needs action.', icon: Share2, about: 'Inbound and outbound progress', overview: [ { key: 'received', label: 'received', tone: '' }, { key: 'closed', label: 'closed', tone: 'good' }, { key: 'needAction', label: 'need action', tone: 'bad' }, ], measures: [ { key: 'received', label: 'received', tone: '' }, { key: 'closed', label: 'closed', tone: 'good', better: 'up' }, { key: 'needAction', label: 'need action', tone: 'warn', better: 'down' }, { key: 'overdue', label: 'overdue', tone: 'bad', better: 'down' }, ], chart: { kind: 'lines', title: 'Referral progress by route', meta: 'Current-period volume and closed work', legend: [['Received or sent', ''], ['Closed', 'good']], lines: (report) => report.rows.map((row) => ({ key: row.key, label: ROUTE_SHORT[row.key] ?? row.label, value: amount(row.values.started), tone: '' as const, text: formatValue(amount(row.values.started)) + ' / ' + formatValue(amount(row.values.closed)), })), }, table: { first: 'Direction and route', columns: [ { key: 'started', label: 'Started' }, { key: 'closed', label: 'Closed' }, { key: 'needAction', label: 'Need action' }, { key: 'overdue', label: 'Overdue' }, ], empty: 'No referrals were received or sent in this period.', drill: (rowKey, filters, timezone) => '/dashboard/referrals?' + new URLSearchParams({ route: rowKey, ...drillPeriodParams(filters, timezone), }).toString(), }, footer: { text: 'Act on the exceptions: opening Referrals shows what is waiting to be taken.', action: 'Open Referrals', to: '/dashboard/referrals', }, practiceWide: 'Referrals carry no location, so this report covers the whole practice.', }; const orders: ReportDefinition = { kind: 'orders', title: 'Orders & results', subtitle: 'Turnaround and unfinished work across laboratory and imaging orders.', icon: FlaskConical, about: 'Laboratory and imaging turnaround', overview: [ { key: 'resulted', label: 'resulted', tone: 'good' }, { key: 'pending', label: 'pending', tone: 'warn' }, { key: 'awaitingReview', label: 'awaiting review', tone: 'bad' }, ], measures: [ { key: 'placed', label: 'orders placed', tone: '' }, { key: 'resulted', label: 'resulted', tone: 'good', better: 'up' }, { key: 'pending', label: 'pending', tone: 'warn', better: 'down' }, { key: 'awaitingReview', label: 'awaiting review', tone: 'bad', better: 'down' }, ], chart: { kind: 'lines', title: 'Order state by service', meta: 'Results and outstanding work', legend: [['Resulted', 'good'], ['Pending', 'warn'], ['Awaiting review', 'bad']], lines: (report) => report.rows.map((row) => ({ key: row.key, label: row.label, value: amount(row.values.resulted), tone: 'good' as const, text: formatValue(amount(row.values.resulted)) + ' results', })), }, table: { first: 'Service', columns: [ { key: 'ordered', label: 'Ordered' }, { key: 'resulted', label: 'Resulted' }, { key: 'pending', label: 'Pending' }, { key: 'awaitingReview', label: 'Awaiting review' }, ], empty: 'No laboratory or imaging orders were placed in this period.', drill: (rowKey, filters, timezone) => '/dashboard/diagnostic-orders?' + new URLSearchParams({ kind: rowKey, ...drillPeriodParams(filters, timezone), }).toString(), }, footer: { text: 'Act on the exceptions: opening Tests & imaging shows the orders behind each count.', action: 'Open Tests & imaging', to: '/dashboard/diagnostic-orders', }, practiceWide: 'Orders carry no location, so this report covers the whole practice.', }; const tasks: ReportDefinition = { kind: 'tasks', title: 'Tasks & work queues', subtitle: 'Open, overdue and unassigned work by queue.', icon: ListTodo, about: 'Open work by queue', overview: [ { key: 'open', label: 'open', tone: '' }, { key: 'overdue', label: 'overdue', tone: 'warn' }, { key: 'unassigned', label: 'unassigned', tone: 'bad' }, ], measures: [ { key: 'open', label: 'open', tone: '', better: 'down' }, { key: 'overdue', label: 'overdue', tone: 'warn', better: 'down' }, { key: 'unassigned', label: 'unassigned', tone: 'bad', better: 'down' }, { key: 'completed', label: 'completed', tone: 'good', better: 'up' }, ], chart: { kind: 'lines', title: 'Overdue work by queue', meta: 'Queues with work past its due time', legend: [], lines: (report) => report.rows.map((row) => ({ key: row.key, label: row.label, value: amount(row.values.overdue), tone: 'warn' as const, text: formatValue(amount(row.values.overdue)), })), }, table: { first: 'Queue', columns: [ { key: 'open', label: 'Open' }, { key: 'overdue', label: 'Overdue' }, { key: 'unassigned', label: 'Unassigned' }, { key: 'completed', label: 'Completed' }, ], empty: 'No tasks were created in this period.', // The referral queue is worked on Referrals; it has no period to narrow, so the row opens it whole. drill: (rowKey) => (rowKey === 'referral' ? '/dashboard/referrals' : undefined), }, footer: { text: 'Act on the exceptions: tasks are worked from your workspace, Referrals, Tests & imaging and Form Submissions.', action: 'Open your workspace', to: '/dashboard/home', }, practiceWide: 'Tasks carry no location, so this report covers the whole practice.', }; const communication: ReportDefinition = { kind: 'communication', title: 'Patient communication', subtitle: 'Front-desk conversations and patient forms in the selected period.', icon: MessageSquareText, about: 'Front-desk conversations and forms', overview: [ { key: 'messages', label: 'messages', tone: '' }, { key: 'awaitingReply', label: 'awaiting reply', tone: 'warn' }, { key: 'formsCompleted', label: 'forms completed', tone: 'good' }, ], measures: [ { key: 'messages', label: 'front-desk messages', tone: '' }, { key: 'awaitingReply', label: 'conversations awaiting reply', tone: 'warn', better: 'down' }, { key: 'formsSent', label: 'forms sent', tone: '' }, { key: 'formsCompleted', label: 'forms completed', tone: 'good', better: 'up' }, ], chart: { kind: 'lines', title: 'Communication by workflow', meta: 'Each row uses the status meaningful to that channel', legend: [], lines: (report) => report.rows.map((row) => ({ key: row.key, label: row.label, value: amount(row.values.sent), tone: '' as const, text: formatValue(amount(row.values.sent)) + ' sent', })), }, table: { first: 'Workflow', columns: [ { key: 'sent', label: 'Sent' }, { key: 'completed', label: 'Completed' }, { key: 'followUp', label: 'Needs follow-up' }, ], empty: 'No front-desk messages or patient forms in this period.', // Front-desk conversations are answered in Messages, which has no period to narrow. drill: (rowKey) => (rowKey === 'front-desk' ? '/dashboard/staff-messages' : undefined), }, footer: { text: 'Open Messages to follow up on front-desk conversations.', action: 'Open Messages', to: '/dashboard/staff-messages', }, practiceWide: 'Conversations and forms carry no location, so this report covers the whole practice.', note: "Conversations are the front desk's shared inbox only; a practitioner's own patient threads are not counted. Email and text delivery are not included.", }; export const REPORT_DEFINITIONS: ReportDefinition[] = [referrals, orders, tasks, communication, billing, staffTime, compliance]; export const definitionOf = (kind: string | undefined): ReportDefinition | undefined => REPORT_DEFINITIONS.find((definition) => definition.kind === kind);