fix(treatment): let the operator actually record an area

Starting a session created area records with no device, and the panel only ever
read the device it never set — so every "اتمام این ناحیه" came back 422 with
"دستگاه این ناحیه مشخص نیست". The backend tests passed because they sent
resource_uuid explicitly; from the UI the flow was unusable end to end.

The device now inherits from the appointment's resource, which the secretary
already chose at booking; asking the operator again is taking one decision
twice. The session screen offers a picker per area on top of that, because one
session really does run bikini on an alexandrite and underarms on a diode.

Treating without a device is allowed: botox is an injection, and requiring a
device would make clinics invent a fake resource per injection. Sending readings
with no device is still rejected — there would be no schema to validate against.

A protocol whose service has no ResourceServiceOffering rows now says so in the
tab where the manager is standing. It does not block booking: "no offering means
any resource" is a deliberate, tested rule. But silence meant the gap surfaced
only when the operator was already in front of a patient.

Also adds the live timer the spec asked for, and wires slot-suggestions into the
unbooked queue — the endpoint existed and tested green but no screen called it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 11:08:48 +03:30
co-authored by Claude Opus 5
parent c7fdb92df4
commit a63de2a52c
11 changed files with 412 additions and 37 deletions
+76 -5
View File
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
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 { formatDate, formatDateTime } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import type { TreatmentCaseSummary, StaffTreatmentSession } from '../types';
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
const TABS = [
{ id: 'cases', label: 'پرونده‌های درمان' },
@@ -161,9 +162,7 @@ function UnbookedTab() {
</span>
)}
<Link to="/admin/appointments/new" className="btn primary sm" style={{ justifySelf: 'start' }}>
ثبت نوبت این جلسه
</Link>
<SlotSuggestions sessionUuid={s.uuid} />
</div>
))}
</div>
@@ -171,3 +170,75 @@ function UnbookedTab() {
</>
);
}
/**
* وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد.
*
* پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش
* می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید.
*/
function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ['session-slot-suggestions', sessionUuid],
queryFn: () => api.get<ApiResponse<SlotSuggestionResponse>>(
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
),
enabled: open,
staleTime: 60_000,
});
if (!open) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
پیشنهاد وقت
</button>
<Link to="/admin/appointments/new" className="btn primary sm">ثبت نوبت این جلسه</Link>
</div>
);
}
const days = data?.data?.days ?? [];
return (
<div style={{ display: 'grid', gap: 8 }}>
{isLoading && <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال جستوجوی وقت...</span>}
{isError && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ دستگاهی انجام نشده.
</span>
)}
{!isLoading && !isError && days.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست.
</span>
)}
{days.slice(0, 3).map((day) => (
<div key={day.date} style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 12.5, minWidth: 96, color: 'var(--text-2)' }}>
{formatDate(Math.floor(new Date(day.date).getTime() / 1000))}
</span>
{day.slots.slice(0, 6).map((slot) => (
<Link
key={slot.start}
to={`/admin/appointments/new?slot_start=${slot.start}&resource_uuid=${data?.data?.resource_uuid ?? ''}`}
className="btn secondary sm"
title={formatDateTime(slot.start)}
>
{slot.start_time}
</Link>
))}
</div>
))}
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)} style={{ justifySelf: 'start' }}>
بستن
</button>
</div>
);
}