Files
clinicpro/assets/admin/pages/CancellationPolicyPage.tsx
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
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>
2026-07-31 21:42:11 +03:30

166 lines
6.2 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';
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>
);
}