Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
TypeScript
140 lines
5.1 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 { 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: 'سه ماه گذشته' },
|
|
];
|
|
|
|
function percent(value: number | null): string {
|
|
return value === null ? '—' : `${Math.round(value * 100)}٪`;
|
|
}
|
|
|
|
/**
|
|
* بهرهوری منابع.
|
|
*
|
|
* ستون «نسبت کار مفید» مهمترین ستون است: فاصلهاش با «اشغال» همان چیزی است که تعریف
|
|
* غلط بخشها را لو میدهد.
|
|
*/
|
|
export default function ResourceUtilizationPage() {
|
|
const { branches } = useBranches();
|
|
const [branchUuid, setBranchUuid] = useState('');
|
|
const [days, setDays] = useState('7');
|
|
|
|
const range = useMemo(() => {
|
|
const to = Math.floor(Date.now() / 1000);
|
|
return { from: to - Number(days) * 86400, to };
|
|
}, [days]);
|
|
|
|
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: 'بهرهوری',
|
|
render: (r) => (
|
|
<span
|
|
style={{ fontSize: 13 }}
|
|
title={r.utilization === null ? 'برای این منبع تقویمی تعریف نشده است' : undefined}
|
|
>
|
|
{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" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<div className="field" 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" style={{ minWidth: 180, margin: 0 }}>
|
|
<label>بازه</label>
|
|
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
|
|
</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>
|
|
);
|
|
}
|