Files
clinicpro/assets/admin/pages/CancellationPolicyPage.tsx
T
hamedandClaude Opus 5 ca2f9b8652 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>
2026-08-01 15:32:58 +03:30

217 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import PageHeader from '../components/ui/PageHeader';
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: 'بدون جریمه' },
{ value: 'percent', label: 'درصدی' },
{ value: 'fixed', label: 'مبلغ ثابت' },
];
/**
* سیاست لغو محیط.
*
* پیش‌فرض «بدون جریمه» است و همین‌جا هم گفته می‌شود: فعال‌کردن جریمه یک تصمیم
* کسب‌وکاری است، نه چیزی که کسی تصادفی روشنش کند.
*/
export default function CancellationPolicyPage() {
const { policy, overrides, loading, save } = useCancellationPolicy();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [freeWindow, setFreeWindow] = useState(24);
const [mode, setMode] = useState<'none' | 'percent' | 'fixed'>('none');
const [value, setValue] = useState(0);
const [depositRefundable, setDepositRefundable] = useState(false);
const [creditRefundable, setCreditRefundable] = useState(true);
const [threshold, setThreshold] = useState(3);
useEffect(() => {
if (!policy) return;
setFreeWindow(policy.free_window_hours);
setMode(policy.penalty_mode);
setValue(policy.penalty_value);
setDepositRefundable(policy.deposit_refundable);
setCreditRefundable(policy.credit_refundable);
setThreshold(policy.no_show_threshold);
}, [policy]);
const percentInvalid = mode === 'percent' && (value < 0 || value > 100);
return (
<div className="fade-in">
<PageHeader
title="سیاست لغو"
description="تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، و بعد از چند بار عدم حضور بیمار پرریسک علامت می‌خورد."
backTo="/admin/settings-menu"
/>
<div className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
{loading && <span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>}
<div className="field-block" style={{ maxWidth: 220 }}>
<label htmlFor="cp-window">پنجرهٔ لغو رایگان (ساعت)</label>
<input
id="cp-window"
className="input"
type="number"
min={0}
value={freeWindow}
onChange={(e) => setFreeWindow(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
لغو زودتر از این، همیشه رایگان است.
</span>
</div>
<div className="field-block" style={{ maxWidth: 260 }}>
<label>نوع جریمه پس از پنجرهٔ رایگان</label>
<SearchableSelect
value={mode}
onChange={(v) => setMode((v as 'none' | 'percent' | 'fixed') ?? 'none')}
options={MODES}
/>
</div>
{mode === 'percent' && (
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-percent">درصد جریمه</label>
<input
id="cp-percent"
className="input"
type="number"
min={0}
max={100}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
/>
{percentInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>درصد باید بین ۰ تا ۱۰۰ باشد.</span>
)}
</div>
)}
{mode === 'fixed' && (
<div className="field-block" style={{ maxWidth: 260 }}>
<label>مبلغ جریمه</label>
<PriceInput value={value} onChange={setValue} suffix="ریال" />
</div>
)}
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
جریمه هرگز از مبلغ پرداختی بیمار بیشتر نمی‌شود؛ نوبت نقدی جریمه‌ای ندارد.
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={depositRefundable}
onChange={(e) => setDepositRefundable(e.target.checked)}
/>
بیعانه پس از پنجرهٔ رایگان هم برمی‌گردد
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={creditRefundable}
onChange={(e) => setCreditRefundable(e.target.checked)}
/>
اعتبار پکیج پس از لغو برمی‌گردد
</label>
<div className="field-block" style={{ maxWidth: 220 }}>
<label htmlFor="cp-threshold">آستانهٔ عدم حضور</label>
<input
id="cp-threshold"
className="input"
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
بعد از این تعداد در یک سال، بیمار برچسب پرریسک می‌گیرد ولی مسدود نمی‌شود.
</span>
</div>
{canManage && (
<div>
<button
type="button"
className="btn primary"
disabled={percentInvalid || save.isPending}
onClick={() =>
save.mutate({
free_window_hours: freeWindow,
penalty_mode: mode,
penalty_value: value,
deposit_refundable: depositRefundable,
credit_refundable: creditRefundable,
no_show_threshold: threshold,
})
}
>
ذخیرهٔ سیاست
</button>
</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>
);
}