Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
TypeScript
166 lines
6.2 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';
|
||
|
||
const MODES = [
|
||
{ value: 'none', label: 'بدون جریمه' },
|
||
{ value: 'percent', label: 'درصدی' },
|
||
{ value: 'fixed', label: 'مبلغ ثابت' },
|
||
];
|
||
|
||
/**
|
||
* سیاست لغو محیط.
|
||
*
|
||
* پیشفرض «بدون جریمه» است و همینجا هم گفته میشود: فعالکردن جریمه یک تصمیم
|
||
* کسبوکاری است، نه چیزی که کسی تصادفی روشنش کند.
|
||
*/
|
||
export default function CancellationPolicyPage() {
|
||
const { policy, 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>
|
||
);
|
||
}
|