Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { BeakerIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import { formatDate } from '../lib/utils';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
|
|
import type { PolicySimulationRun, SimulationRow } from '../types';
|
|
|
|
const SEVERITY: Record<PolicySimulationRun['severity'], { label: string; className: string; note: string }> = {
|
|
none: {
|
|
label: 'بدون اثر',
|
|
className: 'badge',
|
|
note: 'این قانون روی هیچ نوبتی از نمونه اثر نداشت — احتمالاً شرطش هرگز برقرار نمیشود.',
|
|
},
|
|
low: { label: 'کم', className: 'badge green', note: 'اثر محدود و قابل انتظار.' },
|
|
medium: { label: 'متوسط', className: 'badge amber', note: 'بخش قابلتوجهی از نوبتها تغییر میکنند.' },
|
|
high: {
|
|
label: 'زیاد',
|
|
className: 'badge red',
|
|
note: 'بیشتر نوبتهای نمونه تغییر میکنند. مطمئنید قانون درست نوشته شده؟',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* آزمایش قانون روی نوبتهای واقعی گذشته.
|
|
*
|
|
* ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی میفهمد؛ درصد و شدت
|
|
* هم لازماند چون قانونی که ۹۸٪ نوبتها را عوض میکند تقریباً همیشه اشتباه است.
|
|
*/
|
|
export default function PolicySimulationPage() {
|
|
const { policyUuid } = useParams<{ policyUuid: string }>();
|
|
const navigate = useNavigate();
|
|
const { policy } = usePolicy(policyUuid);
|
|
const { runs, loading, run } = usePolicySimulation(policyUuid);
|
|
const { activate } = usePolicies();
|
|
const { can } = usePermissions();
|
|
const canManage = can('appointment_settings', 'update');
|
|
|
|
const [latest, setLatest] = useState<PolicySimulationRun | null>(null);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const current = latest ?? runs[0] ?? null;
|
|
|
|
const isCurrentVersion = !!policy && !!current && current.policy_version === policy.version;
|
|
const severity = current ? SEVERITY[current.severity] : null;
|
|
|
|
const columns: Column<SimulationRow>[] = [
|
|
{
|
|
key: 'patient_name',
|
|
header: 'بیمار',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.patient_name}</span>,
|
|
},
|
|
{
|
|
key: 'slot_start',
|
|
header: 'تاریخ نوبت',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.slot_start)}</span>,
|
|
},
|
|
{
|
|
key: 'change',
|
|
header: 'وضعیت فعلی ← با این قانون',
|
|
render: (r) => (
|
|
<span style={{ fontSize: 13 }}>
|
|
<span style={{ color: 'var(--text-3)' }}>{r.before}</span>
|
|
{' ← '}
|
|
<span style={{ fontWeight: 600 }}>{r.after}</span>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'reason',
|
|
header: 'توضیح',
|
|
render: (r) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{r.reason}</span>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title={policy ? `آزمایش قانون: ${policy.name}` : 'آزمایش قانون'}
|
|
description="اجرای قانون روی نوبتهای واقعی گذشته. هیچ چیزی ثبت یا تغییر نمیشود."
|
|
backTo="/admin/policies"
|
|
action={
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
disabled={run.isPending}
|
|
onClick={async () => setLatest((await run.mutateAsync(50)).data)}
|
|
>
|
|
<BeakerIcon style={{ width: 15 }} /> اجرای آزمایش
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{current && severity && (
|
|
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 14 }}>
|
|
نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت (
|
|
{current.affected_percent}٪)
|
|
</span>
|
|
<span className={severity.className}>
|
|
<span className="bdot" /> شدت: {severity.label}
|
|
</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
نسخهٔ آزمایششده: {current.policy_version}
|
|
</span>
|
|
</div>
|
|
|
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-2)' }}>
|
|
{current.warning ?? severity.note}
|
|
</p>
|
|
|
|
{!isCurrentVersion && (
|
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--warning)' }}>
|
|
این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '}
|
|
{policy?.version} است. برای فعالسازی، دوباره آزمایش کنید.
|
|
</p>
|
|
)}
|
|
|
|
{canManage && (
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
disabled={!isCurrentVersion || activate.isPending || policy?.active}
|
|
onClick={() => {
|
|
// شدت زیاد یعنی احتمالاً قانون اشتباه نوشته شده؛ یک قدم مکث لازم است.
|
|
if (current.severity === 'high') {
|
|
setConfirming(true);
|
|
return;
|
|
}
|
|
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
|
|
}}
|
|
>
|
|
{policy?.active ? 'قانون فعال است' : 'فعالسازی قانون'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => navigate('/admin/policies')}
|
|
>
|
|
بازگشت به فهرست
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirming}
|
|
title="فعالسازی قانون پرتأثیر"
|
|
message={`این قانون ${current?.affected_percent ?? 0}٪ نوبتهای نمونه را تغییر میدهد. مطمئنید میخواهید فعالش کنید؟`}
|
|
confirmLabel="بله، فعال کن"
|
|
danger
|
|
loading={activate.isPending}
|
|
onCancel={() => setConfirming(false)}
|
|
onConfirm={() => {
|
|
setConfirming(false);
|
|
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
|
|
}}
|
|
/>
|
|
|
|
{policy?.versions && policy.versions.length > 1 && (
|
|
<div className="card" style={{ marginBottom: 16 }}>
|
|
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{policy.versions.map((v) => (
|
|
<div key={v.version} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
|
|
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
|
|
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
{JSON.stringify((v.snapshot as { effects?: unknown }).effects)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={current?.rows ?? []}
|
|
loading={loading || run.isPending}
|
|
emptyMessage={
|
|
current
|
|
? 'هیچ نوبتی از نمونه با این قانون تغییر نمیکرد'
|
|
: 'برای دیدن اثر این قانون، یک آزمایش اجرا کنید'
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|