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 { useAddresses } from '../hooks/useAddresses'; interface SegmentRequirementDraft { type_uuid: string; skill_uuid: string | null; count: number; occupancy: 'exclusive' | 'shared' | 'passive'; 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: 'اشتراکی — از ظرفیت منبع یکی کم می‌شود' }, // مثل انحصاری قفل می‌کند، ولی در گزارش «کار مفید» حساب نمی‌شود. { value: 'passive', 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 { addresses } = useAddresses(); const [segments, setSegments] = useState([]); const [branchUuid, setBranchUuid] = useState(''); const { data, isLoading } = useQuery({ queryKey: ['service-segments', serviceUuid], queryFn: () => api.get>(`/api/v1/service-item/${serviceUuid}/segments`), enabled: !!serviceUuid, }); const { data: typesData } = useQuery({ queryKey: ['resource-types-for-segments'], queryFn: () => api.get>('/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>( `/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>('/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) => setSegments(segments.map((s, i) => (i === index ? { ...s, ...changes } : s))); const patchRequirement = ( segmentIndex: number, reqIndex: number, changes: Partial, ) => setSegments( segments.map((s, i) => i === segmentIndex ? { ...s, requirements: s.requirements.map((r, j) => (j === reqIndex ? { ...r, ...changes } : r)) } : s, ), ); return (

بخش‌های نوبت

سرویس بدون بخش، یک نوبت پیوسته است که پزشک را می‌گیرد — همان رفتار امروز.
{isLoading ? ( در حال بارگذاری… ) : ( segments.map((segment, index) => (
patch(index, { name: e.target.value })} />
patch(index, { duration_source: (v as 'fixed' | 'items') ?? 'fixed' })} options={DURATION_OPTIONS} isDisabled={!canEdit} />
{segment.duration_source === 'fixed' && ( )} {canEdit && ( )}
{/* ── نیازمندی‌های منبع ─────────────────────────────────────── */}
{segment.requirements.map((requirement, reqIndex) => (
patchRequirement(index, reqIndex, { type_uuid: String(v ?? '') })} options={types.map((t) => ({ value: t.uuid, label: t.name }))} placeholder="نقش منبع" isDisabled={!canEdit} />
patchRequirement(index, reqIndex, { occupancy: (v as 'exclusive' | 'shared' | 'passive') ?? 'exclusive', }) } options={OCCUPANCY_OPTIONS} isDisabled={!canEdit} />
{canEdit && ( )}
))} {canEdit && (
)}
)) )} {canEdit && (
)}
{/* ── نوار پیش‌نمایش زمانی ──────────────────────────────────────────── */}
setBranchUuid(String(v ?? ''))} options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} placeholder="انتخاب شعبه" />
منابع واجد شرایط از همان شعبه خوانده می‌شوند، پس پیش‌نمایش بدون شعبه معنا ندارد.
{previewError && (
{previewError instanceof ApiError ? previewError.message : 'پیش‌نمایش ساخته نشد'} {/* خطای «هیچ منبعی نیست» بدون راه اصلاح، فقط بن‌بست است. */} افزودن منبع به این شعبه
)} {preview && ( <>
مدت کل نوبت: {preview.total_minutes} دقیقه بیمار واقعاً درگیر: {patientFacingMinutes} دقیقه
{preview.segments.map((segment) => (
{segment.name} {segment.duration_minutes} دقیقه {segment.requirements.length === 0 ? 'بدون منبع' : segment.requirements.map((r) => r.role_name).join('، ')}
))}
بخش‌های کم‌رنگ آن‌هایی‌اند که بیمار حاضر نیست — منبع گرفته می‌شود ولی کاری روی بیمار انجام نمی‌شود. )}
); }