From 2db7a500e69557283bc03ddd12a0589ac5f6cea9 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Sat, 1 Aug 2026 16:01:40 +0330
Subject: [PATCH] feat(reports): restore the documented sample threshold, and
draw the chart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../ResourceUtilizationChart.test.tsx | 36 ++++++++++
.../components/ResourceUtilizationChart.tsx | 68 +++++++++++++++++++
assets/admin/pages/PlanAccuracyPage.tsx | 9 ++-
.../admin/pages/ResourceUtilizationPage.tsx | 3 +
assets/admin/types/index.ts | 4 +-
docs/api/reports.md | 15 ++++
src/Report/Service/PlanAccuracyReporter.php | 35 +++++++---
tests/Report/ReportTest.php | 27 +++++---
8 files changed, 177 insertions(+), 20 deletions(-)
create mode 100644 assets/admin/components/ResourceUtilizationChart.test.tsx
create mode 100644 assets/admin/components/ResourceUtilizationChart.tsx
diff --git a/assets/admin/components/ResourceUtilizationChart.test.tsx b/assets/admin/components/ResourceUtilizationChart.test.tsx
new file mode 100644
index 00000000..668eec7a
--- /dev/null
+++ b/assets/admin/components/ResourceUtilizationChart.test.tsx
@@ -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 = {}) => ({
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(container.textContent).toBe('');
+ });
+});
diff --git a/assets/admin/components/ResourceUtilizationChart.tsx b/assets/admin/components/ResourceUtilizationChart.tsx
new file mode 100644
index 00000000..c13d1171
--- /dev/null
+++ b/assets/admin/components/ResourceUtilizationChart.tsx
@@ -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 (
+
+
بهرهوری منابع
+
+
+
+
+
+
+
+ [`${Number(v ?? 0)}٪`, 'بهرهوری'] as [string, string]}
+ contentStyle={{
+ background: 'var(--surface)',
+ border: '1px solid var(--border)',
+ borderRadius: 8,
+ fontSize: 12,
+ }}
+ />
+
+ {/* رنگ از توکنها میآید نه از hex — دارکمود همینجا شکسته میشد. */}
+ {data.map((row) => (
+ |
+ ))}
+
+
+
+
+
+
+ منبعی که تقویم ندارد در نمودار نمیآید — بهرهوریاش صفر نیست، تعریفنشده است.
+
+
+ );
+}
diff --git a/assets/admin/pages/PlanAccuracyPage.tsx b/assets/admin/pages/PlanAccuracyPage.tsx
index ba2d6796..8ba8c064 100644
--- a/assets/admin/pages/PlanAccuracyPage.tsx
+++ b/assets/admin/pages/PlanAccuracyPage.tsx
@@ -12,11 +12,13 @@ const RANGES = [
{ value: '90', label: 'سه ماه گذشته' },
];
-const SEVERITY: Record = {
+const SEVERITY: Record = {
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) => (
-
+
- {SEVERITY[r.severity].label}
+ {SEVERITY[r.severity ?? 'unrated'].label}
),
},
diff --git a/assets/admin/pages/ResourceUtilizationPage.tsx b/assets/admin/pages/ResourceUtilizationPage.tsx
index 23335158..8b7c9aba 100644
--- a/assets/admin/pages/ResourceUtilizationPage.tsx
+++ b/assets/admin/pages/ResourceUtilizationPage.tsx
@@ -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() {
بیمار حاضر بوده. فاصلهٔ این دو نشان میدهد بخشهای نوبت درست تعریف شدهاند یا نه.
+
+
{
diff --git a/docs/api/reports.md b/docs/api/reports.md
index a82af454..7dee32f7 100644
--- a/docs/api/reports.md
+++ b/docs/api/reports.md
@@ -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` صفر نیست و ستون صفر دروغ
+میگوید. جدول زیرش میماند چون شش ستون عددی را نمودار جواب نمیدهد.
diff --git a/src/Report/Service/PlanAccuracyReporter.php b/src/Report/Service/PlanAccuracyReporter.php
index f4df3d68..0a2cc7d9 100644
--- a/src/Report/Service/PlanAccuracyReporter.php
+++ b/src/Report/Service/PlanAccuracyReporter.php
@@ -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;
}
diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php
index dfd804bf..f8aabf5d 100644
--- a/tests/Report/ReportTest.php
+++ b/tests/Report/ReportTest.php
@@ -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);
}