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>
190 lines
7.5 KiB
TypeScript
190 lines
7.5 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 PersianDateInput from '../components/ui/PersianDateInput';
|
|
import ResourceUtilizationChart from '../components/ResourceUtilizationChart';
|
|
import { Link } from 'react-router-dom';
|
|
import { isoToUnix, unixToIso } from '../lib/utils';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
import { useResourceUtilization } from '../hooks/useReports';
|
|
import type { UtilizationRow } from '../types';
|
|
|
|
const RANGES = [
|
|
{ value: '7', label: 'هفتهٔ گذشته' },
|
|
{ value: '30', label: 'ماه گذشته' },
|
|
{ value: '90', label: 'سه ماه گذشته' },
|
|
{ value: 'custom', label: 'بازهٔ دلخواه' },
|
|
];
|
|
|
|
function percent(value: number | null): string {
|
|
return value === null ? '—' : `${Math.round(value * 100)}٪`;
|
|
}
|
|
|
|
/**
|
|
* بهرهوری منابع.
|
|
*
|
|
* ستون «نسبت کار مفید» مهمترین ستون است: فاصلهاش با «اشغال» همان چیزی است که تعریف
|
|
* غلط بخشها را لو میدهد.
|
|
*/
|
|
export default function ResourceUtilizationPage() {
|
|
const { branches } = useBranches();
|
|
// بازه و شعبه در URL مینشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را
|
|
// برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده.
|
|
const [urlState, setUrlState] = useUrlState({ branch: '', days: '7', from: '', to: '' });
|
|
const branchUuid = urlState.branch;
|
|
const days = urlState.days;
|
|
|
|
const setBranchUuid = (v: string) => setUrlState({ branch: v });
|
|
const setDays = (v: string) => setUrlState({ days: v });
|
|
|
|
/**
|
|
* بازهٔ آماده برای حالت عادی، بازهٔ دلخواه برای وقتی که کاربر دقیقاً میداند چه
|
|
* میخواهد — مثلاً مقایسهٔ دو ماه مشخص با هم.
|
|
*/
|
|
const range = useMemo(() => {
|
|
if (days === 'custom') {
|
|
return {
|
|
from: isoToUnix(urlState.from) ?? Math.floor(Date.now() / 1000) - 7 * 86400,
|
|
to: isoToUnix(urlState.to) ?? Math.floor(Date.now() / 1000),
|
|
};
|
|
}
|
|
|
|
const to = Math.floor(Date.now() / 1000);
|
|
return { from: to - Number(days) * 86400, to };
|
|
}, [days, urlState.from, urlState.to]);
|
|
|
|
const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to);
|
|
|
|
const columns: Column<UtilizationRow>[] = [
|
|
{
|
|
key: 'resource_name',
|
|
header: 'منبع',
|
|
render: (r) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<span style={{ fontWeight: 600 }}>{r.resource_name}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.role}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'available_minutes',
|
|
header: 'در دسترس',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.available_minutes} دقیقه</span>,
|
|
},
|
|
{
|
|
key: 'occupied_minutes',
|
|
header: 'اشغال',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.occupied_minutes} دقیقه</span>,
|
|
},
|
|
{
|
|
key: 'active_minutes',
|
|
header: 'کار مفید',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.active_minutes} دقیقه</span>,
|
|
},
|
|
{
|
|
key: 'utilization',
|
|
header: 'بهرهوری',
|
|
// `null` یعنی «تعریفنشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز.
|
|
// بدون لینک، کاربر عدد را میبیند و نمیداند کجا باید برود.
|
|
render: (r) =>
|
|
r.utilization === null ? (
|
|
<Link
|
|
to={`/admin/resources/${r.resource_uuid}/calendar`}
|
|
style={{ fontSize: 12, color: 'var(--primary)' }}
|
|
title="برای این منبع تقویمی تعریف نشده است"
|
|
>
|
|
تنظیم تقویم
|
|
</Link>
|
|
) : (
|
|
<span style={{ fontSize: 13 }}>{percent(r.utilization)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'active_ratio',
|
|
header: 'نسبت کار مفید',
|
|
// توضیح باید در خودِ صفحه باشد، نه فقط در مستندات: کسی که گزارش را میخواند
|
|
// مستندات را باز نمیکند.
|
|
render: (r) => (
|
|
<span
|
|
style={{ fontSize: 13, fontWeight: 600, color: r.wasted_capacity ? 'var(--danger)' : undefined }}
|
|
title="چه سهمی از زمانِ اشغال، واقعاً کار روی بیمار بوده"
|
|
>
|
|
{percent(r.active_ratio)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'wasted_capacity',
|
|
header: '',
|
|
render: (r) =>
|
|
r.wasted_capacity ? (
|
|
<span className="badge red"><span className="bdot" />ظرفیت هدررفته</span>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
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: 220, margin: 0 }}>
|
|
<label>شعبه</label>
|
|
<SearchableSelect
|
|
value={branchUuid}
|
|
onChange={(v) => setBranchUuid(String(v ?? ''))}
|
|
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
|
placeholder="انتخاب شعبه"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-block" style={{ minWidth: 180, margin: 0 }}>
|
|
<label>بازه</label>
|
|
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
|
|
</div>
|
|
|
|
{days === 'custom' && (
|
|
<>
|
|
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
|
<label>از تاریخ</label>
|
|
<PersianDateInput
|
|
value={urlState.from || unixToIso(range.from)}
|
|
onChange={(v) => setUrlState({ from: v })}
|
|
/>
|
|
</div>
|
|
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
|
<label>تا تاریخ</label>
|
|
<PersianDateInput
|
|
value={urlState.to || unixToIso(range.to)}
|
|
onChange={(v) => setUrlState({ to: v })}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
|
|
«اشغال» شامل آمادهسازی، تمیزکاری و بخشهای انتظار است؛ «کار مفید» فقط زمانی که
|
|
بیمار حاضر بوده. فاصلهٔ این دو نشان میدهد بخشهای نوبت درست تعریف شدهاند یا نه.
|
|
</p>
|
|
|
|
<ResourceUtilizationChart rows={rows} />
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
emptyMessage={branchUuid === '' ? 'برای دیدن گزارش، شعبه را انتخاب کنید' : 'منبعی برای این شعبه نیست'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|