Files
clinicpro/assets/admin/components/PolicyConditionBuilder.tsx
T
hamedandClaude Opus 5 bcfa87bfad 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>
2026-07-31 10:44:28 +03:30

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>
);
}