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,156 @@
|
||||
import React from 'react';
|
||||
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { PolicyCategorySchema, PolicyClause } from '../types';
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
equals: 'برابر است با',
|
||||
not_equals: 'برابر نیست با',
|
||||
greater_than: 'بیشتر از',
|
||||
less_than: 'کمتر از',
|
||||
in: 'یکی از',
|
||||
contains: 'شامل',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
schema: PolicyCategorySchema;
|
||||
match: 'all' | 'any';
|
||||
clauses: PolicyClause[];
|
||||
onChange: (match: 'all' | 'any', clauses: PolicyClause[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* شرطساز — کاملاً از `policy-schema` ساخته میشود.
|
||||
*
|
||||
* فیلدها، عملگرهای **مجاز برای همان فیلد**، و نوع ورودی مقدار، همه از سرور میآیند.
|
||||
* اگر اینجا فهرست دستی مینوشتیم، هر فیلد تازه در بکاند نیاز به تغییر فرانت داشت و
|
||||
* بعد از دو ماه دو فهرست ناهمگام میداشتیم.
|
||||
*/
|
||||
export default function PolicyConditionBuilder({ schema, match, clauses, onChange }: Props) {
|
||||
const metaOf = (field: string) => schema.field_meta.find((m) => m.key === field);
|
||||
|
||||
const update = (index: number, patch: Partial<PolicyClause>) => {
|
||||
onChange(
|
||||
match,
|
||||
clauses.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const addClause = () => {
|
||||
const first = schema.field_meta[0];
|
||||
if (!first) return;
|
||||
onChange(match, [...clauses, { field: first.key, operator: first.operators[0], value: '' }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>شرطها با هم:</span>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={match}
|
||||
onChange={(v) => onChange((v as 'all' | 'any') ?? 'all', clauses)}
|
||||
options={[
|
||||
{ value: 'all', label: 'همه برقرار باشند' },
|
||||
{ value: 'any', label: 'یکی برقرار باشد' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clauses.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
بدون شرط، این قانون روی همهٔ نوبتهای دامنهاش اعمال میشود.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{clauses.map((clause, index) => {
|
||||
const meta = metaOf(clause.field);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<SearchableSelect
|
||||
value={clause.field}
|
||||
onChange={(v) => {
|
||||
const next = metaOf(String(v ?? ''));
|
||||
update(index, {
|
||||
field: String(v ?? ''),
|
||||
// عملگر قبلی ممکن است برای فیلد تازه بیمعنا باشد؛ به اولین
|
||||
// عملگرِ مجاز برمیگردد تا کاربر ۴۲۲ نگیرد.
|
||||
operator: next?.operators[0] ?? 'equals',
|
||||
value: '',
|
||||
});
|
||||
}}
|
||||
options={schema.field_meta.map((m) => ({ value: m.key, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={clause.operator}
|
||||
onChange={(v) => update(index, { operator: String(v ?? 'equals') })}
|
||||
options={(meta?.operators ?? []).map((op) => ({
|
||||
value: op,
|
||||
label: OPERATOR_LABELS[op] ?? op,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160, flex: 1 }}>
|
||||
{meta?.type === 'enum' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: String(v ?? '') })}
|
||||
options={(meta.values ?? []).map((val) => ({
|
||||
value: val,
|
||||
label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val,
|
||||
}))}
|
||||
/>
|
||||
) : meta?.type === 'bool' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: v === 'true' })}
|
||||
options={[
|
||||
{ value: 'true', label: 'بله' },
|
||||
{ value: 'false', label: 'خیر' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={meta?.type === 'int' ? 'number' : 'text'}
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
value: meta?.type === 'int' ? Number(e.target.value) : e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="مقدار"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => onChange(match, clauses.filter((_, i) => i !== index))}
|
||||
aria-label="حذف شرط"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<button type="button" className="btn secondary sm" onClick={addClause}>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن شرط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
||||
|
||||
@@ -32,6 +32,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
Reference in New Issue
Block a user