Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
11 KiB
TypeScript
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 card-pad" 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-block">
|
|
<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-block" 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-block">
|
|
<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-block" 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-block" 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>
|
|
);
|
|
}
|