Files
clinicpro/assets/admin/pages/PolicyFormPage.tsx
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
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>
2026-07-31 21:42:11 +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 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>
);
}