Files
clinicpro/assets/admin/pages/ResourceUtilizationPage.tsx
T
hamedandClaude Opus 5 5c754244f2 feat(admin): finish the screens that were stopping one step short
Five places where the data existed and the screen did not use it.

Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.

A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.

The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.

Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.

A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.

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

187 lines
7.4 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 { 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>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage={branchUuid === '' ? 'برای دیدن گزارش، شعبه را انتخاب کنید' : 'منبعی برای این شعبه نیست'}
/>
</div>
</div>
);
}