Files
clinicpro/assets/admin/pages/PolicyFormPage.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

281 lines
11 KiB
TypeScript

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