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
+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>
)}
</>
);
}