feat(admin): 'next appointments' tab in the patient file
A patient's file had no view of their multi-session courses: which ones they have, when the remaining sessions fall, or what was recorded in the sessions already done. All of it lived on a tenant-wide page. The tab lists the patient's courses and, per course, a card for every session with its date, its status, and — for finished ones — the areas treated with the device readings a staff member logged. Estimated dates are labelled as such, so a projection is never read as a booking. Each unbooked session carries a button that opens the same NewAppointmentModal used elsewhere, seeded with that session's date, and now binds the resulting appointment to that exact session via a new treatmentSessionUuid prop — a patient can have several open courses, and without it the attachment falls back to guessing from the service. The modal opens in resource mode, not doctor mode: a course's service is booked against the device's calendar, so useDoctorBookingServices returns nothing for the supervising doctor and the picker would render 'no bookable services'. The plan response now carries the course's device for exactly this. A course with no device yet says so instead of offering a button that cannot work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export interface BookingResource {
|
||||
*/
|
||||
export default function NewAppointmentModal({
|
||||
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
|
||||
treatmentSessionUuid = null,
|
||||
}: {
|
||||
slot: BookingSlot;
|
||||
onClose: () => void;
|
||||
@@ -51,6 +52,11 @@ export default function NewAppointmentModal({
|
||||
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
||||
clinicUuid?: string | null;
|
||||
resource?: BookingResource | null;
|
||||
/**
|
||||
* رزروِ صریحِ یک جلسهٔ درمان. بیمار میتواند چند دورهٔ باز داشته باشد، پس بدون این،
|
||||
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
||||
*/
|
||||
treatmentSessionUuid?: string | null;
|
||||
}) {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
||||
@@ -158,6 +164,7 @@ export default function NewAppointmentModal({
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||||
...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}),
|
||||
...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
||||
// منبع: مدت را سرور از سرویسهای همین منبع میسازد، پس ساعت پایان حدس نیست.
|
||||
...(resource ? {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon, ClipboardDocumentListIcon, CpuChipIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import StatusBadge from '../ui/StatusBadge';
|
||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||
import { useClinicContext } from '../../hooks/useClinicContext';
|
||||
import { formatDateTime, formatNumber } from '../../lib/utils';
|
||||
import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types';
|
||||
|
||||
interface PlanSession {
|
||||
uuid: string;
|
||||
session_number: number;
|
||||
total_sessions: number;
|
||||
status: string;
|
||||
planned_at: number | null;
|
||||
is_estimate: boolean;
|
||||
appointment: { uuid: string; slot_start: number } | null;
|
||||
performed_by: { uuid: string; name: string } | null;
|
||||
areas?: SessionAreaRecord[];
|
||||
}
|
||||
|
||||
interface PlanResponse {
|
||||
case: TreatmentCaseSummary;
|
||||
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
|
||||
resource: { uuid: string; name: string } | null;
|
||||
sessions: PlanSession[];
|
||||
}
|
||||
|
||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
active: 'در جریان',
|
||||
completed: 'تمام شده',
|
||||
abandoned: 'رها شده',
|
||||
};
|
||||
|
||||
/** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال میخواهد. */
|
||||
function isoDay(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* درمانهای چندجلسهایِ همین بیمار، با تقویم کل دوره.
|
||||
*
|
||||
* تاریخها از `/plan` میآیند و بخشیشان تخمینیاند — همانجا برچسب میخورند تا با
|
||||
* نوبتِ ثبتشده اشتباه گرفته نشوند. کارت نوبت نیست؛ دکمهاش نوبت میسازد.
|
||||
*/
|
||||
export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) {
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['patient-treatment-cases', recordUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`,
|
||||
),
|
||||
enabled: recordUuid !== '',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const cases = data?.data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, padding: '16px 0' }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'center', padding: 32 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن درمانهای این بیمار ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cases.length === 0) {
|
||||
return (
|
||||
<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)', textAlign: 'center', lineHeight: 1.9 }}>
|
||||
این بیمار درمان چندجلسهای ندارد.
|
||||
<br />
|
||||
پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
|
||||
{cases.map((c) => <CasePlan key={c.uuid} summary={c} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
|
||||
// دورهٔ باز پیشفرض باز است و دورهٔ بسته جمع — کارِ پیشِ رو مهمتر از سابقه است.
|
||||
const [open, setOpen] = useState(summary.status === 'active');
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case-plan', summary.uuid],
|
||||
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const sessions = data?.data?.sessions ?? [];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', width: '100%',
|
||||
padding: 'var(--card-pad)', background: 'none', border: 'none', cursor: 'pointer',
|
||||
font: 'inherit', color: 'var(--text)', textAlign: 'start',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 14 }}>{summary.service.name}</strong>
|
||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
|
||||
</span>
|
||||
{summary.supervisor && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پزشک ناظر: {summary.supervisor.name}</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 16, height: 16, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: '0 var(--card-pad) var(--card-pad)' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال خواندن تقویم دوره…</div>
|
||||
) : isError ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن تقویم این دوره ناموفق بود.</span>
|
||||
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.uuid} session={s} summary={summary} resource={data?.data?.resource ?? null} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({ session: s, summary, resource }: {
|
||||
session: PlanSession;
|
||||
summary: TreatmentCaseSummary;
|
||||
resource: { uuid: string; name: string } | null;
|
||||
}) {
|
||||
const [booking, setBooking] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const clinicUuid = useClinicContext();
|
||||
|
||||
const supervisor = summary.supervisor;
|
||||
// سرویسها از تقویم منبع میآیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو
|
||||
// میشود و `useDoctorBookingServices` برای این پزشک چیزی برنمیگرداند.
|
||||
const { services } = useResourceBookingServices(booking ? resource?.uuid : null);
|
||||
|
||||
const settled = ['done', 'cancelled', 'no_show'].includes(s.status);
|
||||
const bookable = !settled && s.appointment === null && summary.status === 'active';
|
||||
const areas = s.areas ?? [];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gap: 8, padding: '12px 14px',
|
||||
borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 13.5 }}>
|
||||
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
|
||||
</strong>
|
||||
<StatusBadge type="treatment-session" value={s.status} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
{s.planned_at === null ? '—' : formatDateTime(s.planned_at)}
|
||||
</span>
|
||||
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */}
|
||||
{s.is_estimate && (
|
||||
<span className="badge gray" style={{ fontSize: 11 }}>تخمینی</span>
|
||||
)}
|
||||
{s.performed_by && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرسنل: {s.performed_by.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خواندههایش. */}
|
||||
{areas.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{areas.map((a) => (
|
||||
<div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}>
|
||||
<span style={{ fontWeight: 600 }}>{a.area.name}</span>
|
||||
<StatusBadge type="treatment-area" value={a.status} />
|
||||
{a.resource && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-3)' }}>
|
||||
<CpuChipIcon style={{ width: 13, height: 13 }} />
|
||||
{a.resource.name}
|
||||
</span>
|
||||
)}
|
||||
{a.parameters && Object.entries(a.parameters).map(([k, v]) => (
|
||||
<span key={k} style={{ color: 'var(--text-3)' }}>{k}: <b style={{ color: 'var(--text-2)' }}>{String(v)}</b></span>
|
||||
))}
|
||||
{a.note && <span style={{ color: 'var(--text-3)' }}>یادداشت: {a.note}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookable && (
|
||||
/* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بیتوضیح بدتر از
|
||||
نبودنش است، پس دلیلش نوشته میشود. */
|
||||
resource === null ? (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
||||
هنوز هیچ جلسهای از این دوره روی دستگاهی انجام نشده — نوبت را از صفحهٔ نوبتها ثبت کنید.
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
style={{ justifySelf: 'start' }}
|
||||
onClick={() => setBooking(true)}
|
||||
>
|
||||
ثبت نوبت این جلسه
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{booking && resource && (
|
||||
<NewAppointmentModal
|
||||
slot={{
|
||||
start: 0, end: 0, start_time: '', end_time: '',
|
||||
doctor_uuid: supervisor?.uuid ?? '', doctor_name: supervisor?.name ?? '',
|
||||
}}
|
||||
resource={resource}
|
||||
services={services}
|
||||
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
|
||||
clinicUuid={clinicUuid}
|
||||
treatmentSessionUuid={s.uuid}
|
||||
onClose={() => setBooking(false)}
|
||||
onSuccess={() => {
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user