feat(reports): restore the documented sample threshold, and draw the chart

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>
This commit is contained in:
hamed
2026-08-01 16:01:40 +03:30
co-authored by Claude Opus 5
parent f2600f9922
commit 2db7a500e6
8 changed files with 177 additions and 20 deletions
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import ResourceUtilizationChart from './ResourceUtilizationChart';
const row = (over: Record<string, unknown> = {}) => ({
resource_uuid: 'r-1',
resource_name: 'اتاق ۱',
role: 'room',
available_minutes: 600,
occupied_minutes: 300,
active_minutes: 200,
utilization: 0.5,
active_ratio: 0.66,
wasted_capacity: false,
...over,
}) as never;
describe('ResourceUtilizationChart', () => {
it('renders a bar per resource that has a calendar', () => {
const { container } = render(
<ResourceUtilizationChart rows={[row(), row({ resource_uuid: 'r-2', resource_name: 'اتاق ۲' })]} />,
);
expect(screen.getByText('بهره‌وری منابع')).toBeInTheDocument();
expect(container.querySelector('.recharts-responsive-container')).not.toBeNull();
});
/** ⭐ `null` صفر نیست — ستون صفر برای منبعِ بی‌تقویم دروغ می‌گوید. */
it('leaves out resources with no calendar instead of drawing them at zero', () => {
const { container } = render(
<ResourceUtilizationChart rows={[row({ utilization: null }), row({ utilization: null, resource_uuid: 'r-2' })]} />,
);
expect(container.textContent).toBe('');
});
});
@@ -0,0 +1,68 @@
import React from 'react';
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { UtilizationRow } from '../types';
/**
* بهره‌وری هر منبع در یک نگاه.
*
* جدول شش ستون عدد دارد و برای مقایسه ساخته نشده؛ چشم نمی‌تواند بگوید کدام منبع
* عقب است. نمودار دقیقاً همان یک سؤال را جواب می‌دهد و بقیه‌اش زیرش در جدول می‌ماند.
*
* منبعِ بدون تقویم اینجا **نمی‌آید**: `null` صفر نیست و ستون صفر دروغ می‌گوید.
*/
export default function ResourceUtilizationChart({ rows }: { rows: UtilizationRow[] }) {
const data = rows
.filter((r) => r.utilization !== null)
.map((r) => ({
name: r.resource_name,
percent: Math.round((r.utilization ?? 0) * 100),
wasted: r.wasted_capacity,
}));
if (data.length === 0) return null;
return (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>بهرهوری منابع</h3>
<div style={{ width: '100%', height: Math.max(180, data.length * 42) }}>
<ResponsiveContainer>
<BarChart data={data} layout="vertical" margin={{ right: 16, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis type="number" domain={[0, 100]} unit="٪" stroke="var(--text-3)" fontSize={12} />
<YAxis
type="category"
dataKey="name"
width={120}
stroke="var(--text-3)"
fontSize={12}
orientation="right"
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0)}٪`, 'بهره‌وری'] as [string, string]}
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 12,
}}
/>
<Bar dataKey="percent" radius={[0, 6, 6, 0]}>
{/* رنگ از توکن‌ها می‌آید نه از hex — دارک‌مود همین‌جا شکسته می‌شد. */}
{data.map((row) => (
<Cell
key={row.name}
fill={row.wasted ? 'var(--danger)' : 'var(--primary)'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '8px 0 0', lineHeight: 1.8 }}>
منبعی که تقویم ندارد در نمودار نمیآید بهرهوریاش صفر نیست، تعریفنشده است.
</p>
</div>
);
}
+6 -3
View File
@@ -12,11 +12,13 @@ const RANGES = [
{ value: '90', label: 'سه ماه گذشته' },
];
const SEVERITY: Record<AccuracyRow['severity'], { label: string; className: string }> = {
const SEVERITY: Record<string, { label: string; className: string }> = {
none: { label: 'دقیق', className: 'badge green' },
low: { label: 'کم', className: 'badge' },
medium: { label: 'متوسط', className: 'badge amber' },
high: { label: 'زیاد', className: 'badge red' },
// زیر آستانهٔ نمونه: عدد هست ولی قابل استناد نیست. ادعای «دقیق» اینجا دروغ است.
unrated: { label: 'نمونهٔ کم', className: 'badge' },
};
/**
@@ -68,6 +70,7 @@ export default function PlanAccuracyPage() {
fontSize: 13,
fontWeight: 600,
color: r.severity === 'high' ? 'var(--danger)' : r.severity === 'medium' ? 'var(--warning)' : undefined,
opacity: r.below_min_sample ? 0.55 : 1,
}}
>
{r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪
@@ -88,9 +91,9 @@ export default function PlanAccuracyPage() {
key: 'severity',
header: 'شدت',
render: (r) => (
<span className={SEVERITY[r.severity].className}>
<span className={SEVERITY[r.severity ?? 'unrated'].className}>
<span className="bdot" />
{SEVERITY[r.severity].label}
{SEVERITY[r.severity ?? 'unrated'].label}
</span>
),
},
@@ -4,6 +4,7 @@ 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';
@@ -173,6 +174,8 @@ export default function ResourceUtilizationPage() {
بیمار حاضر بوده. فاصلهٔ این دو نشان میدهد بخشهای نوبت درست تعریف شدهاند یا نه.
</p>
<ResourceUtilizationChart rows={rows} />
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
+3 -1
View File
@@ -1399,7 +1399,9 @@ export interface AccuracyRow {
planned_minutes: number;
actual_minutes: number;
deviation_percent: number;
severity: 'none' | 'low' | 'medium' | 'high';
/** `null` یعنی نمونه به آستانه نرسیده — شدتی ادعا نمی‌شود */
severity: 'none' | 'low' | 'medium' | 'high' | null;
below_min_sample: boolean;
}
export interface ReportEnvelope<T> {
+15
View File
@@ -165,3 +165,18 @@ ddev exec php bin/phpunit tests/Report # ۱۶ تست
بازهٔ گزارش علاوه بر هفته/ماه/سه‌ماه، حالت **دلخواه** هم دارد (`from`/`to` شمسی در URL)
برای وقتی که کاربر دقیقاً می‌داند چه بازه‌ای می‌خواهد.
## حداقل نمونه
آستانه **۱۰** نمونه است (`PlanAccuracyReporter::MIN_SAMPLE`). زیر آن، ردیف **حذف
نمی‌شود** بلکه با `severity: null` و `below_min_sample: true` برمی‌گردد.
دو خطا با هم رد می‌شوند: ادعای شدت روی میانگین سه نمونه (که معنا ندارد) و گزارشِ خالی
برای کلینیک کوچک (که «همه‌چیز درست است» را تلقین می‌کند). UI همین ردیف‌ها را کم‌رنگ و با
نشان «نمونهٔ کم» می‌آورد و در مرتب‌سازی بعد از ردیف‌های قابل استناد می‌گذارد.
## نمودار بهره‌وری
`ResourceUtilizationChart` — میلهٔ افقی per منبع، رنگ از توکن‌ها (نه hex، وگرنه دارک‌مود
می‌شکند)، و منبعِ **بدون تقویم در نمودار نمی‌آید**: `null` صفر نیست و ستون صفر دروغ
می‌گوید. جدول زیرش می‌ماند چون شش ستون عددی را نمودار جواب نمی‌دهد.
+27 -8
View File
@@ -18,7 +18,7 @@ use Doctrine\ORM\EntityManagerInterface;
final class PlanAccuracyReporter
{
/** زیر این تعداد نمونه، میانگین معنا ندارد. */
public const MIN_SAMPLE = 3;
public const MIN_SAMPLE = 10;
public function __construct(
private readonly EntityManagerInterface $em,
@@ -59,12 +59,7 @@ final class PlanAccuracyReporter
$out = [];
foreach ($rows as $row) {
$sample = (int) $row['sample_size'];
if ($sample < self::MIN_SAMPLE) {
continue;
}
$sample = (int) $row['sample_size'];
$planned = (float) $row['planned'];
$actual = (float) $row['actual'];
@@ -72,6 +67,26 @@ final class PlanAccuracyReporter
continue;
}
// زیر آستانه، **حذف نمی‌شود بلکه بی‌شدت برمی‌گردد**.
//
// میانگینِ سه نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش
// یعنی کلینیک کوچک یک گزارش خالی می‌بیند و فکر می‌کند همه‌چیز درست است.
// این‌طوری هم عدد را می‌بیند هم می‌داند که هنوز قابل استناد نیست.
if ($sample < self::MIN_SAMPLE) {
$out[] = [
'service_uuid' => $row['service_uuid'],
'service_name' => $row['service_name'],
'sample_size' => $sample,
'planned_minutes' => (int) round($planned),
'actual_minutes' => (int) round($actual),
'deviation_percent' => (int) round(($actual - $planned) / $planned * 100),
'severity' => null,
'below_min_sample' => true,
];
continue;
}
$deviation = (int) round(($actual - $planned) / $planned * 100);
$out[] = [
@@ -82,10 +97,14 @@ final class PlanAccuracyReporter
'actual_minutes' => (int) round($actual),
'deviation_percent' => $deviation,
'severity' => $this->severityFor($deviation),
'below_min_sample' => false,
];
}
usort($out, static fn (array $a, array $b): int => abs($b['deviation_percent']) <=> abs($a['deviation_percent']));
// ردیف‌های قابل استناد اول؛ بین خودشان، بدترین انحراف بالاتر.
usort($out, static fn (array $a, array $b): int
=> [$a['below_min_sample'], abs($b['deviation_percent'])]
<=> [$b['below_min_sample'], abs($a['deviation_percent'])]);
return $out;
}
+19 -8
View File
@@ -97,7 +97,7 @@ class ReportTest extends ApiTestCase
$service = $this->service($section, 'لیزر فول‌بادی', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 4; $i++) {
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
}
@@ -125,7 +125,7 @@ class ReportTest extends ApiTestCase
$service = $this->service($section, 'مشاوره', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 3; $i++) {
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i);
}
@@ -140,7 +140,13 @@ class ReportTest extends ApiTestCase
}
/** زیر سه نمونه، میانگین معنا ندارد. */
public function testASmallSampleIsNotReported(): void
/**
* ⭐ زیر آستانه **حذف نمی‌شود، بی‌شدت برمی‌گردد**.
*
* میانگین دو نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش یعنی
* کلینیک کوچک گزارشی خالی می‌بیند و فکر می‌کند همه‌چیز درست است.
*/
public function testASmallSampleIsShownWithoutASeverity(): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'خدمت کم‌تکرار', 60);
@@ -155,10 +161,15 @@ class ReportTest extends ApiTestCase
$user,
)['data']['rows'];
self::assertSame([], array_values(array_filter(
$mine = array_values(array_filter(
$rows,
static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(),
)));
));
self::assertCount(1, $mine);
self::assertNull($mine[0]['severity'], 'با دو نمونه نباید شدتی ادعا شود');
self::assertTrue($mine[0]['below_min_sample']);
self::assertSame(2, $mine[0]['sample_size']);
}
public function testAnAccurateServiceHasNoSeverity(): void
@@ -167,7 +178,7 @@ class ReportTest extends ApiTestCase
$service = $this->service($section, 'خدمت دقیق', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 3; $i++) {
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i);
}
@@ -202,7 +213,7 @@ class ReportTest extends ApiTestCase
$service = $this->service($section, 'خدمت آستانه', $planned);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 3; $i++) {
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i);
}
@@ -483,7 +494,7 @@ class ReportTest extends ApiTestCase
$service = $this->service($section, 'لیزر', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 3; $i++) {
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
}