Files
clinicpro/assets/admin/components/FieldSchemaEditor.tsx
T
hamedandClaude Opus 5 d8aabe5d0a feat(admin): treatment plan tab, staff session screens and the device form editor
Three screens, each reusing what already exists rather than inventing a parallel
look. The treatment plan lives as a tab on the service page next to categories,
because the course belongs to the service; the switch is the protocol's existence
rather than a separate boolean that could disagree with the step list. Each step
asks for days since the previous session, which is how the interval actually
works and what the form should therefore say.

The staff screens are the flow from the reference screenshots: today's sessions,
then a session where each area is started, recorded and closed on its own. Field
inputs are built from the schema the server sends per resource type, so a clinic
adding an RF device sees its own form here without a code change.

StatusBadge gains treatment session and area states rather than a second badge
component sitting beside it, and the resource type modal grows a field editor so
the operator form is configured where the device is defined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:29:49 +03:30

127 lines
4.9 KiB
TypeScript

import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Field from './ui/Field';
import Input from './ui/Input';
import Switch from './ui/Switch';
import SearchableSelect from './ui/SearchableSelect';
import type { TreatmentFormField } from '../types';
const TYPE_OPTIONS = [
{ value: 'select', label: 'انتخاب از فهرست' },
{ value: 'number', label: 'عدد' },
{ value: 'text', label: 'متن' },
];
const MAX_FIELDS = 20;
/**
* فرمی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع پر می‌کند.
*
* روی نوع منبع تعریف می‌شود نه روی سرویس، چون خودِ دستگاه تعیین می‌کند چه چیزی
* خواندنی است: لیزر انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. افزودن دستگاه
* تازه این‌طور تنظیمات است، نه تغییر کد.
*/
export default function FieldSchemaEditor({ value, onChange, disabled }: {
value: TreatmentFormField[];
onChange: (fields: TreatmentFormField[]) => void;
disabled?: boolean;
}) {
const patch = (index: number, changes: Partial<TreatmentFormField>) => {
onChange(value.map((f, i) => (i === index ? { ...f, ...changes } : f)));
};
const add = () => {
onChange([...value, { key: '', label: '', type: 'number', required: false, sort_order: value.length }]);
};
return (
<div style={{ display: 'grid', gap: 12 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
اپراتور بعد از درمان هر ناحیه این فیلدها را پر می‌کند. بدون فیلد، فقط دستگاه و زمان ثبت می‌شود.
</p>
{value.map((field, index) => (
<div key={index} className="card card-pad" style={{ display: 'grid', gap: 10, background: 'var(--surface-2)' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Field label="کلید (انگلیسی)" htmlFor={`fs-key-${index}`}>
<Input
id={`fs-key-${index}`}
value={field.key}
disabled={disabled}
dir="ltr"
placeholder="energy"
onChange={(e) => patch(index, { key: e.target.value })}
/>
</Field>
<Field label="برچسب فارسی" htmlFor={`fs-label-${index}`}>
<Input
id={`fs-label-${index}`}
value={field.label}
disabled={disabled}
placeholder="انرژی"
onChange={(e) => patch(index, { label: e.target.value })}
/>
</Field>
<Field label="نوع" htmlFor={`fs-type-${index}`}>
<SearchableSelect
inputId={`fs-type-${index}`}
options={TYPE_OPTIONS}
value={field.type}
isDisabled={disabled}
onChange={(v) => patch(index, { type: (v === null ? 'text' : String(v)) as TreatmentFormField['type'] })}
ariaLabel="نوع فیلد"
/>
</Field>
</div>
{field.type === 'select' && (
<Field label="گزینه‌ها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
<Input
id={`fs-options-${index}`}
value={(field.options ?? []).join('، ')}
disabled={disabled}
dir="ltr"
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
onChange={(e) => patch(index, {
options: e.target.value
.split(/[,،]/)
.map((o) => o.trim())
.filter((o) => o !== ''),
})}
/>
</Field>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<Switch
inline
checked={field.required ?? false}
onChange={(v) => patch(index, { required: v })}
disabled={disabled}
label="الزامی"
/>
{!disabled && (
<button
type="button"
className="btn ghost sm"
onClick={() => onChange(value.filter((_, i) => i !== index))}
aria-label={`حذف فیلد ${field.label || index + 1}`}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</div>
</div>
))}
{!disabled && value.length < MAX_FIELDS && (
<button type="button" className="btn secondary sm" onClick={add} style={{ justifySelf: 'start' }}>
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن فیلد
</button>
)}
</div>
);
}