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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Switch from './ui/Switch';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
|
||||
interface ProtocolStep {
|
||||
step_number: number;
|
||||
offset_days: number;
|
||||
}
|
||||
|
||||
interface ProtocolStaff {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TreatmentProtocol {
|
||||
uuid: string;
|
||||
active: boolean;
|
||||
total_sessions: number;
|
||||
supervisor: { uuid: string; name: string } | null;
|
||||
steps: ProtocolStep[];
|
||||
staff: ProtocolStaff[];
|
||||
}
|
||||
|
||||
interface StaffRow {
|
||||
uuid: string;
|
||||
full_name: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface DoctorRow {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** پیشفرضِ روشنکردن سوییچ: کوتاهترین دورهای که معنی دارد. */
|
||||
const DEFAULT_STEPS: ProtocolStep[] = [
|
||||
{ step_number: 1, offset_days: 0 },
|
||||
{ step_number: 2, offset_days: 30 },
|
||||
];
|
||||
|
||||
/**
|
||||
* «طول درمان» یک سرویس.
|
||||
*
|
||||
* وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تکجلسهای است — پس روشنکردن یعنی
|
||||
* ساختن و خاموشکردن یعنی حذف. فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره،
|
||||
* چون فاصلهٔ درمان به آخرین جلسه گره خورده نه به روز باز شدن پرونده.
|
||||
*/
|
||||
export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
|
||||
serviceUuid: string;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-protocol', serviceUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentProtocol | null>>(
|
||||
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
|
||||
),
|
||||
});
|
||||
|
||||
const { data: staffData } = useQuery({
|
||||
queryKey: ['staff-list-for-protocol'],
|
||||
queryFn: () => api.get<ApiResponse<StaffRow[]>>('/api/v1/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: doctorData } = useQuery({
|
||||
queryKey: ['my-clinic-doctors'],
|
||||
queryFn: () => api.get<ApiResponse<{ data: DoctorRow[] }>>('/api/v1/my/clinic-doctors'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const protocol = data?.data ?? null;
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [steps, setSteps] = useState<ProtocolStep[]>(DEFAULT_STEPS);
|
||||
const [staffUuids, setStaffUuids] = useState<string[]>([]);
|
||||
const [supervisor, setSupervisor] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(protocol !== null);
|
||||
setSteps(protocol?.steps?.length ? protocol.steps : DEFAULT_STEPS);
|
||||
setStaffUuids(protocol?.staff?.map((s) => s.uuid) ?? []);
|
||||
setSupervisor(protocol?.supervisor?.uuid ?? null);
|
||||
}, [protocol]);
|
||||
|
||||
const staffOptions = (staffData?.data ?? [])
|
||||
.filter((s) => s.active !== false)
|
||||
.map((s) => ({ value: s.uuid, label: s.full_name }));
|
||||
|
||||
const doctorOptions = (doctorData?.data?.data ?? []).map((d) => ({ value: d.uuid, label: d.name }));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.put<ApiResponse<TreatmentProtocol>>(
|
||||
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
|
||||
{
|
||||
steps: steps.map((s, i) => ({ step_number: i + 1, offset_days: s.offset_days })),
|
||||
staff_uuids: staffUuids,
|
||||
supervisor_doctor_uuid: supervisor,
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('طول درمان ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ طول درمان ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/service-item/${serviceUuid}/treatment-protocol`),
|
||||
onSuccess: () => {
|
||||
toast.success('طول درمان خاموش شد');
|
||||
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'خاموشکردن ناموفق بود'),
|
||||
});
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
setEnabled(on);
|
||||
if (!on && protocol !== null) remove.mutate();
|
||||
};
|
||||
|
||||
const setOffset = (index: number, value: number) => {
|
||||
setSteps((prev) => prev.map((s, i) => (i === index ? { ...s, offset_days: value } : s)));
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
setSteps((prev) => [
|
||||
...prev,
|
||||
{ step_number: prev.length + 1, offset_days: prev[prev.length - 1]?.offset_days || 30 },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
setSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== index)));
|
||||
};
|
||||
|
||||
const addStaff = (uuid: string | number | null) => {
|
||||
const id = uuid === null ? '' : String(uuid);
|
||||
if (id && !staffUuids.includes(id)) setStaffUuids((prev) => [...prev, id]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 16 }}>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={toggle}
|
||||
disabled={!canEdit}
|
||||
label="طول درمان"
|
||||
hint="سرویسهایی که در چند جلسه انجام میشوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تکجلسهای."
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<section style={{ display: 'grid', gap: 10 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 13.5 }}>
|
||||
جلسات دوره <span style={{ color: 'var(--text-3)', fontWeight: 400 }}>({steps.length} جلسه)</span>
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
فاصلهٔ هر جلسه از <b>جلسهٔ قبل</b> حساب میشود، نه از شروع دوره. اگر بیمار دیر بیاید،
|
||||
بقیهٔ دوره هم جابهجا میشود.
|
||||
</p>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="field" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ minWidth: 62, fontSize: 13 }}>جلسهٔ {index + 1}</span>
|
||||
|
||||
{index === 0 ? (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1 }}>
|
||||
شروع دوره — همان روز اولین نوبت
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={step.offset_days}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setOffset(index, Number(e.target.value))}
|
||||
aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1 }}>روز بعد از جلسهٔ قبل</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && steps.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
onClick={() => removeStep(index)}
|
||||
aria-label={`حذف جلسهٔ ${index + 1}`}
|
||||
>
|
||||
<TrashIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
|
||||
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gap: 8 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 13.5 }}>پرسنل مجاز</h3>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
منشی هنگام رزرو فقط از میان همینها انتخاب میکند.
|
||||
</p>
|
||||
|
||||
{canEdit && (
|
||||
<SearchableSelect
|
||||
options={staffOptions.filter((o) => !staffUuids.includes(String(o.value)))}
|
||||
value={null}
|
||||
onChange={addStaff}
|
||||
placeholder="افزودن پرسنل..."
|
||||
ariaLabel="افزودن پرسنل مجاز"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{staffUuids.length === 0 && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>حداقل یک پرسنل الزامی است</span>
|
||||
)}
|
||||
{staffUuids.map((uuid) => (
|
||||
<span key={uuid} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
|
||||
aria-label="حذف این پرسنل"
|
||||
style={{ padding: 0, minWidth: 18, height: 18 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gap: 8 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 13.5 }}>پزشک ناظر</h3>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.
|
||||
</p>
|
||||
<SearchableSelect
|
||||
options={doctorOptions}
|
||||
value={supervisor}
|
||||
onChange={(v) => setSupervisor(v === null ? null : String(v))}
|
||||
placeholder="بدون پزشک ناظر"
|
||||
isClearable
|
||||
isDisabled={!canEdit}
|
||||
ariaLabel="پزشک ناظر دوره"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || staffUuids.length === 0}
|
||||
style={{ justifySelf: 'start' }}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ طول درمان'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,8 +51,25 @@ const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }
|
||||
unsettled: { color: 'amber', label: 'تسویه نشده' },
|
||||
};
|
||||
|
||||
const treatmentSessionMap: Record<string, { color: BadgeColor; label: string }> = {
|
||||
planned: { color: 'gray', label: 'برنامهریزی شده' },
|
||||
booked: { color: 'blue', label: 'زمانبندی شده' },
|
||||
in_progress: { color: 'amber', label: 'در حال انجام' },
|
||||
done: { color: 'green', label: 'انجام شد' },
|
||||
cancelled: { color: 'red', label: 'لغو شده' },
|
||||
no_show: { color: 'gray', label: 'غیبت' },
|
||||
};
|
||||
|
||||
const treatmentAreaMap: Record<string, { color: BadgeColor; label: string }> = {
|
||||
pending: { color: 'gray', label: 'در انتظار' },
|
||||
in_progress: { color: 'amber', label: 'در حال انجام' },
|
||||
completed: { color: 'green', label: 'تکمیل شده' },
|
||||
skipped: { color: 'violet', label: 'صرفنظر شده' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim' | 'invoice';
|
||||
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim' | 'invoice'
|
||||
| 'treatment-session' | 'treatment-area';
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -78,6 +95,12 @@ export default function StatusBadge({ type, value }: Props) {
|
||||
} else if (type === 'invoice') {
|
||||
const m = invoiceMap[value as InvoiceListStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'treatment-session') {
|
||||
const m = treatmentSessionMap[value];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'treatment-area') {
|
||||
const m = treatmentAreaMap[value];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'active') {
|
||||
color = value === 'true' || value === 'active' ? 'green' : 'gray';
|
||||
label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال';
|
||||
|
||||
Reference in New Issue
Block a user