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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user