From d8aabe5d0a8f4bceeac2ca01c319ed56df6a2208 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 18:29:49 +0330 Subject: [PATCH] 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) --- assets/admin/App.tsx | 4 + assets/admin/components/FieldSchemaEditor.tsx | 126 ++++++++ .../admin/components/TreatmentProtocolTab.tsx | 285 +++++++++++++++++ assets/admin/components/ui/StatusBadge.tsx | 25 +- assets/admin/hooks/useResources.ts | 4 +- assets/admin/pages/ResourceTypesPage.tsx | 16 +- assets/admin/pages/ServiceDetailPage.tsx | 3 + assets/admin/pages/StaffSessionDetailPage.tsx | 286 ++++++++++++++++++ .../pages/StaffTreatmentSessionsPage.tsx | 69 +++++ assets/admin/types/index.ts | 69 +++++ 10 files changed, 880 insertions(+), 7 deletions(-) create mode 100644 assets/admin/components/FieldSchemaEditor.tsx create mode 100644 assets/admin/components/TreatmentProtocolTab.tsx create mode 100644 assets/admin/pages/StaffSessionDetailPage.tsx create mode 100644 assets/admin/pages/StaffTreatmentSessionsPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 5ae5c4cd..8b60bcf7 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -60,6 +60,8 @@ import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; import StaffPage from './pages/StaffPage'; import StaffMyServicesPage from './pages/StaffMyServicesPage'; +import StaffTreatmentSessionsPage from './pages/StaffTreatmentSessionsPage'; +import StaffSessionDetailPage from './pages/StaffSessionDetailPage'; import SubscriptionPage from './pages/SubscriptionPage'; import DiscountsPage from './pages/DiscountsPage'; import ClinicServicesPage from './pages/ClinicServicesPage'; @@ -281,6 +283,8 @@ export default function App() { } /> {/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */} } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/FieldSchemaEditor.tsx b/assets/admin/components/FieldSchemaEditor.tsx new file mode 100644 index 00000000..0a867d2e --- /dev/null +++ b/assets/admin/components/FieldSchemaEditor.tsx @@ -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) => { + 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 ( +
+

+ اپراتور بعد از درمان هر ناحیه این فیلدها را پر می‌کند. بدون فیلد، فقط دستگاه و زمان ثبت می‌شود. +

+ + {value.map((field, index) => ( +
+
+ + patch(index, { key: e.target.value })} + /> + + + + patch(index, { label: e.target.value })} + /> + + + + patch(index, { type: (v === null ? 'text' : String(v)) as TreatmentFormField['type'] })} + ariaLabel="نوع فیلد" + /> + +
+ + {field.type === 'select' && ( + + patch(index, { + options: e.target.value + .split(/[,،]/) + .map((o) => o.trim()) + .filter((o) => o !== ''), + })} + /> + + )} + +
+ patch(index, { required: v })} + disabled={disabled} + label="الزامی" + /> + + {!disabled && ( + + )} +
+
+ ))} + + {!disabled && value.length < MAX_FIELDS && ( + + )} +
+ ); +} diff --git a/assets/admin/components/TreatmentProtocolTab.tsx b/assets/admin/components/TreatmentProtocolTab.tsx new file mode 100644 index 00000000..9d2d3ad1 --- /dev/null +++ b/assets/admin/components/TreatmentProtocolTab.tsx @@ -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>( + `/api/v1/service-item/${serviceUuid}/treatment-protocol`, + ), + }); + + const { data: staffData } = useQuery({ + queryKey: ['staff-list-for-protocol'], + queryFn: () => api.get>('/api/v1/staff'), + staleTime: 60_000, + }); + + const { data: doctorData } = useQuery({ + queryKey: ['my-clinic-doctors'], + queryFn: () => api.get>('/api/v1/my/clinic-doctors'), + staleTime: 60_000, + }); + + const protocol = data?.data ?? null; + + const [enabled, setEnabled] = useState(false); + const [steps, setSteps] = useState(DEFAULT_STEPS); + const [staffUuids, setStaffUuids] = useState([]); + const [supervisor, setSupervisor] = useState(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>( + `/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>(`/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
در حال بارگذاری...
; + } + + return ( +
+ + + {enabled && ( + <> +
+

+ جلسات دوره ({steps.length} جلسه) +

+

+ فاصلهٔ هر جلسه از جلسهٔ قبل حساب می‌شود، نه از شروع دوره. اگر بیمار دیر بیاید، + بقیهٔ دوره هم جابه‌جا می‌شود. +

+ + {steps.map((step, index) => ( +
+ جلسهٔ {index + 1} + + {index === 0 ? ( + + شروع دوره — همان روز اولین نوبت + + ) : ( + <> + setOffset(index, Number(e.target.value))} + aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`} + style={{ width: 96 }} + /> + روز بعد از جلسهٔ قبل + + )} + + {canEdit && steps.length > 2 && ( + + )} +
+ ))} + + {canEdit && ( + + )} +
+ +
+

پرسنل مجاز

+

+ منشی هنگام رزرو فقط از میان همین‌ها انتخاب می‌کند. +

+ + {canEdit && ( + !staffUuids.includes(String(o.value)))} + value={null} + onChange={addStaff} + placeholder="افزودن پرسنل..." + ariaLabel="افزودن پرسنل مجاز" + /> + )} + +
+ {staffUuids.length === 0 && ( + حداقل یک پرسنل الزامی است + )} + {staffUuids.map((uuid) => ( + + {staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid} + {canEdit && ( + + )} + + ))} +
+
+ +
+

پزشک ناظر

+

+ پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد. +

+ setSupervisor(v === null ? null : String(v))} + placeholder="بدون پزشک ناظر" + isClearable + isDisabled={!canEdit} + ariaLabel="پزشک ناظر دوره" + /> +
+ + {canEdit && ( + + )} + + )} +
+ ); +} diff --git a/assets/admin/components/ui/StatusBadge.tsx b/assets/admin/components/ui/StatusBadge.tsx index e2709d64..42ea04f0 100644 --- a/assets/admin/components/ui/StatusBadge.tsx +++ b/assets/admin/components/ui/StatusBadge.tsx @@ -51,8 +51,25 @@ const invoiceMap: Record = { + 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 = { + 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' ? 'فعال' : 'غیرفعال'; diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 57af90f5..d03c5f76 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -162,14 +162,14 @@ export function useResourceTypes() { }); const create = useMutation({ - mutationFn: (d: { code: string; name: string }) => + mutationFn: (d: { code: string; name: string; field_schema?: unknown }) => api.post>('/api/v1/resource-types', d), onSuccess: () => { toast.success('نوع منبع افزوده شد'); invalidate(); }, onError: (e) => fail(e, 'افزودن نوع منبع ناموفق بود'), }); const update = useMutation({ - mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) => + mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean; field_schema?: unknown } }) => api.patch>(`/api/v1/resource-type/${uuid}`, d), onSuccess: () => { toast.success('نوع منبع به‌روزرسانی شد'); invalidate(); }, onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), diff --git a/assets/admin/pages/ResourceTypesPage.tsx b/assets/admin/pages/ResourceTypesPage.tsx index fd8d53d7..952f1970 100644 --- a/assets/admin/pages/ResourceTypesPage.tsx +++ b/assets/admin/pages/ResourceTypesPage.tsx @@ -7,11 +7,12 @@ import ConfirmDialog from '../components/ui/ConfirmDialog'; import Field from '../components/ui/Field'; import Input from '../components/ui/Input'; import Switch from '../components/ui/Switch'; +import FieldSchemaEditor from '../components/FieldSchemaEditor'; import { ActiveBadge } from '../components/ui/StatusBadge'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; import { useResourceTypes } from '../hooks/useResources'; -import type { ResourceType } from '../types'; +import type { TreatmentFormField, ResourceType } from '../types'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */ @@ -101,7 +102,7 @@ export default function ResourceTypesPage() { onClose={() => setEditing({ open: false, type: null })} onSave={(payload) => { const opts = { onSuccess: () => setEditing({ open: false, type: null }) }; - if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts); + if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active, field_schema: payload.fieldSchema } }, opts); else create.mutate({ code: payload.code, name: payload.name }, opts); }} /> @@ -126,17 +127,19 @@ function TypeModal({ type: ResourceType | null; saving: boolean; onClose: () => void; - onSave: (payload: { code: string; name: string; active: boolean }) => void; + onSave: (payload: { code: string; name: string; active: boolean; fieldSchema: TreatmentFormField[] }) => void; }) { const [code, setCode] = useState(''); const [name, setName] = useState(''); const [active, setActive] = useState(true); + const [fieldSchema, setFieldSchema] = useState([]); React.useEffect(() => { if (!open) return; setCode(type?.code ?? ''); setName(type?.name ?? ''); setActive(type?.active ?? true); + setFieldSchema(type?.field_schema ?? []); }, [open, type]); const isEdit = type !== null; @@ -145,7 +148,7 @@ function TypeModal({ const submit = () => { if (saving || invalid) return; - onSave({ code: code.trim(), name: name.trim(), active }); + onSave({ code: code.trim(), name: name.trim(), active, fieldSchema }); }; return ( @@ -210,6 +213,11 @@ function TypeModal({ همین نام در فهرست منابع و انتخابگرها دیده می‌شود. +
+ فرم ثبت درمان + +
+ )} + {tab === 'treatment' && } {tab === 'goods' && setEditOpen(true)} canUpdate={canUpdate} />} {tab === 'history' && } diff --git a/assets/admin/pages/StaffSessionDetailPage.tsx b/assets/admin/pages/StaffSessionDetailPage.tsx new file mode 100644 index 00000000..13d6ff3c --- /dev/null +++ b/assets/admin/pages/StaffSessionDetailPage.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import PageHeader from '../components/ui/PageHeader'; +import StatusBadge from '../components/ui/StatusBadge'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { formatDate } from '../lib/utils'; +import type { SessionAreaRecord, StaffSessionDetail, TreatmentFormField } from '../types'; + +const BASE = '/api/v1/dashboard/staff'; + +/** + * صفحهٔ انجام جلسه. + * + * فرمِ هر ناحیه از `forms` می‌آید که سرور از روی نوع منبع ساخته — پنل فیلدها را حدس + * نمی‌زند، پس افزودن دستگاه تازه در تنظیمات همین‌جا هم ظاهر می‌شود بدون تغییر کد. + */ +export default function StaffSessionDetailPage() { + const { uuid = '' } = useParams(); + const qc = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['staff-session', uuid], + queryFn: () => api.get>(`${BASE}/treatment-session/${uuid}`), + enabled: uuid !== '', + }); + + const session = data?.data; + const [note, setNote] = useState(''); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ['staff-session', uuid] }); + qc.invalidateQueries({ queryKey: ['staff-treatment-sessions'] }); + }; + + const fail = (e: unknown, fallback: string) => + toast.error(e instanceof ApiError ? e.message : fallback); + + const startSession = useMutation({ + mutationFn: () => api.post>(`${BASE}/treatment-session/${uuid}/start`, {}), + onSuccess: () => { toast.success('جلسه شروع شد'); refresh(); }, + onError: (e) => fail(e, 'شروع جلسه ناموفق بود'), + }); + + const finishSession = useMutation({ + mutationFn: () => api.post>( + `${BASE}/treatment-session/${uuid}/finish`, + { note: note || undefined }, + ), + onSuccess: (res) => { + const left = res?.data?.unsettled_areas ?? 0; + toast.success(left > 0 ? `جلسه بسته شد — ${left} ناحیه تکمیل نشده بود` : 'جلسه با موفقیت تمام شد'); + refresh(); + }, + onError: (e) => fail(e, 'اتمام جلسه ناموفق بود'), + }); + + const skipArea = useMutation({ + mutationFn: (areaUuid: string) => api.post>(`${BASE}/session-area/${areaUuid}/skip`, {}), + onSuccess: () => { toast.success('این ناحیه صرف‌نظر شد'); refresh(); }, + onError: (e) => fail(e, 'صرف‌نظر از ناحیه ناموفق بود'), + }); + + const completeArea = useMutation({ + mutationFn: (payload: { areaUuid: string; body: Record }) => + api.post>(`${BASE}/session-area/${payload.areaUuid}/complete`, payload.body), + onSuccess: () => { toast.success('اطلاعات ناحیه ثبت شد'); refresh(); }, + onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'), + }); + + if (isLoading) { + return
در حال بارگذاری...
; + } + + if (!session) { + return
جلسه یافت نشد
; + } + + const areas = session.areas ?? []; + const settled = areas.filter((a) => a.status === 'completed' || a.status === 'skipped').length; + const started = session.started_at !== null; + const finished = session.status === 'done'; + + return ( + <> + + +
+
+ {session.case.service.name} + +
+ +
+ {session.appointment && تاریخ: {formatDate(session.appointment.slot_start)}} + {session.performed_by && اپراتور: {session.performed_by.name}} + {settled} از {areas.length} ناحیه انجام شده +
+ + {!started && !finished && ( + + )} +
+ +

نواحی این جلسه

+ +
+ {areas.length === 0 && ( +
+ برای دیدن نواحی، ابتدا جلسه را شروع کنید. +
+ )} + + {areas.map((area) => ( + skipArea.mutate(area.uuid)} + onComplete={(body) => completeArea.mutate({ areaUuid: area.uuid, body })} + saving={completeArea.isPending} + /> + ))} +
+ + {started && !finished && ( +
+

یادداشت و اتمام جلسه

+