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:
hamed
2026-08-06 18:29:49 +03:30
co-authored by Claude Opus 5
parent 77eeefd5b4
commit d8aabe5d0a
10 changed files with 880 additions and 7 deletions
+4
View File
@@ -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() {
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
<Route path="my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
<Route path="my-sessions" element={<RoleRoute roles={['staff']}><StaffTreatmentSessionsPage /></RoleRoute>} />
<Route path="my-sessions/:uuid" element={<RoleRoute roles={['staff']}><StaffSessionDetailPage /></RoleRoute>} />
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
@@ -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>
);
}
+24 -1
View File
@@ -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' ? 'فعال' : 'غیرفعال';
+2 -2
View File
@@ -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<ApiResponse<ResourceType>>('/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<ApiResponse<ResourceType>>(`/api/v1/resource-type/${uuid}`, d),
onSuccess: () => { toast.success('نوع منبع به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
+12 -4
View File
@@ -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<TreatmentFormField[]>([]);
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({
<span className="field-hint">همین نام در فهرست منابع و انتخابگرها دیده میشود.</span>
</Field>
<fieldset style={{ border: 0, padding: 0, margin: 0, display: 'grid', gap: 10 }}>
<legend style={{ fontSize: 13.5, fontWeight: 600, padding: 0 }}>فرم ثبت درمان</legend>
<FieldSchemaEditor value={fieldSchema} onChange={setFieldSchema} />
</fieldset>
<Switch
id="resource-type-active"
checked={active}
+3
View File
@@ -19,6 +19,7 @@ import { useUrlState } from '../hooks/useUrlState';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import ServiceCategoryTab from '../components/ServiceCategoryTab';
import TreatmentProtocolTab from '../components/TreatmentProtocolTab';
interface TenantInsurance {
uuid: string;
@@ -39,6 +40,7 @@ const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'treatment', label: 'طول درمان' },
{ id: 'goods', label: 'کالاهای مرتبط' },
{ id: 'history', label: 'لاگ تغییرات' },
] as const;
@@ -482,6 +484,7 @@ function ServiceDetailPageInner() {
canEdit={canUpdate}
/>
)}
{tab === 'treatment' && <TreatmentProtocolTab serviceUuid={item.uuid} canEdit={canUpdate} />}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
{tab === 'history' && <HistoryTab item={item} />}
@@ -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<ApiResponse<StaffSessionDetail>>(`${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<ApiResponse<unknown>>(`${BASE}/treatment-session/${uuid}/start`, {}),
onSuccess: () => { toast.success('جلسه شروع شد'); refresh(); },
onError: (e) => fail(e, 'شروع جلسه ناموفق بود'),
});
const finishSession = useMutation({
mutationFn: () => api.post<ApiResponse<{ unsettled_areas: number }>>(
`${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<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/skip`, {}),
onSuccess: () => { toast.success('این ناحیه صرف‌نظر شد'); refresh(); },
onError: (e) => fail(e, 'صرف‌نظر از ناحیه ناموفق بود'),
});
const completeArea = useMutation({
mutationFn: (payload: { areaUuid: string; body: Record<string, unknown> }) =>
api.post<ApiResponse<unknown>>(`${BASE}/session-area/${payload.areaUuid}/complete`, payload.body),
onSuccess: () => { toast.success('اطلاعات ناحیه ثبت شد'); refresh(); },
onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'),
});
if (isLoading) {
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
}
if (!session) {
return <div className="card card-pad" style={{ fontSize: 13 }}>جلسه یافت نشد</div>;
}
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 (
<>
<PageHeader
title={`جلسهٔ ${session.session_number} از ${session.total_sessions}`}
backTo="/admin/my-sessions"
/>
<div className="card card-pad" style={{ display: 'grid', gap: 10, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14 }}>{session.case.service.name}</strong>
<StatusBadge type="treatment-session" value={session.status} />
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
{session.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
<span>{settled} از {areas.length} ناحیه انجام شده</span>
</div>
{!started && !finished && (
<button
type="button"
className="btn primary"
onClick={() => startSession.mutate()}
disabled={startSession.isPending}
style={{ justifySelf: 'start' }}
>
{startSession.isPending ? 'در حال شروع...' : 'شروع جلسه'}
</button>
)}
</div>
<h2 style={{ fontSize: 14, margin: '0 0 10px' }}>نواحی این جلسه</h2>
<div style={{ display: 'grid', gap: 12 }}>
{areas.length === 0 && (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
برای دیدن نواحی، ابتدا جلسه را شروع کنید.
</div>
)}
{areas.map((area) => (
<AreaCard
key={area.uuid}
area={area}
forms={session.forms}
disabled={finished}
onSkip={() => skipArea.mutate(area.uuid)}
onComplete={(body) => completeArea.mutate({ areaUuid: area.uuid, body })}
saving={completeArea.isPending}
/>
))}
</div>
{started && !finished && (
<div className="card card-pad" style={{ display: 'grid', gap: 10, marginTop: 16 }}>
<h2 style={{ fontSize: 14, margin: 0 }}>یادداشت و اتمام جلسه</h2>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="یادداشت کلی جلسه (اختیاری)"
rows={3}
aria-label="یادداشت کلی جلسه"
/>
{settled < areas.length && (
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
{areas.length - settled} ناحیه هنوز تکمیل نشده است جلسه با همین وضعیت بسته میشود.
</span>
)}
<button
type="button"
className="btn primary"
onClick={() => finishSession.mutate()}
disabled={finishSession.isPending}
style={{ justifySelf: 'start' }}
>
{finishSession.isPending ? 'در حال ثبت...' : 'انجام شد'}
</button>
</div>
)}
</>
);
}
function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
area: SessionAreaRecord;
forms: Record<string, TreatmentFormField[]>;
disabled: boolean;
onSkip: () => void;
onComplete: (body: Record<string, unknown>) => void;
saving: boolean;
}) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [areaNote, setAreaNote] = useState('');
const resourceUuid = area.resource?.uuid ?? null;
const fields = resourceUuid ? forms[resourceUuid] ?? [] : [];
const settled = area.status === 'completed' || area.status === 'skipped';
const submit = () => {
const parameters: Record<string, string> = {};
fields.forEach((f) => {
if (values[f.key] !== undefined && values[f.key] !== '') parameters[f.key] = values[f.key];
});
onComplete({
resource_uuid: resourceUuid ?? undefined,
parameters,
note: areaNote || undefined,
});
};
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13.5 }}>{area.area.name}</strong>
<StatusBadge type="treatment-area" value={area.status} />
{area.resource && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>دستگاه: {area.resource.name}</span>
)}
</div>
{settled && area.parameters && (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5 }}>
{Object.entries(area.parameters).map(([key, value]) => (
<span key={key}>
{fields.find((f) => f.key === key)?.label ?? key}: <b>{String(value)}</b>
</span>
))}
</div>
)}
{settled && area.note && (
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>یادداشت: {area.note}</span>
)}
{!settled && !disabled && !open && (
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn primary sm" onClick={() => setOpen(true)}>
ثبت اطلاعات این ناحیه
</button>
<button type="button" className="btn secondary sm" onClick={onSkip}>
صرفنظر از این ناحیه
</button>
</div>
)}
{!settled && open && (
<div style={{ display: 'grid', gap: 10 }}>
{fields.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
برای این دستگاه فرمی تعریف نشده است. در «تنظیمات انواع منابع» میتوانید فیلدها را تعریف کنید.
</span>
)}
{fields.map((field) => (
<label key={field.key} className="field" style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: 12.5 }}>
{field.label}{field.required ? ' *' : ''}
</span>
{field.type === 'select' ? (
<SearchableSelect
options={(field.options ?? []).map((o) => ({ value: String(o), label: String(o) }))}
value={values[field.key] ?? null}
onChange={(v) => setValues((p) => ({ ...p, [field.key]: v === null ? '' : String(v) }))}
placeholder={`انتخاب ${field.label}`}
ariaLabel={field.label}
/>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={values[field.key] ?? ''}
onChange={(e) => setValues((p) => ({ ...p, [field.key]: e.target.value }))}
aria-label={field.label}
/>
)}
</label>
))}
<textarea
value={areaNote}
onChange={(e) => setAreaNote(e.target.value)}
placeholder="یادداشت این ناحیه (اختیاری)"
rows={2}
aria-label={`یادداشت ناحیهٔ ${area.area.name}`}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn primary sm" onClick={submit} disabled={saving}>
{saving ? 'در حال ثبت...' : 'اتمام این ناحیه'}
</button>
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)}>
انصراف
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,69 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ClipboardDocumentListIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge';
import { formatDate } from '../lib/utils';
import type { StaffTreatmentSession } from '../types';
/**
* «جلسات امروز من» — نقطهٔ ورود پرسنل به کار روز.
*
* فهرست از نوبتِ متصل به جلسه می‌آید نه از سررسید تخمینی: کارِ امروز چیزی است که
* برایش وقت گرفته شده.
*/
export default function StaffTreatmentSessionsPage() {
const { data, isLoading } = useQuery({
queryKey: ['staff-treatment-sessions'],
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/dashboard/staff/treatment-sessions'),
staleTime: 30_000,
});
const sessions = data?.data ?? [];
return (
<>
<PageHeader title="جلسات امروز من" />
{isLoading ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div className="card card-pad" style={{ display: 'grid', gap: 8, justifyItems: 'center', padding: 40 }}>
<ClipboardDocumentListIcon style={{ width: 40, height: 40, color: 'var(--text-3)' }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>امروز جلسهای برای شما ثبت نشده است</span>
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{sessions.map((s) => {
const settled = s.areas?.filter((a) => a.status === 'completed' || a.status === 'skipped').length ?? 0;
const total = s.areas?.length ?? 0;
return (
<div key={s.uuid} className="card card-pad" style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14 }}>{s.service_name}</strong>
<StatusBadge type="treatment-session" value={s.status} />
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
جلسهٔ {s.session_number} از {s.total_sessions}
</span>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
{s.appointment && <span>ساعت {new Date(s.appointment.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}</span>}
{s.appointment && <span>{formatDate(s.appointment.slot_start)}</span>}
{total > 0 && <span>{settled} از {total} ناحیه انجام شده</span>}
</div>
<Link to={`/admin/my-sessions/${s.uuid}`} className="btn primary sm" style={{ justifySelf: 'start' }}>
مشاهده جزئیات و انجام جلسه
</Link>
</div>
);
})}
</div>
)}
</>
);
}
+69
View File
@@ -958,6 +958,8 @@ export interface ResourceType {
/** نوع‌های doctor/staff/room را backfill می‌سازد و حذف نمی‌شوند */
is_system: boolean;
active: boolean;
/** فیلدهای فرم ثبت درمان برای این نوع منبع؛ `null` یعنی فرمی ندارد. */
field_schema: TreatmentFormField[] | null;
resources_count?: number;
created_at: number;
updated_at: number;
@@ -1267,3 +1269,70 @@ export interface CatalogCategory {
active: boolean;
children?: CatalogCategory[];
}
// ── Treatment ────────────────────────────────────────────────────────────────
export type TreatmentSessionStatus =
| 'planned' | 'booked' | 'in_progress' | 'done' | 'cancelled' | 'no_show';
export type SessionAreaStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
export interface SessionAreaRecord {
uuid: string;
area: { uuid: string; name: string };
status: SessionAreaStatus;
/** خوانده‌های دستگاه؛ کلیدهایش از `field_schema` همان نوع منبع می‌آید. */
parameters: Record<string, string | number> | null;
started_at: number | null;
finished_at: number | null;
note: string | null;
resource: { uuid: string; name: string; type: string } | null;
}
export interface TreatmentSessionSummary {
uuid: string;
session_number: number;
total_sessions: number;
status: TreatmentSessionStatus;
due_at: number | null;
started_at: number | null;
finished_at: number | null;
note: string | null;
appointment: { uuid: string; slot_start: number; slot_end: number; status: string } | null;
performed_by: { uuid: string; name: string } | null;
areas?: SessionAreaRecord[];
}
export interface StaffTreatmentSession extends TreatmentSessionSummary {
case_uuid: string;
service_name: string;
}
export interface TreatmentCaseSummary {
uuid: string;
status: 'active' | 'completed' | 'abandoned';
total_sessions: number;
completed_sessions: number;
opened_at: number;
closed_at: number | null;
service: { uuid: string; name: string };
supervisor: { uuid: string; name: string } | null;
areas: Array<{ uuid: string; name: string }>;
sessions?: TreatmentSessionSummary[];
}
/** تعریف یک فیلد فرم ثبت درمان، از `ResourceType.field_schema`. */
export interface TreatmentFormField {
key: string;
label: string;
type: 'select' | 'number' | 'text';
options?: Array<string | number>;
required?: boolean;
sort_order?: number;
}
export interface StaffSessionDetail extends TreatmentSessionSummary {
case: TreatmentCaseSummary;
/** uuid منبع => فیلدهای فرمش */
forms: Record<string, TreatmentFormField[]>;
}