feat(admin): resource-mode booking flow with a hold countdown

The engine from tasks 06 and 07 could find slots and hold them, but nothing in
the panel could actually book one.

- Search, hold, confirm stay three separate steps because they are three
  separate states: between seeing a slot and taking it the seat is still open,
  and between taking and confirming there is a deadline
- HoldCountdown reads the server's expires_at rather than starting its own
  timer at render: browser clock skew and network latency both cost seconds,
  and those seconds are exactly where a hold is lost. It turns urgent under a
  minute and tells the parent the moment it lapses
- Per-role resource swap offers only the resources the engine returned for that
  same slot. Listing every resource in the branch would let an operator pick
  one that was never free and collect a 409
- An empty result is not an error: the reason code renders as a sentence
  saying what to change
- Confirm requires a doctor and stays disabled until one is chosen — the
  endpoint rejects it anyway, and finding that out after the hold clock has
  been running is the wrong time

Reached from the appointments page as a separate action rather than folded into
the existing form: its search comes from the intersection of resource
calendars, not from one doctor's slots, and merging the two would confuse both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 20:05:12 +03:30
co-authored by Claude Opus 5
parent 4bdacdcc7b
commit 0074162bb1
8 changed files with 571 additions and 16 deletions
+2
View File
@@ -75,6 +75,7 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import BranchesPage from './pages/BranchesPage';
import ResourceBookingPage from './pages/ResourceBookingPage';
import PriceListsPage from './pages/PriceListsPage';
import ResourceUtilizationPage from './pages/ResourceUtilizationPage';
import PlanAccuracyPage from './pages/PlanAccuracyPage';
@@ -311,6 +312,7 @@ export default function App() {
<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="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></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>} />
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import HoldCountdown from './HoldCountdown';
describe('HoldCountdown', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const nowSeconds = () => Math.floor(Date.now() / 1000);
it('counts down from the server expiry', () => {
render(<HoldCountdown expiresAt={nowSeconds() + 125} onExpired={() => {}} />);
expect(screen.getByText(/2:05/)).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(5000); });
expect(screen.getByText(/2:00/)).toBeInTheDocument();
});
/** ⭐ رزروی که در سکوت منقضی شود، اپراتور را با یک ۴۰۹ بی‌توضیح تنها می‌گذارد. */
it('reports expiry to the parent exactly once it lapses', () => {
const onExpired = vi.fn();
render(<HoldCountdown expiresAt={nowSeconds() + 2} onExpired={onExpired} />);
expect(onExpired).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(3000); });
expect(onExpired).toHaveBeenCalled();
expect(screen.getByText('مهلت تمام شد')).toBeInTheDocument();
});
it('switches to the urgent style under a minute', () => {
const { container } = render(<HoldCountdown expiresAt={nowSeconds() + 30} onExpired={() => {}} />);
expect(container.querySelector('.badge.red')).not.toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
interface Props {
expiresAt: number;
onExpired: () => void;
}
/**
* شمارش معکوس مهلت رزرو موقت.
*
* بدون این، اپراتور نمی‌داند چقدر وقت دارد و رزرو در سکوت منقضی می‌شود — بعد کلیک
* «ثبت» یک ۴۰۹ می‌گیرد که هیچ‌جا توضیحش را ندیده.
*
* مبنا `expires_at` سرور است، نه شمارنده‌ای که از لحظهٔ رندر شروع شود: ساعت مرورگر و
* تأخیر شبکه هر دو می‌توانند چند ثانیه اختلاف بسازند و آن چند ثانیه دقیقاً همان‌جایی
* است که رزرو از دست می‌رود.
*/
export default function HoldCountdown({ expiresAt, onExpired }: Props) {
const [remaining, setRemaining] = useState(() => expiresAt - Math.floor(Date.now() / 1000));
useEffect(() => {
const tick = () => {
const left = expiresAt - Math.floor(Date.now() / 1000);
setRemaining(left);
if (left <= 0) onExpired();
};
tick();
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, [expiresAt, onExpired]);
if (remaining <= 0) {
return (
<span className="badge red">
<span className="bdot" />
مهلت تمام شد
</span>
);
}
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
// زیر یک دقیقه هشدار می‌گیرد؛ همان لحظه‌ای که اپراتور باید تصمیمش را بگیرد.
const urgent = remaining < 60;
return (
<span className={urgent ? 'badge red' : 'badge amber'}>
<span className="bdot" />
مهلت ثبت: {minutes}:{String(seconds).padStart(2, '0')}
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
/**
* جستجوی وقت، رزرو موقت و ثبت نهایی در حالت منبع‌محور.
*
* سه عمل جدا هستند و باید جدا بمانند: بین «دیدن وقت» و «گرفتنش» صندلی هنوز آزاد است،
* و بین «گرفتن» و «ثبت» یک مهلت وجود دارد که اگر نگذرد، ظرفیت برای همیشه قفل می‌ماند.
*/
export interface ResourceRef {
uuid: string;
name: string;
}
export type SlotAssignment = Record<string, ResourceRef[]>;
export interface AvailableSlot {
start: number;
end: number;
assignment: SlotAssignment;
}
export interface PlanSegment {
sequence: number;
name: string;
offset_minutes: number;
duration_minutes: number;
patient_present: boolean;
requirements: { role: string; role_name: string; count: number }[];
}
export interface AvailabilityResult {
plan: { total_minutes: number; segments: PlanSegment[] };
slots: AvailableSlot[];
/** خالی بودن فهرست خطا نیست؛ این می‌گوید چرا خالی است. */
reason: string | null;
}
export interface HoldResult {
hold_uuid: string;
starts_at: number;
ends_at: number;
expires_at: number;
confirmed: boolean;
assignment: SlotAssignment;
}
export const REASON_LABELS: Record<string, string> = {
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا شعبهٔ دیگری را امتحان کنید.',
no_working_hours: 'شعبه در این بازه ساعت کاری ندارد.',
no_eligible_resource: 'هیچ منبعی شرایط بخش‌های این خدمت را ندارد.',
};
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useAvailabilitySearch(
params: { serviceUuid: string; branchUuid: string; from: number; to: number; stepMinutes?: number },
enabled: boolean,
) {
const query = useQuery({
queryKey: ['resource-availability', params],
queryFn: () =>
api.post<ApiResponse<AvailabilityResult>>('/api/v1/appointment-availability', {
service_uuid: params.serviceUuid,
branch_uuid: params.branchUuid,
from: params.from,
to: params.to,
...(params.stepMinutes ? { step_minutes: params.stepMinutes } : {}),
}),
enabled: enabled && !!params.serviceUuid && !!params.branchUuid,
retry: false,
});
return {
result: query.data?.data,
loading: query.isFetching,
error: query.error,
refetch: query.refetch,
};
}
export function useHold() {
const create = useMutation({
mutationFn: (body: {
service_uuid: string;
branch_uuid: string;
start: number;
assignment: Record<string, string[]>;
item_uuids?: string[];
patient_gender?: string;
}) => api.post<ApiResponse<HoldResult>>('/api/v1/appointment-hold', body),
// ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور دقیقاً همین را می‌گوید.
onError: (e) => fail(e, 'گرفتن این زمان ناموفق بود'),
});
const release = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/appointment-hold/${uuid}`),
onSuccess: () => toast.success('رزرو موقت آزاد شد'),
onError: (e) => fail(e, 'آزادسازی ناموفق بود'),
});
const confirm = useMutation({
mutationFn: (body: { hold_uuid: string; doctor_uuid: string; patient_uuid?: string }) =>
api.post<ApiResponse<{ uuid: string }>>('/api/v1/appointment-confirm', body),
onSuccess: () => toast.success('نوبت ثبت شد'),
onError: (e) => fail(e, 'ثبت نهایی ناموفق بود'),
});
return { create, release, confirm };
}
+12
View File
@@ -795,6 +795,18 @@ export default function AppointmentsPage() {
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button>
{/* رزرو منبع‌محور مسیر جداست چون جستجویش از تقاطع تقویم منابع می‌آید، نه از
اسلات‌های یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج می‌کرد. */}
{!isRepresentation && canCreateAppt && (
<button
className="btn secondary sm"
onClick={() => navigate('/admin/resource-booking')}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
نوبت منبعمحور
</button>
)}
{!isRepresentation && canCreateAppt && (
<button
className="btn primary sm"
+331
View File
@@ -0,0 +1,331 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
import HoldCountdown from '../components/HoldCountdown';
import { formatDate } from '../lib/utils';
import { useBranches } from '../hooks/useBranches';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import {
REASON_LABELS,
useAvailabilitySearch,
useHold,
type AvailableSlot,
type HoldResult,
} from '../hooks/useResourceBooking';
import { api, ApiError, type ApiResponse } from '../lib/api';
import { useQuery } from '@tanstack/react-query';
const DAY = 86400;
function timeOf(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
}
/**
* رزرو نوبت در حالت منبع‌محور.
*
* سه مرحلهٔ جدا و عمداً جدا: جستجو → گرفتن موقت → ثبت نهایی. بین دومی و سومی یک مهلت
* هست و صفحه آن را با شمارش معکوس نشان می‌دهد؛ رزروی که در سکوت منقضی شود، اپراتور را
* با یک ۴۰۹ بی‌توضیح تنها می‌گذارد.
*/
export default function ResourceBookingPage() {
const navigate = useNavigate();
const { branches } = useBranches();
const { items: services } = useAllServiceItems();
const { create, release, confirm } = useHold();
const [serviceUuid, setServiceUuid] = useState('');
const [branchUuid, setBranchUuid] = useState('');
const [days, setDays] = useState('7');
const [searching, setSearching] = useState(false);
const [doctorUuid, setDoctorUuid] = useState('');
const [hold, setHold] = useState<HoldResult | null>(null);
const [expired, setExpired] = useState(false);
/** جایگزینی منبع per نقش، فقط برای همان زمانِ انتخاب‌شده. */
const [picked, setPicked] = useState<Record<string, string[]> | null>(null);
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
/**
* پزشکِ نوبت — ثبت نهایی بدونش ممکن نیست.
*
* منشی از اندپوینت احرازشده می‌گیرد تا فقط پزشکان تخصیص‌یافته‌اش بیایند؛ همان
* قاعده‌ای که صفحهٔ نوبت‌ها از قبل دارد.
*/
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 ?? [];
const range = useMemo(() => {
const from = Math.floor(Date.now() / 1000);
return { from, to: from + Number(days) * DAY };
}, [days]);
const { result, loading, error } = useAvailabilitySearch(
{ serviceUuid, branchUuid, from: range.from, to: range.to },
searching,
);
const onExpired = useCallback(() => setExpired(true), []);
const chooseSlot = (slot: AvailableSlot) => {
setPickedSlot(slot);
setPicked(
Object.fromEntries(
Object.entries(slot.assignment).map(([role, resources]) => [role, resources.map((r) => r.uuid)]),
),
);
};
/**
* گزینه‌های جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شده‌اند.
*
* فهرست کاملِ منابع شعبه اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد
* نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ می‌گیرد.
*/
const optionsFor = (role: string): { value: string; label: string }[] =>
(pickedSlot?.assignment[role] ?? []).map((r) => ({ value: r.uuid, label: r.name }));
const takeHold = async () => {
if (!pickedSlot || !picked) return;
const created = await create.mutateAsync({
service_uuid: serviceUuid,
branch_uuid: branchUuid,
start: pickedSlot.start,
assignment: picked,
});
setHold(created.data);
setExpired(false);
};
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
return (
<div className="fade-in">
<PageHeader
title="رزرو نوبت منبع‌محور"
description="وقت آزاد از تقاطع تقویم منابع می‌آید؛ هر وقت با منابع پیشنهادی خودش نمایش داده می‌شود."
backTo="/admin/appointments"
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<label>خدمت</label>
<SearchableSelect
value={serviceUuid}
onChange={(v) => {
setServiceUuid(String(v ?? ''));
setPickedSlot(null);
}}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="انتخاب خدمت"
/>
</div>
<div className="field" style={{ minWidth: 200, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => {
setBranchUuid(String(v ?? ''));
setPickedSlot(null);
}}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
<div className="field" style={{ minWidth: 160, margin: 0 }}>
<label>بازه</label>
<SearchableSelect
value={days}
onChange={(v) => setDays(String(v ?? '7'))}
options={[
{ value: '7', label: 'یک هفته' },
{ value: '30', label: 'یک ماه' },
{ value: '90', label: 'سه ماه' },
]}
/>
</div>
<button
type="button"
className="btn primary sm"
disabled={serviceUuid === '' || branchUuid === '' || loading}
onClick={() => setSearching(true)}
>
جستجوی وقت
</button>
</div>
{error && (
<div className="card" style={{ marginBottom: 16, fontSize: 13, color: 'var(--danger)' }}>
{error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
</div>
)}
{result && (
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
<span>
مدت نوبت: <strong>{result.plan.total_minutes}</strong> دقیقه
</span>
<span style={{ color: 'var(--text-2)' }}>
{result.plan.segments.length} بخش · {result.slots.length} وقت پیدا شد
</span>
</div>
{/* فهرست خالی خطا نیست؛ دلیلش را می‌گوییم تا کاربر حدس نزند. */}
{result.slots.length === 0 && reasonText && (
<span style={{ fontSize: 13, color: 'var(--warning)' }}>{reasonText}</span>
)}
</div>
)}
{result && result.slots.length > 0 && (
<div style={{ overflowX: 'auto' }} className="card">
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', minWidth: 520 }}>
<thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
<th style={{ padding: '8px 6px', fontWeight: 500 }}>تاریخ</th>
<th style={{ padding: '8px 6px', fontWeight: 500 }}>ساعت</th>
<th style={{ padding: '8px 6px', fontWeight: 500 }}>منابع پیشنهادی</th>
<th style={{ padding: '8px 6px', fontWeight: 500 }} />
</tr>
</thead>
<tbody>
{result.slots.slice(0, 100).map((slot) => (
<tr
key={slot.start}
style={{
borderTop: '1px solid var(--border)',
background: pickedSlot?.start === slot.start ? 'var(--primary-soft)' : undefined,
}}
>
<td style={{ padding: '8px 6px' }}>{formatDate(slot.start)}</td>
<td style={{ padding: '8px 6px' }} dir="ltr">
{timeOf(slot.start)} {timeOf(slot.end)}
</td>
<td style={{ padding: '8px 6px', color: 'var(--text-2)' }}>
{Object.values(slot.assignment)
.flat()
.map((r) => r.name)
.join('، ')}
</td>
<td style={{ padding: '8px 6px' }}>
<button
type="button"
className="btn secondary sm"
disabled={hold !== null}
onClick={() => chooseSlot(slot)}
>
انتخاب
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pickedSlot && (
<div className="card" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>
{formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span>
</h3>
{hold && !expired && <HoldCountdown expiresAt={hold.expires_at} onExpired={onExpired} />}
{expired && (
<span className="badge red">
<span className="bdot" />
مهلت تمام شد دوباره جستجو کنید
</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries(pickedSlot.assignment).map(([role, resources]) => (
<div key={role} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, minWidth: 90, color: 'var(--text-2)' }}>{role}</span>
<div style={{ minWidth: 220 }}>
<SearchableSelect
value={picked?.[role]?.[0] ?? resources[0]?.uuid ?? ''}
onChange={(v) =>
setPicked((prev) => ({ ...(prev ?? {}), [role]: [String(v ?? '')] }))
}
options={optionsFor(role)}
isDisabled={hold !== null}
/>
</div>
</div>
))}
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار
نمایش داده نمیشود.
</span>
<div className="field" style={{ maxWidth: 280, margin: 0 }}>
<label>پزشک نوبت</label>
<SearchableSelect
value={doctorUuid}
onChange={(v) => setDoctorUuid(String(v ?? ''))}
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
placeholder="انتخاب پزشک"
/>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{hold === null ? (
<button
type="button"
className="btn primary"
disabled={create.isPending}
onClick={takeHold}
>
نگهداشتن این زمان
</button>
) : (
<>
<button
type="button"
className="btn primary"
disabled={expired || doctorUuid === '' || confirm.isPending}
onClick={async () => {
await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
navigate('/admin/appointments');
}}
>
ثبت نهایی نوبت
</button>
<button
type="button"
className="btn secondary"
disabled={release.isPending}
onClick={async () => {
await release.mutateAsync(hold.hold_uuid);
setHold(null);
setPickedSlot(null);
}}
>
آزادکردن
</button>
</>
)}
</div>
</div>
)}
</div>
);
}