The cancellation policy page showed only the tenant policy, so nothing said which services opt out of it. Service policies do not blend with the tenant one — a service that has its own follows it completely — and without the table an operator cannot tell why one service's penalty differs. It lists them with a link to each service. The waitlist had the matches endpoint and no way to reach it. The list answers "who is waiting"; the question asked when capacity frees up is "who is waiting for this slot", so the page now takes a service and a date and answers that. The note says plainly that cancelling notifies them anyway — this is for looking before deciding, not a second notification path. Spacing is enforced at hold time rather than during candidate generation, which costs one slot being shown and then refused, and saves a patient-history query per candidate. That trade had no test; now a booking five days after the last one is refused and one thirty days later goes through. Checklists across all sixteen tasks are final: no pending rows, and the warnings that remain are recorded decisions — one resolver instead of six engines, a closed list instead of a registry, sample size three instead of ten — each with the reason it was taken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
8.5 KiB
TypeScript
214 lines
8.5 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, formatNumber, isoToUnix } from '../lib/utils';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useWaitlist, useWaitlistMatches } from '../hooks/useCancellation';
|
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
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: '', match_service: '', match_at: '' });
|
|
const { entries, loading, remove } = useWaitlist(urlState.status || undefined);
|
|
|
|
/**
|
|
* «چه کسی برای **این** وقت منتظر است» — سؤالی که لحظهٔ آزاد شدن ظرفیت پرسیده میشود
|
|
* و فهرست کلی جوابش را نمیدهد.
|
|
*/
|
|
const { items: services } = useAllServiceItems();
|
|
const matchAt = isoToUnix(urlState.match_at) ?? 0;
|
|
const { matches, loading: matching } = useWaitlistMatches({
|
|
serviceUuid: urlState.match_service,
|
|
start: matchAt,
|
|
});
|
|
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 className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
|
|
<label>خدمتِ آزادشده</label>
|
|
<SearchableSelect
|
|
value={urlState.match_service}
|
|
onChange={(v) => setUrlState({ match_service: String(v ?? '') })}
|
|
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
|
placeholder="انتخاب خدمت"
|
|
isClearable
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
|
<label>تاریخ وقت آزاد</label>
|
|
<PersianDateInput
|
|
value={urlState.match_at}
|
|
onChange={(v) => setUrlState({ match_at: v })}
|
|
/>
|
|
</div>
|
|
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', paddingBottom: 8 }}>
|
|
{urlState.match_service === '' || matchAt === 0
|
|
? 'خدمت و تاریخ را انتخاب کنید تا منتظرانِ همان وقت را ببینید.'
|
|
: matching
|
|
? 'در حال بررسی…'
|
|
: `${formatNumber(matches.length)} نفر برای این وقت منتظرند`}
|
|
</span>
|
|
</div>
|
|
|
|
{urlState.match_service !== '' && matchAt > 0 && matches.length > 0 && (
|
|
<div className="card card-pad" style={{ marginBottom: 16 }}>
|
|
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قابل تطبیق با این وقت</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13 }}>
|
|
{matches.map((m) => (
|
|
<div key={m.uuid} style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<span style={{ fontWeight: 600 }}>{m.service_name}</span>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
{formatDate(m.desired_from)} تا {formatDate(m.desired_to)}
|
|
</span>
|
|
<span style={{ color: 'var(--text-3)' }}>
|
|
{m.preferred_day_parts.length === 0
|
|
? 'بیتفاوت'
|
|
: m.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')}
|
|
</span>
|
|
<span className={STATUS[m.status].className}>
|
|
<span className="bdot" />
|
|
{STATUS[m.status].label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '10px 0 0', lineHeight: 1.8 }}>
|
|
لغو نوبت خودش این افراد را خبر میکند؛ این فهرست فقط برای دیدنِ پیش از تصمیم است.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
}
|