Files
clinicpro/assets/admin/pages/StaffSessionDetailPage.tsx
T
hamedandClaude Opus 5 d8aabe5d0a 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>
2026-08-06 18:29:49 +03:30

287 lines
11 KiB
TypeScript

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