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>
404 lines
17 KiB
TypeScript
404 lines
17 KiB
TypeScript
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<ItemRelation['type'], string> = {
|
||
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<string | null>(null);
|
||
const [relations, setRelations] = useState<ItemRelation[]>(initialRelations);
|
||
|
||
// ── پیشنمایش زنده با debounce ───────────────────────────────────────────
|
||
// بدون تأخیر، هر کلیک روی چیپ یک درخواست میسازد و اپراتورِ سریع، ده درخواست.
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
const [debounced, setDebounced] = useState<string[]>([]);
|
||
|
||
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 (
|
||
<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>
|
||
|
||
{canEdit && (
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||
<label htmlFor="new-group">گروه تازه</label>
|
||
<input
|
||
id="new-group"
|
||
className="input"
|
||
value={newGroupName}
|
||
onChange={(e) => setNewGroupName(e.target.value)}
|
||
placeholder="مثلاً: نواحی بدن"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={newGroupName.trim() === '' || create.isPending}
|
||
onClick={async () => {
|
||
await create.mutateAsync({ name: newGroupName.trim(), min_select: 0, max_select: null });
|
||
setNewGroupName('');
|
||
}}
|
||
>
|
||
<PlusIcon style={{ width: 15 }} /> افزودن گروه
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>
|
||
) : groups.length === 0 ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||
این سرویس گروه انتخابی ندارد؛ بیمار هر ترکیبی را میتواند انتخاب کند.
|
||
</span>
|
||
) : (
|
||
groups.map((group) => (
|
||
<div
|
||
key={group.uuid}
|
||
style={{
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 'var(--r)',
|
||
padding: 14,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
<input
|
||
className="input"
|
||
style={{ maxWidth: 220 }}
|
||
defaultValue={group.name}
|
||
disabled={!canEdit}
|
||
onBlur={(e) => {
|
||
if (e.target.value.trim() !== '' && e.target.value !== group.name) {
|
||
update.mutate({ uuid: group.uuid, body: { name: e.target.value.trim() } });
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
حداقل
|
||
<input
|
||
className="input"
|
||
style={{ width: 70 }}
|
||
type="number"
|
||
min={0}
|
||
defaultValue={group.min_select}
|
||
disabled={!canEdit}
|
||
onBlur={(e) =>
|
||
update.mutate({ uuid: group.uuid, body: { min_select: Number(e.target.value) } })
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
حداکثر
|
||
<input
|
||
className="input"
|
||
style={{ width: 70 }}
|
||
type="number"
|
||
min={0}
|
||
placeholder="نامحدود"
|
||
defaultValue={group.max_select ?? ''}
|
||
disabled={!canEdit}
|
||
onBlur={(e) =>
|
||
update.mutate({
|
||
uuid: group.uuid,
|
||
// خالی یعنی نامحدود؛ صفر یعنی «هیچکدام» و معنای متفاوتی دارد.
|
||
body: { max_select: e.target.value === '' ? null : Number(e.target.value) },
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
className="btn secondary sm"
|
||
style={{ marginRight: 'auto' }}
|
||
onClick={() => setDeleting(group.uuid)}
|
||
aria-label="حذف گروه"
|
||
>
|
||
<TrashIcon style={{ width: 15 }} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="field" style={{ margin: 0 }}>
|
||
<label>آیتمهای این گروه</label>
|
||
<SearchableSelect
|
||
value={null}
|
||
onChange={(v) => {
|
||
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}
|
||
/>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||
{group.items.map((item) => (
|
||
<button
|
||
key={item.uuid}
|
||
type="button"
|
||
className="badge"
|
||
disabled={!canEdit}
|
||
onClick={() =>
|
||
setItems.mutate({
|
||
uuid: group.uuid,
|
||
itemUuids: group.items.filter((i) => i.uuid !== item.uuid).map((i) => i.uuid),
|
||
})
|
||
}
|
||
>
|
||
{item.name} ✕
|
||
</button>
|
||
))}
|
||
{group.items.length === 0 && (
|
||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||
گروه بدون آیتم، حداقلِ انتخابش هرگز برآورده نمیشود.
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</section>
|
||
|
||
{/* ── روابط ─────────────────────────────────────────────────────────── */}
|
||
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<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>
|
||
|
||
{relations.map((relation, index) => (
|
||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<div style={{ minWidth: 200 }}>
|
||
<SearchableSelect
|
||
value={relation.related_item_uuid}
|
||
onChange={(v) =>
|
||
setRelations(
|
||
relations.map((r, i) =>
|
||
i === index ? { ...r, related_item_uuid: String(v ?? '') } : r,
|
||
),
|
||
)
|
||
}
|
||
options={itemOptions}
|
||
isDisabled={!canEdit}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ minWidth: 190 }}>
|
||
<SearchableSelect
|
||
value={relation.type}
|
||
onChange={(v) =>
|
||
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}
|
||
/>
|
||
</div>
|
||
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
className="btn secondary sm"
|
||
onClick={() => setRelations(relations.filter((_, i) => i !== index))}
|
||
aria-label="حذف رابطه"
|
||
>
|
||
<TrashIcon style={{ width: 15 }} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{canEdit && (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="btn secondary sm"
|
||
onClick={() =>
|
||
setRelations([...relations, { related_item_uuid: '', type: 'incompatible_with' }])
|
||
}
|
||
>
|
||
<PlusIcon style={{ width: 15 }} /> افزودن رابطه
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={saveRelations.isPending || relations.some((r) => r.related_item_uuid === '')}
|
||
onClick={() => saveRelations.mutate(relations)}
|
||
>
|
||
ذخیرهٔ روابط
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── پیشنمایش زنده ────────────────────────────────────────────────── */}
|
||
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<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>
|
||
|
||
<SearchableSelect
|
||
value={null}
|
||
onChange={(v) => {
|
||
const uuid = String(v ?? '');
|
||
if (uuid && !selected.includes(uuid)) setSelected([...selected, uuid]);
|
||
}}
|
||
options={itemOptions.filter((o) => !selected.includes(o.value))}
|
||
placeholder="آیتمی را برای آزمایش انتخاب کنید"
|
||
/>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{selected.map((uuid) => (
|
||
<button
|
||
key={uuid}
|
||
type="button"
|
||
className="badge"
|
||
onClick={() => setSelected(selected.filter((u) => u !== uuid))}
|
||
>
|
||
{nameOf(uuid)} ✕
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{selected.length === 0 ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||
چند آیتم انتخاب کنید تا مدت، قیمت و خطاهای ترکیب را ببینید.
|
||
</span>
|
||
) : previewing ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال محاسبه…</span>
|
||
) : preview ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
||
<span>
|
||
مدت: <strong>{preview.total_duration_minutes}</strong> دقیقه
|
||
</span>
|
||
<span>قیمت: {formatRial(preview.total_price_rials)}</span>
|
||
{preview.valid ? (
|
||
<span className="badge green"><span className="bdot" />ترکیب معتبر</span>
|
||
) : (
|
||
<span className="badge red"><span className="bdot" />ترکیب نامعتبر</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* همهٔ خطاها با هم میآیند، نه اولی — کاربر نباید سه بار رفتوبرگشت کند. */}
|
||
{preview.errors.map((error, index) => (
|
||
<span key={index} style={{ fontSize: 13, color: 'var(--danger)' }}>
|
||
{error.message}
|
||
</span>
|
||
))}
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>آیتم</th>
|
||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>شمردهشده بهعنوان</th>
|
||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>دقیقه</th>
|
||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>قیمت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{preview.breakdown.map((row) => (
|
||
<tr key={row.item_uuid} style={{ borderTop: '1px solid var(--border)' }}>
|
||
<td style={{ padding: '6px 4px' }}>{row.item_name}</td>
|
||
<td style={{ padding: '6px 4px', color: 'var(--text-2)' }}>
|
||
{row.counted_as === 'solo' ? 'مدت تنها (لنگر)' : 'مدت اضافه'}
|
||
</td>
|
||
<td style={{ padding: '6px 4px' }}>{row.minutes}</td>
|
||
<td style={{ padding: '6px 4px' }}>{formatRial(row.price_rials)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<ConfirmDialog
|
||
open={deleting !== null}
|
||
title="حذف گروه"
|
||
message="آیتمهای گروه حذف نمیشوند؛ فقط قاعدهٔ «حداقل/حداکثر انتخاب» برداشته میشود."
|
||
confirmLabel="حذف کن"
|
||
danger
|
||
loading={remove.isPending}
|
||
onCancel={() => setDeleting(null)}
|
||
onConfirm={async () => {
|
||
await remove.mutateAsync(deleting!);
|
||
setDeleting(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|