Files
clinicpro/assets/admin/pages/PlanAccuracyPage.tsx
T
hamedandClaude Opus 5 2db7a500e6 feat(reports): restore the documented sample threshold, and draw the chart
MIN_SAMPLE goes back to the specified 10. The reason it had been lowered to 3
was real — a small clinic saw an empty report — but the fix was wrong: three
samples do not make an average, and calling that "accurate" is worse than
saying nothing.

Rows below the threshold are now returned rather than dropped, with severity
null and below_min_sample true. That refuses both mistakes: it claims no
severity it cannot support, and it does not show a small clinic an empty page
that implies everything is fine. They sort after the usable rows and render
faded with a "small sample" badge.

The utilization page gets its Recharts bar chart. The table stays underneath —
six numeric columns are not something a chart answers — but the one question
the table is bad at, "which resource is behind", is exactly what a chart is
for. Colours come from the design tokens rather than hex, which is where a
chart usually breaks in dark mode, and a resource with no calendar is left out
entirely: null is not zero, and a zero bar would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:01:40 +03:30

131 lines
4.8 KiB
TypeScript

import React, { useMemo } from 'react';
import { useUrlState } from '../hooks/useUrlState';
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<string, { label: string; className: string }> = {
none: { label: 'دقیق', className: 'badge green' },
low: { label: 'کم', className: 'badge' },
medium: { label: 'متوسط', className: 'badge amber' },
high: { label: 'زیاد', className: 'badge red' },
// زیر آستانهٔ نمونه: عدد هست ولی قابل استناد نیست. ادعای «دقیق» اینجا دروغ است.
unrated: { label: 'نمونهٔ کم', className: 'badge' },
};
/**
* مدت پیش‌بینی‌شده در برابر مدت واقعی.
*
* سرویسی که یک ساعت پیش‌بینی شده ولی یک‌ساعت‌ونیم طول می‌کشد، هر روز نیم ساعت از ظرفیت
* کلینیک را بی‌صدا می‌خورد — این صفحه تنها جایی است که آن را نشان می‌دهد.
*/
export default function PlanAccuracyPage() {
// بازه در URL: لینکِ گزارش باید همان بازه‌ای را باز کند که فرستنده دیده بود.
const [urlState, setUrlState] = useUrlState({ days: '30' });
const days = urlState.days;
const setDays = (v: string) => setUrlState({ days: v });
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,
opacity: r.below_min_sample ? 0.55 : 1,
}}
>
{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 ?? 'unrated'].className}>
<span className="bdot" />
{SEVERITY[r.severity ?? 'unrated'].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="دقت برنامهٔ نوبت"
description="مقایسهٔ مدت پیش‌بینی‌شدهٔ هر خدمت با مدت واقعی نوبت‌های انجام‌شده."
backTo="/admin/settings-menu"
/>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field-block" 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>
);
}