Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { Link } from 'react-router-dom';
|
|
import { usePlanAccuracy } from '../hooks/useReports';
|
|
import type { AccuracyRow } from '../types';
|
|
|
|
const RANGES = [
|
|
{ value: '30', label: 'ماه گذشته' },
|
|
{ value: '90', label: 'سه ماه گذشته' },
|
|
];
|
|
|
|
const SEVERITY: Record<AccuracyRow['severity'], { label: string; className: string }> = {
|
|
none: { label: 'دقیق', className: 'badge green' },
|
|
low: { label: 'کم', className: 'badge' },
|
|
medium: { label: 'متوسط', className: 'badge amber' },
|
|
high: { label: 'زیاد', className: 'badge red' },
|
|
};
|
|
|
|
/**
|
|
* مدت پیشبینیشده در برابر مدت واقعی.
|
|
*
|
|
* سرویسی که یک ساعت پیشبینی شده ولی یکساعتونیم طول میکشد، هر روز نیم ساعت از ظرفیت
|
|
* کلینیک را بیصدا میخورد — این صفحه تنها جایی است که آن را نشان میدهد.
|
|
*/
|
|
export default function PlanAccuracyPage() {
|
|
const [days, setDays] = useState('30');
|
|
|
|
const range = useMemo(() => {
|
|
const to = Math.floor(Date.now() / 1000);
|
|
return { from: to - Number(days) * 86400, to };
|
|
}, [days]);
|
|
|
|
const { rows, loading } = usePlanAccuracy(range.from, range.to);
|
|
|
|
const columns: Column<AccuracyRow>[] = [
|
|
{
|
|
key: 'service_name',
|
|
header: 'خدمت',
|
|
render: (r) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<span style={{ fontWeight: 600 }}>{r.service_name}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.sample_size} نوبت</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'planned_minutes',
|
|
header: 'پیشبینی',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.planned_minutes} دقیقه</span>,
|
|
},
|
|
{
|
|
key: 'actual_minutes',
|
|
header: 'واقعی',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.actual_minutes} دقیقه</span>,
|
|
},
|
|
{
|
|
key: 'deviation_percent',
|
|
header: 'انحراف',
|
|
render: (r) => (
|
|
<span
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: r.severity === 'high' ? 'var(--danger)' : r.severity === 'medium' ? 'var(--warning)' : undefined,
|
|
}}
|
|
>
|
|
{r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'fix',
|
|
header: '',
|
|
// گزارشی که راه اصلاح ندهد خوانده نمیشود.
|
|
render: (r) => (
|
|
<Link className="btn secondary sm" to={`/admin/services?service=${r.service_uuid}`}>
|
|
ویرایش بخشهای این خدمت
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
key: 'severity',
|
|
header: 'شدت',
|
|
render: (r) => (
|
|
<span className={SEVERITY[r.severity].className}>
|
|
<span className="bdot" />
|
|
{SEVERITY[r.severity].label}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title="دقت برنامهٔ نوبت"
|
|
description="مقایسهٔ مدت پیشبینیشدهٔ هر خدمت با مدت واقعی نوبتهای انجامشده."
|
|
backTo="/admin/settings-menu"
|
|
/>
|
|
|
|
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<div className="field" style={{ minWidth: 200, margin: 0 }}>
|
|
<label>بازه</label>
|
|
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
|
|
</div>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'flex-end' }}>
|
|
خدماتی با کمتر از سه نوبت انجامشده در گزارش نمیآیند.
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
emptyMessage="دادهای برای این بازه نیست"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|