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