Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
TypeScript
124 lines
4.3 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 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>
|
|
);
|
|
}
|