Files
clinicpro/assets/admin/components/ServiceGroupsTab.tsx
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
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>
2026-07-31 21:42:11 +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 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>
);
}