feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import ResourceUtilizationPage from './pages/ResourceUtilizationPage';
|
||||
import PlanAccuracyPage from './pages/PlanAccuracyPage';
|
||||
import CancellationPolicyPage from './pages/CancellationPolicyPage';
|
||||
import WaitlistPage from './pages/WaitlistPage';
|
||||
import CourseProtocolsPage from './pages/CourseProtocolsPage';
|
||||
@@ -305,6 +307,8 @@ export default function App() {
|
||||
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
|
||||
<Route path="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
|
||||
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
|
||||
<Route path="reports/resource-utilization" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceUtilizationPage /></RoleRoute>} />
|
||||
<Route path="reports/plan-accuracy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PlanAccuracyPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon, ChartBarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
||||
|
||||
@@ -37,6 +37,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'utilization', label: 'بهرهوری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'plan-accuracy', label: 'دقت برنامه', icon: ChartBarIcon, to: '/admin/reports/plan-accuracy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type ApiResponse } from '../lib/api';
|
||||
import type { AccuracyRow, ReportEnvelope, UtilizationRow } from '../types';
|
||||
|
||||
/**
|
||||
* گزارشهای بهرهوری و دقت برنامه.
|
||||
*
|
||||
* بازه همیشه صریح فرستاده میشود تا نمودار با پیشفرض سرور جابهجا نشود.
|
||||
*/
|
||||
export function useResourceUtilization(branchUuid: string | undefined, from: number, to: number) {
|
||||
const query = useQuery({
|
||||
queryKey: ['resource-utilization', branchUuid, from, to],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<ReportEnvelope<UtilizationRow>>>(
|
||||
`/api/v1/reports/resource-utilization?branch_uuid=${branchUuid}&from=${from}&to=${to}`,
|
||||
),
|
||||
enabled: !!branchUuid,
|
||||
});
|
||||
|
||||
return { rows: query.data?.data?.rows ?? [], loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePlanAccuracy(from: number, to: number) {
|
||||
const query = useQuery({
|
||||
queryKey: ['plan-accuracy', from, to],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<ReportEnvelope<AccuracyRow>>>(
|
||||
`/api/v1/reports/plan-accuracy?from=${from}&to=${to}`,
|
||||
),
|
||||
});
|
||||
|
||||
return { rows: query.data?.data?.rows ?? [], loading: query.isLoading };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import PlanAccuracyPage from './PlanAccuracyPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const rows = [
|
||||
{
|
||||
service_uuid: 's1',
|
||||
service_name: 'لیزر فولبادی',
|
||||
sample_size: 4,
|
||||
planned_minutes: 60,
|
||||
actual_minutes: 90,
|
||||
deviation_percent: 50,
|
||||
severity: 'high' as const,
|
||||
},
|
||||
{
|
||||
service_uuid: 's2',
|
||||
service_name: 'مشاوره',
|
||||
sample_size: 5,
|
||||
planned_minutes: 30,
|
||||
actual_minutes: 29,
|
||||
deviation_percent: -3,
|
||||
severity: 'none' as const,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PlanAccuracyPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
get.mockResolvedValue({ success: true, data: { from: 1, to: 2, rows } });
|
||||
});
|
||||
|
||||
it('shows planned against actual with a signed deviation', async () => {
|
||||
renderWithProviders(<PlanAccuracyPage />, { route: '/admin/reports/plan-accuracy' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('لیزر فولبادی')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText('+50٪')).toBeInTheDocument();
|
||||
expect(screen.getByText('-3٪')).toBeInTheDocument();
|
||||
expect(screen.getByText('زیاد')).toBeInTheDocument();
|
||||
expect(screen.getByText('دقیق')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** نمونهٔ کوچک از گزارش حذف میشود؛ صفحه باید همان قاعده را بگوید. */
|
||||
it('explains that small samples are excluded', async () => {
|
||||
renderWithProviders(<PlanAccuracyPage />, { route: '/admin/reports/plan-accuracy' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/کمتر از سه نوبت انجامشده در گزارش نمیآیند/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { usePlanAccuracy } from '../hooks/useReports';
|
||||
import type { AccuracyRow } from '../types';
|
||||
|
||||
const RANGES = [
|
||||
{ value: '30', label: 'ماه گذشته' },
|
||||
{ value: '90', label: 'سه ماه گذشته' },
|
||||
];
|
||||
|
||||
const SEVERITY: Record<AccuracyRow['severity'], { label: string; className: string }> = {
|
||||
none: { label: 'دقیق', className: 'badge green' },
|
||||
low: { label: 'کم', className: 'badge' },
|
||||
medium: { label: 'متوسط', className: 'badge amber' },
|
||||
high: { label: 'زیاد', className: 'badge red' },
|
||||
};
|
||||
|
||||
/**
|
||||
* مدت پیشبینیشده در برابر مدت واقعی.
|
||||
*
|
||||
* سرویسی که یک ساعت پیشبینی شده ولی یکساعتونیم طول میکشد، هر روز نیم ساعت از ظرفیت
|
||||
* کلینیک را بیصدا میخورد — این صفحه تنها جایی است که آن را نشان میدهد.
|
||||
*/
|
||||
export default function PlanAccuracyPage() {
|
||||
const [days, setDays] = useState('30');
|
||||
|
||||
const range = useMemo(() => {
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
return { from: to - Number(days) * 86400, to };
|
||||
}, [days]);
|
||||
|
||||
const { rows, loading } = usePlanAccuracy(range.from, range.to);
|
||||
|
||||
const columns: Column<AccuracyRow>[] = [
|
||||
{
|
||||
key: 'service_name',
|
||||
header: 'خدمت',
|
||||
render: (r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontWeight: 600 }}>{r.service_name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.sample_size} نوبت</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'planned_minutes',
|
||||
header: 'پیشبینی',
|
||||
render: (r) => <span style={{ fontSize: 13 }}>{r.planned_minutes} دقیقه</span>,
|
||||
},
|
||||
{
|
||||
key: 'actual_minutes',
|
||||
header: 'واقعی',
|
||||
render: (r) => <span style={{ fontSize: 13 }}>{r.actual_minutes} دقیقه</span>,
|
||||
},
|
||||
{
|
||||
key: 'deviation_percent',
|
||||
header: 'انحراف',
|
||||
render: (r) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: r.severity === 'high' ? 'var(--danger)' : r.severity === 'medium' ? 'var(--warning)' : undefined,
|
||||
}}
|
||||
>
|
||||
{r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fix',
|
||||
header: '',
|
||||
// گزارشی که راه اصلاح ندهد خوانده نمیشود.
|
||||
render: (r) => (
|
||||
<Link className="btn secondary sm" to={`/admin/services?service=${r.service_uuid}`}>
|
||||
ویرایش بخشهای این خدمت
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'شدت',
|
||||
render: (r) => (
|
||||
<span className={SEVERITY[r.severity].className}>
|
||||
<span className="bdot" />
|
||||
{SEVERITY[r.severity].label}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="دقت برنامهٔ نوبت"
|
||||
description="مقایسهٔ مدت پیشبینیشدهٔ هر خدمت با مدت واقعی نوبتهای انجامشده."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 200, margin: 0 }}>
|
||||
<label>بازه</label>
|
||||
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'flex-end' }}>
|
||||
خدماتی با کمتر از سه نوبت انجامشده در گزارش نمیآیند.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
emptyMessage="دادهای برای این بازه نیست"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="field" 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" 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>
|
||||
);
|
||||
}
|
||||
@@ -1373,3 +1373,34 @@ export interface WaitlistEntry {
|
||||
notify_count: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// ── گزارشها (تسک ۱۴) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UtilizationRow {
|
||||
resource_uuid: string;
|
||||
resource_name: string;
|
||||
role: string;
|
||||
available_minutes: number;
|
||||
occupied_minutes: number;
|
||||
active_minutes: number;
|
||||
/** `null` یعنی تقویمی نیست — تعریفنشده، نه صفر */
|
||||
utilization: number | null;
|
||||
active_ratio: number | null;
|
||||
wasted_capacity: boolean;
|
||||
}
|
||||
|
||||
export interface AccuracyRow {
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
sample_size: number;
|
||||
planned_minutes: number;
|
||||
actual_minutes: number;
|
||||
deviation_percent: number;
|
||||
severity: 'none' | 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface ReportEnvelope<T> {
|
||||
from: number;
|
||||
to: number;
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user