Files
clinicpro/assets/admin/components/ServiceGroupsTab.tsx
T
hamedandClaude Opus 5 26a8e53b34 feat(admin): catalog groups and appointment segments editors
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>
2026-07-31 19:42:24 +03:30

404 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}