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