feat(policy): rule builder and mandatory dry-run sandbox
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>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PolicyConditionBuilder from '../components/PolicyConditionBuilder';
|
||||
import {
|
||||
usePolicyMutations,
|
||||
usePolicySchema,
|
||||
usePolicyTemplates,
|
||||
} from '../hooks/usePolicies';
|
||||
import type { PolicyCategory, PolicyClause, PolicyEffect } from '../types';
|
||||
|
||||
type Mode = 'template' | 'advanced';
|
||||
|
||||
/**
|
||||
* ساخت قانون — با الگو (راه ۹۰٪ کاربران) یا دستی.
|
||||
*
|
||||
* قانون تازه پیشنویس ذخیره میشود و کاربر مستقیم به صفحهٔ آزمایش میرود: فعالسازی
|
||||
* بدون دیدن نتیجه ممکن نیست، پس بردنش به همانجا کوتاهترین مسیر درست است.
|
||||
*/
|
||||
export default function PolicyFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { schema } = usePolicySchema();
|
||||
const { templates } = usePolicyTemplates();
|
||||
const { create } = usePolicyMutations();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('template');
|
||||
const [name, setName] = useState('');
|
||||
const [priority, setPriority] = useState(0);
|
||||
|
||||
const [templateKey, setTemplateKey] = useState('');
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
|
||||
const [category, setCategory] = useState<PolicyCategory>('timing');
|
||||
const [match, setMatch] = useState<'all' | 'any'>('all');
|
||||
const [clauses, setClauses] = useState<PolicyClause[]>([]);
|
||||
const [effects, setEffects] = useState<PolicyEffect[]>([]);
|
||||
|
||||
const template = templates.find((t) => t.key === templateKey);
|
||||
const categorySchema = schema?.[category];
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => Object.entries(schema ?? {}).map(([key, v]) => ({ value: key, label: v.label })),
|
||||
[schema],
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
const body =
|
||||
mode === 'template'
|
||||
? { name, priority, template: templateKey, values }
|
||||
: {
|
||||
name,
|
||||
priority,
|
||||
category,
|
||||
condition: clauses.length ? { match, conditions: clauses } : {},
|
||||
effects,
|
||||
};
|
||||
|
||||
const created = await create.mutateAsync(body);
|
||||
navigate(`/admin/policies/${created.data.uuid}/simulate`);
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
name.trim() !== '' &&
|
||||
(mode === 'template' ? templateKey !== '' : effects.length > 0);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="قانون تازه"
|
||||
description="قانون پیشنویس ذخیره میشود؛ برای فعال شدن باید یک بار آزمایش شود."
|
||||
backTo="/admin/policies"
|
||||
/>
|
||||
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn sm ${mode === 'template' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setMode('template')}
|
||||
>
|
||||
از الگو
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn sm ${mode === 'advanced' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setMode('advanced')}
|
||||
>
|
||||
پیشرفته
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="policy-name">نام قانون</label>
|
||||
<input
|
||||
id="policy-name"
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
این نام در پیام خطا به بیمار نشان داده میشود؛ قابل فهم بنویسید.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ maxWidth: 200 }}>
|
||||
<label htmlFor="policy-priority">اولویت</label>
|
||||
<input
|
||||
id="policy-priority"
|
||||
className="input"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
در تناقض، عدد بزرگتر برنده است.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'template' ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>الگو</label>
|
||||
<SearchableSelect
|
||||
value={templateKey}
|
||||
onChange={(v) => {
|
||||
setTemplateKey(String(v ?? ''));
|
||||
setValues({});
|
||||
}}
|
||||
options={templates.map((t) => ({ value: t.key, label: t.title }))}
|
||||
placeholder="یک الگو انتخاب کنید"
|
||||
/>
|
||||
{template && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{template.description}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{template?.inputs.map((input) => (
|
||||
<div className="field" key={input.key} style={{ maxWidth: 260 }}>
|
||||
<label htmlFor={`tpl-${input.key}`}>{input.label}</label>
|
||||
<input
|
||||
id={`tpl-${input.key}`}
|
||||
className="input"
|
||||
type={input.type === 'int' ? 'number' : 'text'}
|
||||
min={input.min}
|
||||
max={input.max}
|
||||
value={values[input.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [input.key]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field" style={{ maxWidth: 260 }}>
|
||||
<label>دسته</label>
|
||||
<SearchableSelect
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory((v as PolicyCategory) ?? 'timing');
|
||||
// فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگهداشتنشان یعنی ۴۲۲.
|
||||
setClauses([]);
|
||||
setEffects([]);
|
||||
}}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categorySchema && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>شرط</h3>
|
||||
<PolicyConditionBuilder
|
||||
schema={categorySchema}
|
||||
match={match}
|
||||
clauses={clauses}
|
||||
onChange={(m, c) => {
|
||||
setMatch(m);
|
||||
setClauses(c);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>اثر</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{categorySchema.effects.map((meta) => {
|
||||
const current = effects.find((e) => e.type === meta.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={meta.type}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 200 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!current}
|
||||
onChange={(e) =>
|
||||
setEffects(
|
||||
e.target.checked
|
||||
? [...effects, { type: meta.type, value: meta.value_type === 'int' ? 0 : '' }]
|
||||
: effects.filter((x) => x.type !== meta.type),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{meta.label}
|
||||
</label>
|
||||
|
||||
{current && meta.value_type !== 'none' && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 180 }}
|
||||
type={meta.value_type === 'int' ? 'number' : 'text'}
|
||||
value={String(current.value ?? '')}
|
||||
onChange={(e) =>
|
||||
setEffects(
|
||||
effects.map((x) =>
|
||||
x.type === meta.type
|
||||
? {
|
||||
...x,
|
||||
value:
|
||||
meta.value_type === 'int'
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
}
|
||||
: x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{current && meta.type === 'forbid' && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
value={String(current.reason ?? '')}
|
||||
placeholder="پیامی که بیمار میبیند"
|
||||
onChange={(e) =>
|
||||
setEffects(
|
||||
effects.map((x) =>
|
||||
x.type === 'forbid' ? { ...x, reason: e.target.value } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
ترکیب: {meta.combination}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!canSubmit || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
ذخیره و آزمایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => navigate('/admin/policies')}>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user