Tasks 04 and 05 shipped working engines that a clinic could only reach through the API. Both now have the panel that makes them usable. Groups tab - Inline min/max per group, saved on blur, with the meaning of an empty maximum spelled out next to the field rather than left as folklore - Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the server's own message, which is more precise than anything generic - A live preview that calls the same service-selection/validate the public site calls, debounced 400ms. Two separate calculations would eventually show the operator and the patient different numbers - The breakdown table shows which item was counted as the anchor and which as additional, so a surprising total explains itself Segments tab - Sequence, duration source, patient-present and mergeable per segment, plus resource requirements with an explanation attached to each occupancy mode - A timeline bar whose widths are proportional to duration, with segments the patient is absent for drawn faded. That contrast is the whole point of task 05: the waiting segment holds the room but frees the operator - "No eligible resource" renders with a link to add one — an error with no route forward is a dead end Task 05's checklist had been left on "not started" this whole time even though its code shipped with the task; it is now filled in against reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import SearchableSelect from './ui/SearchableSelect';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
|
|
interface SegmentRequirementDraft {
|
|
type_uuid: string;
|
|
skill_uuid: string | null;
|
|
count: number;
|
|
occupancy: 'exclusive' | 'shared';
|
|
constraints: string[];
|
|
}
|
|
|
|
interface SegmentDraft {
|
|
sequence: number;
|
|
name: string;
|
|
duration_source: 'fixed' | 'items';
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
mergeable: boolean;
|
|
requirements: SegmentRequirementDraft[];
|
|
}
|
|
|
|
interface PlanPreviewSegment {
|
|
sequence: number;
|
|
name: string;
|
|
offset_minutes: number;
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
requirements: { role_name: string; count: number; candidates?: number }[];
|
|
}
|
|
|
|
interface PlanPreview {
|
|
total_minutes: number;
|
|
segments: PlanPreviewSegment[];
|
|
}
|
|
|
|
interface ResourceType {
|
|
uuid: string;
|
|
code: string;
|
|
name: string;
|
|
}
|
|
|
|
const OCCUPANCY_OPTIONS = [
|
|
{ value: 'exclusive', label: 'انحصاری — منبع کامل قفل میشود' },
|
|
{ value: 'shared', label: 'اشتراکی — از ظرفیت منبع یکی کم میشود' },
|
|
];
|
|
|
|
const DURATION_OPTIONS = [
|
|
{ value: 'fixed', label: 'مدت ثابت' },
|
|
{ value: 'items', label: 'از آیتمهای انتخابی' },
|
|
];
|
|
|
|
interface Props {
|
|
serviceUuid: string;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
/**
|
|
* بخشهای نوبت — بخش UI تسک ۰۵.
|
|
*
|
|
* نوار پیشنمایش تنها جایی است که تفاوت «مدت نوبت» و «زمانی که منبع واقعاً درگیر است»
|
|
* دیده میشود؛ همان تفاوتی که کل تسک ۰۵ برایش وجود دارد: «انتظار اثر کرم» اتاق را
|
|
* میگیرد ولی اپراتور را آزاد میگذارد.
|
|
*/
|
|
export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
|
|
const qc = useQueryClient();
|
|
const { branches } = useBranches();
|
|
|
|
const [segments, setSegments] = useState<SegmentDraft[]>([]);
|
|
const [branchUuid, setBranchUuid] = useState('');
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['service-segments', serviceUuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<{ segments: SegmentDraft[] }>>(`/api/v1/service-item/${serviceUuid}/segments`),
|
|
enabled: !!serviceUuid,
|
|
});
|
|
|
|
const { data: typesData } = useQuery({
|
|
queryKey: ['resource-types-for-segments'],
|
|
queryFn: () => api.get<ApiResponse<ResourceType[]>>('/api/v1/resource-types'),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (data?.data?.segments) setSegments(data.data.segments);
|
|
}, [data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: (body: SegmentDraft[]) =>
|
|
api.put<ApiResponse<{ segments: SegmentDraft[] }>>(
|
|
`/api/v1/service-item/${serviceUuid}/segments`,
|
|
{ segments: body },
|
|
),
|
|
onSuccess: () => {
|
|
toast.success('بخشها ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: ['service-segments', serviceUuid] });
|
|
qc.invalidateQueries({ queryKey: ['segments-preview', serviceUuid] });
|
|
},
|
|
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ بخشها ناموفق بود'),
|
|
});
|
|
|
|
const { data: previewData, error: previewError } = useQuery({
|
|
queryKey: ['segments-preview', serviceUuid, branchUuid],
|
|
queryFn: () =>
|
|
api.post<ApiResponse<PlanPreview>>('/api/v1/appointment-plan/preview', {
|
|
service_uuid: serviceUuid,
|
|
branch_uuid: branchUuid,
|
|
}),
|
|
enabled: !!branchUuid,
|
|
retry: false,
|
|
});
|
|
|
|
const preview = previewData?.data;
|
|
const types = typesData?.data ?? [];
|
|
|
|
// «اپراتور واقعاً درگیر» — مجموع بخشهایی که بیمار حاضر است.
|
|
const patientFacingMinutes =
|
|
preview?.segments.filter((s) => s.patient_present).reduce((sum, s) => sum + s.duration_minutes, 0) ?? 0;
|
|
|
|
const patch = (index: number, changes: Partial<SegmentDraft>) =>
|
|
setSegments(segments.map((s, i) => (i === index ? { ...s, ...changes } : s)));
|
|
|
|
const patchRequirement = (
|
|
segmentIndex: number,
|
|
reqIndex: number,
|
|
changes: Partial<SegmentRequirementDraft>,
|
|
) =>
|
|
setSegments(
|
|
segments.map((s, i) =>
|
|
i === segmentIndex
|
|
? { ...s, requirements: s.requirements.map((r, j) => (j === reqIndex ? { ...r, ...changes } : r)) }
|
|
: s,
|
|
),
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
<h3 style={{ fontSize: 15, margin: 0 }}>بخشهای نوبت</h3>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
سرویس بدون بخش، یک نوبت پیوسته است که پزشک را میگیرد — همان رفتار امروز.
|
|
</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>
|
|
) : (
|
|
segments.map((segment, index) => (
|
|
<div
|
|
key={index}
|
|
style={{
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)',
|
|
padding: 14,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
ترتیب
|
|
<input
|
|
className="input"
|
|
style={{ width: 70 }}
|
|
type="number"
|
|
min={1}
|
|
value={segment.sequence}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { sequence: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
|
|
<input
|
|
className="input"
|
|
style={{ maxWidth: 220 }}
|
|
value={segment.name}
|
|
placeholder="نام بخش"
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { name: e.target.value })}
|
|
/>
|
|
|
|
<div style={{ minWidth: 180 }}>
|
|
<SearchableSelect
|
|
value={segment.duration_source}
|
|
onChange={(v) => patch(index, { duration_source: (v as 'fixed' | 'items') ?? 'fixed' })}
|
|
options={DURATION_OPTIONS}
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
{segment.duration_source === 'fixed' && (
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
دقیقه
|
|
<input
|
|
className="input"
|
|
style={{ width: 90 }}
|
|
type="number"
|
|
min={1}
|
|
value={segment.duration_minutes}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { duration_minutes: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
style={{ marginRight: 'auto' }}
|
|
onClick={() => setSegments(segments.filter((_, i) => i !== index))}
|
|
aria-label="حذف بخش"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={segment.patient_present}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { patient_present: e.target.checked })}
|
|
/>
|
|
بیمار حاضر است
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={segment.mergeable}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { mergeable: e.target.checked })}
|
|
/>
|
|
با چند آیتم فقط یک بار بیاید
|
|
</label>
|
|
</div>
|
|
|
|
{/* ── نیازمندیهای منبع ─────────────────────────────────────── */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{segment.requirements.map((requirement, reqIndex) => (
|
|
<div key={reqIndex} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<div style={{ minWidth: 170 }}>
|
|
<SearchableSelect
|
|
value={requirement.type_uuid}
|
|
onChange={(v) => patchRequirement(index, reqIndex, { type_uuid: String(v ?? '') })}
|
|
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
|
placeholder="نقش منبع"
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
تعداد
|
|
<input
|
|
className="input"
|
|
style={{ width: 70 }}
|
|
type="number"
|
|
min={1}
|
|
value={requirement.count}
|
|
disabled={!canEdit}
|
|
onChange={(e) =>
|
|
patchRequirement(index, reqIndex, { count: Number(e.target.value) })
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
<div style={{ minWidth: 250 }}>
|
|
<SearchableSelect
|
|
value={requirement.occupancy}
|
|
onChange={(v) =>
|
|
patchRequirement(index, reqIndex, {
|
|
occupancy: (v as 'exclusive' | 'shared') ?? 'exclusive',
|
|
})
|
|
}
|
|
options={OCCUPANCY_OPTIONS}
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={requirement.constraints.includes('same_gender_as_patient')}
|
|
disabled={!canEdit}
|
|
onChange={(e) =>
|
|
patchRequirement(index, reqIndex, {
|
|
constraints: e.target.checked ? ['same_gender_as_patient'] : [],
|
|
})
|
|
}
|
|
/>
|
|
همجنس بیمار
|
|
</label>
|
|
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
patch(index, {
|
|
requirements: segment.requirements.filter((_, j) => j !== reqIndex),
|
|
})
|
|
}
|
|
aria-label="حذف نیازمندی"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{canEdit && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
patch(index, {
|
|
requirements: [
|
|
...segment.requirements,
|
|
{ type_uuid: '', skill_uuid: null, count: 1, occupancy: 'exclusive', constraints: [] },
|
|
],
|
|
})
|
|
}
|
|
>
|
|
<PlusIcon style={{ width: 15 }} /> افزودن نیازمندی منبع
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{canEdit && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
setSegments([
|
|
...segments,
|
|
{
|
|
sequence: segments.length + 1,
|
|
name: '',
|
|
duration_source: 'fixed',
|
|
duration_minutes: 15,
|
|
patient_present: true,
|
|
mergeable: false,
|
|
requirements: [],
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<PlusIcon style={{ width: 15 }} /> افزودن بخش
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
disabled={save.isPending || segments.some((s) => s.name.trim() === '')}
|
|
onClick={() => save.mutate(segments)}
|
|
>
|
|
ذخیرهٔ بخشها
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── نوار پیشنمایش زمانی ──────────────────────────────────────────── */}
|
|
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
|
|
<div className="field" style={{ minWidth: 220, margin: 0 }}>
|
|
<label>شعبه برای پیشنمایش</label>
|
|
<SearchableSelect
|
|
value={branchUuid}
|
|
onChange={(v) => setBranchUuid(String(v ?? ''))}
|
|
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
|
placeholder="انتخاب شعبه"
|
|
/>
|
|
</div>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
منابع واجد شرایط از همان شعبه خوانده میشوند، پس پیشنمایش بدون شعبه معنا ندارد.
|
|
</span>
|
|
</div>
|
|
|
|
{previewError && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<span style={{ fontSize: 13, color: 'var(--danger)' }}>
|
|
{previewError instanceof ApiError ? previewError.message : 'پیشنمایش ساخته نشد'}
|
|
</span>
|
|
{/* خطای «هیچ منبعی نیست» بدون راه اصلاح، فقط بنبست است. */}
|
|
<Link className="btn secondary sm" to="/admin/resources" style={{ alignSelf: 'flex-start' }}>
|
|
افزودن منبع به این شعبه
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{preview && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
|
<span>
|
|
مدت کل نوبت: <strong>{preview.total_minutes}</strong> دقیقه
|
|
</span>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
بیمار واقعاً درگیر: {patientFacingMinutes} دقیقه
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<div style={{ display: 'flex', minWidth: 420, gap: 2 }}>
|
|
{preview.segments.map((segment) => (
|
|
<div
|
|
key={segment.sequence}
|
|
title={`${segment.name} — ${segment.duration_minutes} دقیقه`}
|
|
style={{
|
|
// عرض متناسب مدت: بخش سیدقیقهای باید شش برابر بخش پنجدقیقهای دیده شود.
|
|
flex: `${Math.max(1, segment.duration_minutes)} 0 0`,
|
|
minWidth: 60,
|
|
padding: '10px 8px',
|
|
borderRadius: 'var(--r-sm)',
|
|
background: segment.patient_present ? 'var(--primary-soft)' : 'var(--surface-2)',
|
|
border: '1px solid var(--border)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 12, fontWeight: 600 }}>{segment.name}</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
|
{segment.duration_minutes} دقیقه
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-2)' }}>
|
|
{segment.requirements.length === 0
|
|
? 'بدون منبع'
|
|
: segment.requirements.map((r) => r.role_name).join('، ')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
بخشهای کمرنگ آنهاییاند که بیمار حاضر نیست — منبع گرفته میشود ولی کاری
|
|
روی بیمار انجام نمیشود.
|
|
</span>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|