import React, { useEffect, useMemo, useState } from 'react'; import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import SearchableSelect from './ui/SearchableSelect'; import ConfirmDialog from './ui/ConfirmDialog'; import { formatRial } from '../lib/utils'; import { useAllServiceItems, useSelectionPreview, useServiceGroups, useServiceRelations, type ItemRelation, } from '../hooks/useServiceCatalog'; interface Props { serviceUuid: string; canEdit: boolean; /** روابط فعلیِ سرویس از پاسخ جزئیات — `PUT` جایگزینی کامل است. */ initialRelations?: ItemRelation[]; } const RELATION_LABELS: Record = { incompatible_with: 'با هم انجام نمی‌شوند', requires: 'پیش‌نیاز دارد', }; /** * گروه‌های انتخاب و روابط آیتم‌ها — بخش UI تسک ۰۴. * * پیش‌نمایش مدت و قیمت از **همان** اندپوینتی می‌آید که سایت عمومی می‌زند * (`service-selection/validate`)، نه از یک جمع‌زدن جداگانه در فرانت: دو محاسبه یعنی * بالاخره روزی دو عدد متفاوت به اپراتور و بیمار نشان داده شود. */ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelations = [] }: Props) { const { groups, loading, create, update, remove, setItems } = useServiceGroups(serviceUuid); const { save: saveRelations } = useServiceRelations(serviceUuid); const { items } = useAllServiceItems(); const [newGroupName, setNewGroupName] = useState(''); const [deleting, setDeleting] = useState(null); const [relations, setRelations] = useState(initialRelations); // ── پیش‌نمایش زنده با debounce ─────────────────────────────────────────── // بدون تأخیر، هر کلیک روی چیپ یک درخواست می‌سازد و اپراتورِ سریع، ده درخواست. const [selected, setSelected] = useState([]); const [debounced, setDebounced] = useState([]); useEffect(() => { const timer = setTimeout(() => setDebounced(selected), 400); return () => clearTimeout(timer); }, [selected]); const { preview, loading: previewing } = useSelectionPreview(debounced, serviceUuid); const itemOptions = useMemo( () => items.map((i) => ({ value: i.uuid, label: i.name })), [items], ); const nameOf = (uuid: string) => items.find((i) => i.uuid === uuid)?.name ?? uuid; return (
{/* ── گروه‌ها ───────────────────────────────────────────────────────── */}

گروه‌های انتخاب

«حتماً یک سطح انرژی، فقط یکی» یعنی حداقل ۱ و حداکثر ۱. حداکثرِ خالی یعنی نامحدود.
{canEdit && (
setNewGroupName(e.target.value)} placeholder="مثلاً: نواحی بدن" />
)} {loading ? ( در حال بارگذاری… ) : groups.length === 0 ? ( این سرویس گروه انتخابی ندارد؛ بیمار هر ترکیبی را می‌تواند انتخاب کند. ) : ( groups.map((group) => (
{ if (e.target.value.trim() !== '' && e.target.value !== group.name) { update.mutate({ uuid: group.uuid, body: { name: e.target.value.trim() } }); } }} /> {canEdit && ( )}
{ const uuid = String(v ?? ''); if (uuid && !group.items.some((i) => i.uuid === uuid)) { setItems.mutate({ uuid: group.uuid, itemUuids: [...group.items.map((i) => i.uuid), uuid], }); } }} options={itemOptions.filter((o) => !group.items.some((i) => i.uuid === o.value))} placeholder="افزودن آیتم" isDisabled={!canEdit} />
{group.items.map((item) => ( ))} {group.items.length === 0 && ( گروه بدون آیتم، حداقلِ انتخابش هرگز برآورده نمی‌شود. )}
)) )}
{/* ── روابط ─────────────────────────────────────────────────────────── */}

ناسازگاری و پیش‌نیاز

ناسازگاری متقارن است؛ پیش‌نیاز جهت‌دار. حلقهٔ پیش‌نیاز هنگام ذخیره رد می‌شود.
{relations.map((relation, index) => (
setRelations( relations.map((r, i) => i === index ? { ...r, related_item_uuid: String(v ?? '') } : r, ), ) } options={itemOptions} isDisabled={!canEdit} />
setRelations( relations.map((r, i) => i === index ? { ...r, type: (v as ItemRelation['type']) ?? 'incompatible_with' } : r, ), ) } options={Object.entries(RELATION_LABELS).map(([value, label]) => ({ value, label }))} isDisabled={!canEdit} />
{canEdit && ( )}
))} {canEdit && (
)}
{/* ── پیش‌نمایش زنده ────────────────────────────────────────────────── */}

پیش‌نمایش انتخاب

همان محاسبه‌ای که بیمار می‌بیند — یک آیتم با «مدت تنها» و بقیه با «مدت اضافه».
{ const uuid = String(v ?? ''); if (uuid && !selected.includes(uuid)) setSelected([...selected, uuid]); }} options={itemOptions.filter((o) => !selected.includes(o.value))} placeholder="آیتمی را برای آزمایش انتخاب کنید" />
{selected.map((uuid) => ( ))}
{selected.length === 0 ? ( چند آیتم انتخاب کنید تا مدت، قیمت و خطاهای ترکیب را ببینید. ) : previewing ? ( در حال محاسبه… ) : preview ? (
مدت: {preview.total_duration_minutes} دقیقه قیمت: {formatRial(preview.total_price_rials)} {preview.valid ? ( ترکیب معتبر ) : ( ترکیب نامعتبر )}
{/* همهٔ خطاها با هم می‌آیند، نه اولی — کاربر نباید سه بار رفت‌وبرگشت کند. */} {preview.errors.map((error, index) => ( {error.message} ))}
{preview.breakdown.map((row) => ( ))}
آیتم شمرده‌شده به‌عنوان دقیقه قیمت
{row.item_name} {row.counted_as === 'solo' ? 'مدت تنها (لنگر)' : 'مدت اضافه'} {row.minutes} {formatRial(row.price_rials)}
) : null}
setDeleting(null)} onConfirm={async () => { await remove.mutateAsync(deleting!); setDeleting(null); }} />
); }