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:
@@ -18,6 +18,8 @@ interface ProtocolStaff {
|
||||
|
||||
interface TreatmentProtocol {
|
||||
uuid: string;
|
||||
/** آیا هیچ منبعی این سرویس را ارائه میدهد؛ نبودش رزرو را قفل نمیکند ولی باید دیده شود. */
|
||||
service_has_resources?: boolean;
|
||||
active: boolean;
|
||||
total_sessions: number;
|
||||
supervisor: { uuid: string; name: string } | null;
|
||||
@@ -158,6 +160,19 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
|
||||
hint="سرویسهایی که در چند جلسه انجام میشوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تکجلسهای."
|
||||
/>
|
||||
|
||||
{enabled && protocol !== null && protocol.service_has_resources === false && (
|
||||
<div
|
||||
className="card card-pad"
|
||||
style={{ background: 'var(--warning-bg)', display: 'grid', gap: 4 }}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
|
||||
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
|
||||
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<section style={{ display: 'grid', gap: 10 }}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* مدت سپریشده از یک لحظه، بهصورت زنده.
|
||||
*
|
||||
* `startedAt` تایماستمپ **سرور** است و مبنا همان میماند؛ اینجا فقط هر ثانیه دوباره
|
||||
* رندر میشود. ساعت مرورگر ممکن است چند ثانیه جلو یا عقب باشد، ولی زمانِ ثبتشده
|
||||
* همان است که سرور نوشته — این عدد فقط برای دیدن است، نه برای ذخیره.
|
||||
*
|
||||
* `null` یعنی هنوز شروع نشده؛ `finishedAt` که بیاید تایمر میایستد.
|
||||
*/
|
||||
export function useElapsed(startedAt: number | null, finishedAt: number | null = null): string | null {
|
||||
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
|
||||
|
||||
const running = startedAt !== null && finishedAt === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
|
||||
const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [running]);
|
||||
|
||||
if (startedAt === null) return null;
|
||||
|
||||
const seconds = Math.max(0, (finishedAt ?? now) - startedAt);
|
||||
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
/** «۰۵:۳۲» یا «۱:۱۲:۰۴» — ساعت فقط وقتی واقعاً از یک ساعت گذشته باشد. */
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@@ -7,7 +7,8 @@ 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';
|
||||
import { useElapsed } from '../hooks/useElapsed';
|
||||
import type { SessionAreaRecord, StaffSessionDetail, TreatmentDevice, TreatmentFormField } from '../types';
|
||||
|
||||
const BASE = '/api/v1/dashboard/staff';
|
||||
|
||||
@@ -70,6 +71,9 @@ export default function StaffSessionDetailPage() {
|
||||
onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'),
|
||||
});
|
||||
|
||||
// پیش از هر return زودهنگام: ترتیب hookها باید در هر رندر یکی باشد.
|
||||
const elapsed = useElapsed(session?.started_at ?? null, session?.finished_at ?? null);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
@@ -100,6 +104,11 @@ export default function StaffSessionDetailPage() {
|
||||
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
|
||||
{session.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
|
||||
<span>{settled} از {areas.length} ناحیه انجام شده</span>
|
||||
{elapsed && (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!started && !finished && (
|
||||
@@ -128,6 +137,7 @@ export default function StaffSessionDetailPage() {
|
||||
<AreaCard
|
||||
key={area.uuid}
|
||||
area={area}
|
||||
devices={session.devices}
|
||||
forms={session.forms}
|
||||
disabled={finished}
|
||||
onSkip={() => skipArea.mutate(area.uuid)}
|
||||
@@ -167,20 +177,25 @@ export default function StaffSessionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
|
||||
function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }: {
|
||||
area: SessionAreaRecord;
|
||||
devices: TreatmentDevice[];
|
||||
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 [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [areaNote, setAreaNote] = useState('');
|
||||
// پیشفرض همان دستگاهی است که از نوبت به ارث رسیده؛ اپراتور فقط اگر لازم شد عوضش میکند.
|
||||
const [resourceUuid, setResourceUuid] = useState<string | null>(area.resource?.uuid ?? null);
|
||||
const fields = resourceUuid ? forms[resourceUuid] ?? [] : [];
|
||||
const settled = area.status === 'completed' || area.status === 'skipped';
|
||||
const elapsed = useElapsed(area.started_at, area.finished_at);
|
||||
|
||||
useEffect(() => setResourceUuid(area.resource?.uuid ?? null), [area.resource?.uuid]);
|
||||
|
||||
const submit = () => {
|
||||
const parameters: Record<string, string> = {};
|
||||
@@ -203,6 +218,11 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
|
||||
{area.resource && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>دستگاه: {area.resource.name}</span>
|
||||
)}
|
||||
{elapsed && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{area.finished_at === null ? 'در حال انجام: ' : 'مدت: '}{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settled && area.parameters && (
|
||||
@@ -232,7 +252,22 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
|
||||
|
||||
{!settled && open && (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{fields.length === 0 && (
|
||||
<label className="field" style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 12.5 }}>دستگاه</span>
|
||||
<SearchableSelect
|
||||
options={devices.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||
value={resourceUuid}
|
||||
onChange={(v) => setResourceUuid(v === null ? null : String(v))}
|
||||
placeholder="بدون دستگاه"
|
||||
isClearable
|
||||
ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`}
|
||||
/>
|
||||
<span className="field-hint">
|
||||
پیشفرض همان دستگاه نوبت است. فرم زیر از روی همین دستگاه ساخته میشود.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{resourceUuid !== null && fields.length === 0 && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
برای این دستگاه فرمی تعریف نشده است. در «تنظیمات ← انواع منابع» میتوانید فیلدها را تعریف کنید.
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1342,8 +1342,27 @@ export interface TreatmentFormField {
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/** وقتهای آزادِ پیشنهادی برای جلسهٔ بعد. */
|
||||
export interface SlotSuggestionResponse {
|
||||
resource_uuid: string;
|
||||
from: number;
|
||||
days: Array<{
|
||||
date: string;
|
||||
slots: Array<{ start: number; end: number; start_time: string; end_time: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** دستگاهی که اپراتور میتواند برای یک ناحیه انتخاب کند. */
|
||||
export interface TreatmentDevice {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StaffSessionDetail extends TreatmentSessionSummary {
|
||||
case: TreatmentCaseSummary;
|
||||
/** دستگاههای فعالِ همین محیط — ناحیهها میتوانند دستگاه متفاوت داشته باشند. */
|
||||
devices: TreatmentDevice[];
|
||||
/** uuid منبع => فیلدهای فرمش */
|
||||
forms: Record<string, TreatmentFormField[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user