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:
hamed
2026-08-01 15:32:58 +03:30
co-authored by Claude Opus 5
parent f8d4e97e35
commit ca2f9b8652
19 changed files with 248 additions and 51 deletions
+52 -1
View File
@@ -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>
);
}