Files
clinicpro/assets/admin/pages/WaitlistPage.tsx
T
hamedandClaude Opus 5 4bba322b8e feat(waitlist): make the day-part preference, the conversion and the expiry real
Three rows of task 13 were storing data nothing ever read.

`preferred_day_parts` was saved and displayed but never applied when matching.
It was deferred because "evening" has no fixed meaning — but branches already
carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned:
morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour.
The list is now closed and validated; an unknown part is a 422 rather than a
preference that silently matches nothing. The filter runs *before* the cut to
ten recipients — otherwise the first ten slots go to people who did not want
that hour and the real eleventh person is never told.

`markConverted()` was dead code: nothing called it. It now runs off the
AppointmentBooked domain event rather than from inside BookingService, because
converting is a side effect of booking — inside the booking transaction a
waitlist error could roll back the patient's actual appointment. The match is
deliberately narrow (same patient, same service, start inside the window); a
loose match closes a row the patient is still waiting on. It is idempotent, so
redelivery is harmless.

Expiry now exists as a service, a daily scheduled message and
`app:waitlist:expire`. Expired rows were already excluded from matching, so
this is display hygiene, not a behaviour fix: without it the waitlist page
fills with dead entries and the operator cannot tell which are still live. It
sets a status rather than deleting — who waited and never got a slot is data.

Also: a waitlist window is capped at 90 days, matching the booking horizon. An
unbounded window is a row that never expires and shows up in every match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:33:26 +03:30

144 lines
5.1 KiB
TypeScript

import React, { useMemo } from 'react';
import { TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect';
import { formatDate } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useWaitlist } from '../hooks/useCancellation';
import type { WaitlistEntry } from '../types';
/**
* مرزهای ساعتش در بک‌اند است (`WaitlistEntry::DAY_PARTS`)؛ اینجا فقط ترجمهٔ نمایش.
* تعریف مرز در دو جا یعنی روزی که یکی عوض شود، بیمار برای ساعتی خبر شود که رد کرده بود.
*/
const DAY_PART_LABELS: Record<string, string> = {
morning: 'صبح',
afternoon: 'بعدازظهر',
evening: 'عصر',
};
const STATUS: Record<WaitlistEntry['status'], { label: string; className: string }> = {
waiting: { label: 'در انتظار', className: 'badge' },
notified: { label: 'خبر داده شد', className: 'badge amber' },
converted: { label: 'رزرو شد', className: 'badge green' },
expired: { label: 'منقضی', className: 'badge red' },
};
/**
* لیست انتظار.
*
* ستون «تعداد اطلاع» عمداً دیده می‌شود: وقتی ظرفیتی آزاد می‌شود همه خبر می‌گیرند و
* اولین رزروکننده می‌برد، پس اپراتور باید بداند چه کسی چند بار خبر شده.
*/
export default function WaitlistPage() {
const [urlState, setUrlState] = useUrlState({ search: '', status: '' });
const { entries, loading, remove } = useWaitlist(urlState.status || undefined);
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const rows = useMemo(() => {
const q = urlState.search.trim();
return entries.filter((e) => q === '' || e.service_name.includes(q));
}, [entries, urlState.search]);
const columns: Column<WaitlistEntry>[] = [
{
key: 'service_name',
header: 'خدمت',
render: (e) => <span style={{ fontWeight: 600 }}>{e.service_name}</span>,
},
{
key: 'window',
header: 'بازهٔ دلخواه',
render: (e) => (
<span style={{ fontSize: 13 }}>
{formatDate(e.desired_from)} تا {formatDate(e.desired_to)}
</span>
),
},
{
key: 'preferred_day_parts',
header: 'زمان ترجیحی',
render: (e) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{e.preferred_day_parts.length === 0
? 'بی‌تفاوت'
: e.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')}
</span>
),
},
{
key: 'notify_count',
header: 'تعداد اطلاع',
render: (e) => (
<span style={{ fontSize: 13 }}>
{e.notify_count}
{e.notified_at !== null && (
<span style={{ fontSize: 11, color: 'var(--text-3)' }}> · {formatDate(e.notified_at)}</span>
)}
</span>
),
},
{
key: 'status',
header: 'وضعیت',
render: (e) => (
<span className={STATUS[e.status].className}>
<span className="bdot" />
{STATUS[e.status].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="لیست انتظار"
description="وقتی ظرفیتی آزاد می‌شود همهٔ منتظرانِ آن بازه خبر می‌گیرند و اولین رزروکننده آن را می‌برد."
backTo="/admin/settings-menu"
/>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در خدمات..."
emptyMessage="کسی در لیست انتظار نیست"
headerExtra={
<div style={{ minWidth: 180, marginRight: 'auto' }}>
<SearchableSelect
value={urlState.status}
onChange={(v) => setUrlState({ status: String(v ?? '') })}
options={[
{ value: '', label: 'همهٔ وضعیت‌ها' },
...Object.entries(STATUS).map(([value, meta]) => ({ value, label: meta.label })),
]}
placeholder="وضعیت"
/>
</div>
}
actions={(e) =>
canManage ? (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => remove.mutate(e.uuid)}
aria-label="حذف از لیست انتظار"
>
<TrashIcon style={{ width: 15 }} />
</button>
) : null
}
/>
</div>
</div>
);
}