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 && ( +
+

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

+