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>
157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|