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>
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 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>
|
|
</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>
|
|
);
|
|
}
|