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>
217 lines
8.8 KiB
TypeScript
217 lines
8.8 KiB
TypeScript
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>
|
||
);
|
||
}
|