Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. 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 card-pad" 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-block" 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-block" 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 card-pad" 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 card-pad" 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>
|
||
);
|
||
}
|