feat(admin): build the last two screens, and pin spacing with a test
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>
This commit is contained in:
@@ -81,6 +81,26 @@ export function useCancelAppointment() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* درخواستهایی که با یک زمان مشخص میخوانند — ابزار لحظهٔ آزاد شدن ظرفیت.
|
||||
*
|
||||
* جدا از فهرست است چون سؤال متفاوتی میپرسد: نه «چه کسی منتظر است» بلکه «چه کسی
|
||||
* برای **این** وقت منتظر است».
|
||||
*/
|
||||
export function useWaitlistMatches(params: { serviceUuid: string; start: number; branchUuid?: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['waitlist-matches', params],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<WaitlistEntry[]>>(
|
||||
`/api/v1/waitlist/matches?service_uuid=${params.serviceUuid}&start=${params.start}` +
|
||||
(params.branchUuid ? `&branch_uuid=${params.branchUuid}` : ''),
|
||||
),
|
||||
enabled: !!params.serviceUuid && params.start > 0,
|
||||
});
|
||||
|
||||
return { matches: Array.isArray(query.data?.data) ? query.data.data : [], loading: query.isFetching };
|
||||
}
|
||||
|
||||
export function useWaitlist(status?: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = [...WAITLIST_KEY, status ?? ''];
|
||||
|
||||
@@ -4,6 +4,8 @@ import PriceInput from '../components/ui/PriceInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useCancellationPolicy } from '../hooks/useCancellation';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatNumber, formatRial } from '../lib/utils';
|
||||
|
||||
const MODES = [
|
||||
{ value: 'none', label: 'بدون جریمه' },
|
||||
@@ -18,7 +20,7 @@ const MODES = [
|
||||
* کسبوکاری است، نه چیزی که کسی تصادفی روشنش کند.
|
||||
*/
|
||||
export default function CancellationPolicyPage() {
|
||||
const { policy, loading, save } = useCancellationPolicy();
|
||||
const { policy, overrides, loading, save } = useCancellationPolicy();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can('appointment_settings', 'update');
|
||||
|
||||
@@ -160,6 +162,55 @@ export default function CancellationPolicyPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سیاست سرویس و سیاست محیط **ترکیب نمیشوند** — سرویس اگر سیاست دارد، همهاش
|
||||
مال اوست. بدون این جدول، اپراتور نمیداند کدام خدمتها از قاعدهٔ عمومی
|
||||
پیروی نمیکنند و چرا عدد جریمهٔ آنها فرق دارد. */}
|
||||
<div className="card card-pad" style={{ marginTop: 16, maxWidth: 640 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 4px' }}>سیاستهای اختصاصی خدمات</h3>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 12px', lineHeight: 1.9 }}>
|
||||
خدمتی که سیاست خودش را دارد، **کاملاً** از آن پیروی میکند و سیاست بالا رویش
|
||||
اثری ندارد. ساخت و ویرایشش از صفحهٔ همان خدمت انجام میشود.
|
||||
</p>
|
||||
|
||||
{overrides.length === 0 ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
هیچ خدمتی سیاست اختصاصی ندارد؛ همه از سیاست بالا پیروی میکنند.
|
||||
</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
|
||||
{overrides.map((o) => (
|
||||
<div
|
||||
key={o.uuid}
|
||||
style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}
|
||||
>
|
||||
<Link
|
||||
to={`/admin/clinic-services/${o.service_uuid}`}
|
||||
style={{ fontWeight: 600, color: 'var(--primary)' }}
|
||||
>
|
||||
{o.service_name ?? o.service_uuid}
|
||||
</Link>
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
رایگان تا {formatNumber(o.free_window_hours)} ساعت قبل
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
{o.penalty_mode === 'none'
|
||||
? 'بدون جریمه'
|
||||
: o.penalty_mode === 'percent'
|
||||
? `جریمه ${formatNumber(o.penalty_value)}٪`
|
||||
: `جریمه ${formatRial(o.penalty_value)}`}
|
||||
</span>
|
||||
{!o.active && (
|
||||
<span className="badge red">
|
||||
<span className="bdot" />
|
||||
غیرفعال
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ 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 { formatDate, formatNumber, isoToUnix } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWaitlist } from '../hooks/useCancellation';
|
||||
import { useWaitlist, useWaitlistMatches } from '../hooks/useCancellation';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import type { WaitlistEntry } from '../types';
|
||||
|
||||
/**
|
||||
@@ -33,8 +35,19 @@ const STATUS: Record<WaitlistEntry['status'], { label: string; className: string
|
||||
* اولین رزروکننده میبرد، پس اپراتور باید بداند چه کسی چند بار خبر شده.
|
||||
*/
|
||||
export default function WaitlistPage() {
|
||||
const [urlState, setUrlState] = useUrlState({ search: '', status: '' });
|
||||
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');
|
||||
|
||||
@@ -101,6 +114,63 @@ export default function WaitlistPage() {
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user