diff --git a/assets/admin/components/PolicyVersionDiff.test.tsx b/assets/admin/components/PolicyVersionDiff.test.tsx new file mode 100644 index 00000000..112c0dd5 --- /dev/null +++ b/assets/admin/components/PolicyVersionDiff.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import PolicyVersionDiff from './PolicyVersionDiff'; + +const version = (n: number, snapshot: Record) => ({ + version: n, + created_at: 1_800_000_000 + n, + snapshot, +}); + +describe('PolicyVersionDiff', () => { + /** ⭐ سؤال واقعی «چه چیزی عوض شد؟» است، نه «هر نسخه چه بود». */ + it('shows only the fields that changed between versions', () => { + render( + , + ); + + expect(screen.getByText('اولویت')).toBeInTheDocument(); + expect(screen.getByText('اثرها')).toBeInTheDocument(); + // نام عوض نشده، پس نباید ردیف بگیرد. + expect(screen.queryByText('نام')).toBeNull(); + }); + + it('marks the first version rather than diffing it against nothing', () => { + render(); + + expect(screen.getByText('نسخهٔ نخست')).toBeInTheDocument(); + }); + + it('says so when a version changed nothing meaningful', () => { + render( + , + ); + + expect(screen.getByText('بدون تغییرِ معنادار')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/PolicyVersionDiff.tsx b/assets/admin/components/PolicyVersionDiff.tsx new file mode 100644 index 00000000..4071088c --- /dev/null +++ b/assets/admin/components/PolicyVersionDiff.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { formatDate } from '../lib/utils'; + +interface VersionRow { + version: number; + created_at: number; + snapshot: Record; +} + +/** فقط فیلدهایی که تغییرشان معنا دارد — `updated_at` و نسخه خودشان همیشه فرق دارند. */ +const WATCHED: Array<{ key: string; label: string }> = [ + { key: 'name', label: 'نام' }, + { key: 'priority', label: 'اولویت' }, + { key: 'active', label: 'فعال' }, + { key: 'condition', label: 'شرط' }, + { key: 'effects', label: 'اثرها' }, + { key: 'valid_from', label: 'شروع اعتبار' }, + { key: 'valid_to', label: 'پایان اعتبار' }, + { key: 'service_uuid', label: 'سرویس' }, + { key: 'address_uuid', label: 'شعبه' }, +]; + +function show(value: unknown): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'بله' : 'خیر'; + if (typeof value === 'object') return JSON.stringify(value, null, 0); + + return String(value); +} + +/** + * تفاوت هر نسخه با نسخهٔ پیش از خودش. + * + * فهرست کامل اثرها روی هر نسخه، سؤال واقعی را جواب نمی‌دهد: «چه چیزی عوض شد؟». وقتی + * نوبتی به نسخهٔ ۳ ارجاع می‌دهد و کسی می‌پرسد چرا قیمتش فرق دارد، همین ستون جواب است. + */ +export default function PolicyVersionDiff({ versions }: { versions: VersionRow[] }) { + if (!versions || versions.length === 0) return null; + + const ordered = [...versions].sort((a, b) => a.version - b.version); + + return ( +
+

تاریخچهٔ نسخه‌ها

+ +
+ {ordered.map((v, i) => { + const previous = i === 0 ? null : ordered[i - 1].snapshot; + + const changes = previous + ? WATCHED.filter((f) => show(v.snapshot[f.key]) !== show(previous[f.key])) + : []; + + return ( +
+
+ نسخهٔ {v.version} + {formatDate(v.created_at)} + {previous === null && ( + نسخهٔ نخست + )} + {previous !== null && changes.length === 0 && ( + بدون تغییرِ معنادار + )} +
+ + {changes.map((f) => ( +
+ {f.label} + + {show(previous?.[f.key])} + + + + {show(v.snapshot[f.key])} + +
+ ))} +
+ ); + })} +
+
+ ); +} diff --git a/assets/admin/pages/PatientPackageLedgerPage.tsx b/assets/admin/pages/PatientPackageLedgerPage.tsx index 82bf3e44..b424bb7d 100644 --- a/assets/admin/pages/PatientPackageLedgerPage.tsx +++ b/assets/admin/pages/PatientPackageLedgerPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import Modal from '../components/ui/Modal'; @@ -66,6 +66,30 @@ export default function PatientPackageLedgerPage() { ), }, + { + // «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمی‌شدند. دفترِ + // اصلاح‌پذیر بدون نامِ اصلاح‌کننده، نصف حسابرسی است. + key: 'created_by', + header: 'ثبت‌کننده', + render: (r) => ( + {r.created_by ?? 'سیستم'} + ), + }, + { + key: 'appointment_uuid', + header: 'نوبت', + render: (r) => + r.appointment_uuid ? ( + + مشاهدهٔ نوبت + + ) : ( + + ), + }, ]; return ( diff --git a/assets/admin/pages/PolicySimulationPage.tsx b/assets/admin/pages/PolicySimulationPage.tsx index 749411b6..22f7ed19 100644 --- a/assets/admin/pages/PolicySimulationPage.tsx +++ b/assets/admin/pages/PolicySimulationPage.tsx @@ -4,6 +4,7 @@ import { BeakerIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import DataTable, { type Column } from '../components/ui/DataTable'; +import PolicyVersionDiff from '../components/PolicyVersionDiff'; import { formatDate } from '../lib/utils'; import { usePermissions } from '../hooks/usePermissions'; import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies'; @@ -163,20 +164,7 @@ export default function PolicySimulationPage() { /> {policy?.versions && policy.versions.length > 1 && ( -
-

تاریخچهٔ نسخه‌ها

-
- {policy.versions.map((v) => ( -
- نسخهٔ {v.version} - {formatDate(v.created_at)} - - {JSON.stringify((v.snapshot as { effects?: unknown }).effects)} - -
- ))} -
-
+ )}
diff --git a/assets/admin/pages/ResourceUtilizationPage.tsx b/assets/admin/pages/ResourceUtilizationPage.tsx index 05ee1539..23335158 100644 --- a/assets/admin/pages/ResourceUtilizationPage.tsx +++ b/assets/admin/pages/ResourceUtilizationPage.tsx @@ -3,6 +3,9 @@ import { useUrlState } from '../hooks/useUrlState'; 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 { Link } from 'react-router-dom'; +import { isoToUnix, unixToIso } from '../lib/utils'; import { useBranches } from '../hooks/useBranches'; import { useResourceUtilization } from '../hooks/useReports'; import type { UtilizationRow } from '../types'; @@ -11,6 +14,7 @@ const RANGES = [ { value: '7', label: 'هفتهٔ گذشته' }, { value: '30', label: 'ماه گذشته' }, { value: '90', label: 'سه ماه گذشته' }, + { value: 'custom', label: 'بازهٔ دلخواه' }, ]; function percent(value: number | null): string { @@ -27,17 +31,28 @@ export default function ResourceUtilizationPage() { const { branches } = useBranches(); // بازه و شعبه در URL می‌نشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را // برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده. - const [urlState, setUrlState] = useUrlState({ branch: '', days: '7' }); + const [urlState, setUrlState] = useUrlState({ branch: '', days: '7', from: '', to: '' }); const branchUuid = urlState.branch; const days = urlState.days; const setBranchUuid = (v: string) => setUrlState({ branch: v }); const setDays = (v: string) => setUrlState({ days: v }); + /** + * بازهٔ آماده برای حالت عادی، بازهٔ دلخواه برای وقتی که کاربر دقیقاً می‌داند چه + * می‌خواهد — مثلاً مقایسهٔ دو ماه مشخص با هم. + */ const range = useMemo(() => { + if (days === 'custom') { + return { + from: isoToUnix(urlState.from) ?? Math.floor(Date.now() / 1000) - 7 * 86400, + to: isoToUnix(urlState.to) ?? Math.floor(Date.now() / 1000), + }; + } + const to = Math.floor(Date.now() / 1000); return { from: to - Number(days) * 86400, to }; - }, [days]); + }, [days, urlState.from, urlState.to]); const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to); @@ -70,14 +85,20 @@ export default function ResourceUtilizationPage() { { key: 'utilization', header: 'بهره‌وری', - render: (r) => ( - - {percent(r.utilization)} - - ), + // `null` یعنی «تعریف‌نشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز. + // بدون لینک، کاربر عدد را می‌بیند و نمی‌داند کجا باید برود. + render: (r) => + r.utilization === null ? ( + + تنظیم تقویم + + ) : ( + {percent(r.utilization)} + ), }, { key: 'active_ratio', @@ -126,6 +147,25 @@ export default function ResourceUtilizationPage() { setDays(String(v ?? '7'))} options={RANGES} />
+ + {days === 'custom' && ( + <> +
+ + setUrlState({ from: v })} + /> +
+
+ + setUrlState({ to: v })} + /> +
+ + )}

diff --git a/assets/admin/pages/TreatmentCoursePage.tsx b/assets/admin/pages/TreatmentCoursePage.tsx index 9f1ff5c2..dd4f1820 100644 --- a/assets/admin/pages/TreatmentCoursePage.tsx +++ b/assets/admin/pages/TreatmentCoursePage.tsx @@ -8,6 +8,8 @@ import { formatDate, formatNumber } from '../lib/utils'; import { usePermissions } from '../hooks/usePermissions'; import { useBranches } from '../hooks/useBranches'; import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses'; +import { useQuery } from '@tanstack/react-query'; +import { api, type ApiResponse } from '../lib/api'; import type { CourseSessionRow } from '../types'; const SESSION_STATUS: Record = { @@ -25,17 +27,27 @@ const SESSION_STATUS: Record(); - const { course, loading, abandon } = useTreatmentCourse(courseUuid); + const { course, loading, abandon, bookAll } = useTreatmentCourse(courseUuid); const { branches } = useBranches(); const { can } = usePermissions(); const canManage = can('appointment_settings', 'update'); const [branchUuid, setBranchUuid] = useState(''); + const [doctorUuid, setDoctorUuid] = useState(''); const [abandoning, setAbandoning] = useState(false); const [reason, setReason] = useState(''); const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined); + /** همان اندپوینتی که صفحهٔ رزرو منبع‌محور استفاده می‌کند — منشی فقط پزشکان خودش را می‌بیند. */ + const doctorsQuery = useQuery>({ + queryKey: ['booking-doctors'], + queryFn: () => api.get('/api/v1/my/clinic-doctors'), + staleTime: 60_000, + }); + + const doctors = doctorsQuery.data?.data?.data ?? []; + /** * دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و * هیچ‌کس خبردار نمی‌شود. @@ -173,6 +185,25 @@ export default function TreatmentCoursePage() { )} + {/* پکیجی که کفاف جلسات باقی‌مانده را نمی‌دهد، دوره را باطل نمی‌کند — بقیه‌اش + نقدی می‌شود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */} + {(course.package_shortfall ?? 0) > 0 && ( + + اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم می‌آید + (مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت + عادی حساب می‌شوند. + + )} + {overdue !== null && ( + {/* رزرو گروهی پزشک لازم دارد؛ خودِ دوره پزشکی ندارد چون هر جلسه می‌تواند + با پزشک دیگری باشد. */} + {canManage && course.status === 'active' && ( +

+ + setDoctorUuid(String(v ?? ''))} + options={doctors.map((d) => ({ value: d.uuid, label: d.name }))} + placeholder="انتخاب پزشک" + /> +
+ )} + + {canManage && course.status === 'active' && ( + + )} + {canManage && course.status === 'active' && (