feat(admin): finish the screens that were stopping one step short

Five places where the data existed and the screen did not use it.

Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.

A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.

The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.

Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.

A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 15:07:27 +03:30
co-authored by Claude Opus 5
parent fe48b10fb5
commit 5c754244f2
10 changed files with 314 additions and 27 deletions
@@ -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() {
</span>
),
},
{
// «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمی‌شدند. دفترِ
// اصلاح‌پذیر بدون نامِ اصلاح‌کننده، نصف حسابرسی است.
key: 'created_by',
header: 'ثبت‌کننده',
render: (r) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.created_by ?? 'سیستم'}</span>
),
},
{
key: 'appointment_uuid',
header: 'نوبت',
render: (r) =>
r.appointment_uuid ? (
<Link
to={`/admin/appointments/${r.appointment_uuid}`}
style={{ fontSize: 12, color: 'var(--primary)' }}
>
مشاهدهٔ نوبت
</Link>
) : (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>
),
},
];
return (
+2 -14
View File
@@ -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 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{policy.versions.map((v) => (
<div key={v.version} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
<span style={{ color: 'var(--text-2)' }}>
{JSON.stringify((v.snapshot as { effects?: unknown }).effects)}
</span>
</div>
))}
</div>
</div>
<PolicyVersionDiff versions={policy.versions as never} />
)}
<div style={{ overflowX: 'auto' }}>
+50 -10
View File
@@ -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) => (
<span
style={{ fontSize: 13 }}
title={r.utilization === null ? 'برای این منبع تقویمی تعریف نشده است' : undefined}
>
{percent(r.utilization)}
</span>
),
// `null` یعنی «تعریف‌نشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز.
// بدون لینک، کاربر عدد را می‌بیند و نمی‌داند کجا باید برود.
render: (r) =>
r.utilization === null ? (
<Link
to={`/admin/resources/${r.resource_uuid}/calendar`}
style={{ fontSize: 12, color: 'var(--primary)' }}
title="برای این منبع تقویمی تعریف نشده است"
>
تنظیم تقویم
</Link>
) : (
<span style={{ fontSize: 13 }}>{percent(r.utilization)}</span>
),
},
{
key: 'active_ratio',
@@ -126,6 +147,25 @@ export default function ResourceUtilizationPage() {
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
</div>
{days === 'custom' && (
<>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>از تاریخ</label>
<PersianDateInput
value={urlState.from || unixToIso(range.from)}
onChange={(v) => setUrlState({ from: v })}
/>
</div>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>تا تاریخ</label>
<PersianDateInput
value={urlState.to || unixToIso(range.to)}
onChange={(v) => setUrlState({ to: v })}
/>
</div>
</>
)}
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
+62 -1
View File
@@ -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<CourseSessionRow['status'], { label: string; className: string }> = {
@@ -25,17 +27,27 @@ const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; classN
*/
export default function TreatmentCoursePage() {
const { courseUuid } = useParams<{ courseUuid: string }>();
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<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
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() {
)}
</div>
{/* پکیجی که کفاف جلسات باقی‌مانده را نمی‌دهد، دوره را باطل نمی‌کند — بقیه‌اش
نقدی می‌شود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */}
{(course.package_shortfall ?? 0) > 0 && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم میآید
(مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت
عادی حساب میشوند.
</span>
)}
{overdue !== null && (
<span
style={{
@@ -204,6 +235,36 @@ export default function TreatmentCoursePage() {
/>
</div>
{/* رزرو گروهی پزشک لازم دارد؛ خودِ دوره پزشکی ندارد چون هر جلسه می‌تواند
با پزشک دیگری باشد. */}
{canManage && course.status === 'active' && (
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>پزشک برای رزرو گروهی</label>
<SearchableSelect
value={doctorUuid}
onChange={(v) => setDoctorUuid(String(v ?? ''))}
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
placeholder="انتخاب پزشک"
/>
</div>
)}
{canManage && course.status === 'active' && (
<button
type="button"
className="btn primary sm"
disabled={branchUuid === '' || doctorUuid === '' || bookAll.isPending}
title={
branchUuid === '' || doctorUuid === ''
? 'شعبه و پزشک را انتخاب کنید'
: 'همهٔ جلسات برنامه‌ریزی‌شده یکجا رزرو می‌شوند — همه یا هیچ'
}
onClick={() => bookAll.mutate({ branch_uuid: branchUuid, doctor_uuid: doctorUuid })}
>
رزرو همهٔ جلسات
</button>
)}
{canManage && course.status === 'active' && (
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
رهاکردن دوره