165 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|