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>
287 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|