Files

165 lines
6.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
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;
const parseOptions = (raw: string) =>
raw.split(/[,،]/).map((o) => o.trim()).filter((o) => o !== '');
/**
* ورودی متنی گزینه‌های فیلد `select`.
*
* متن خام را در state خودش نگه می‌دارد و آرایه را فقط موقع emit می‌سازد. اگر مقدار
* input مستقیم از آرایه ساخته می‌شد، کاما و فاصلهٔ انتهایی در همان keystroke حذف
* می‌شد (چون عنصر خالی filter می‌شود) و کاربر اصلاً نمی‌توانست کاما تایپ کند.
*/
function OptionsInput({ id, options, disabled, onChange }: {
id: string;
options: TreatmentFormField['options'];
disabled?: boolean;
onChange: (options: string[]) => void;
}) {
const [draft, setDraft] = useState(() => (options ?? []).join(', '));
const emitted = useRef(options);
// فقط وقتی آرایه از بیرون عوض شود (نه با تایپ خودِ کاربر) متن را همگام کن.
useEffect(() => {
if (options !== emitted.current) {
emitted.current = options;
setDraft((options ?? []).join(', '));
}
}, [options]);
return (
<Input
id={id}
value={draft}
disabled={disabled}
dir="ltr"
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
onChange={(e) => {
setDraft(e.target.value);
const next = parseOptions(e.target.value);
emitted.current = next;
onChange(next);
}}
/>
);
}
/**
* فرمی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع پر می‌کند.
*
* روی نوع منبع تعریف می‌شود نه روی سرویس، چون خودِ دستگاه تعیین می‌کند چه چیزی
* خواندنی است: لیزر انرژی و پالس و شات دارد، دستگاه 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}`}>
<OptionsInput
id={`fs-options-${index}`}
options={field.options}
disabled={disabled}
onChange={(options) => patch(index, { options })}
/>
</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>
);
}