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>
187 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|