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>
This commit is contained in:
hamed
2026-07-31 19:42:24 +03:30
co-authored by Claude Opus 5
parent bd1f79f5e1
commit 26a8e53b34
7 changed files with 1197 additions and 75 deletions
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import ServiceGroupsTab from './ServiceGroupsTab';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const groups = [
{
uuid: 'g1',
name: 'نواحی بدن',
min_select: 1,
max_select: null,
items: [{ uuid: 'i1', name: 'صورت' }],
},
];
const items = [
{ uuid: 'i1', name: 'صورت' },
{ uuid: 'i2', name: 'بیکینی' },
];
function mockApi() {
get.mockImplementation((path: string) => {
if (path.endsWith('/groups')) return Promise.resolve({ success: true, data: groups });
if (path === '/api/v1/service-items') return Promise.resolve({ success: true, data: items });
return Promise.resolve({ success: true, data: null });
});
post.mockResolvedValue({
success: true,
data: {
valid: false,
errors: [{ code: 'min_select', message: 'انتخاب حداقل ۱ مورد از «نواحی بدن» الزامی است' }],
total_duration_minutes: 23,
total_price_rials: 800000,
breakdown: [
{ item_uuid: 'i1', item_name: 'صورت', counted_as: 'solo', minutes: 15, price_rials: 500000 },
{ item_uuid: 'i2', item_name: 'بیکینی', counted_as: 'additional', minutes: 8, price_rials: 300000 },
],
},
});
patch.mockResolvedValue({ success: true, data: groups[0] });
}
describe('ServiceGroupsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi();
});
it('shows each group with its min and max', async () => {
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
await waitFor(() => expect(screen.getByDisplayValue('نواحی بدن')).toBeInTheDocument());
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
// حداکثرِ خالی یعنی نامحدود — نه صفر.
expect(screen.getByPlaceholderText('نامحدود')).toHaveValue(null);
});
/**
* ⭐ پیش‌نمایش نباید بدون انتخاب، درخواستی بزند: فراخوانی با سبد خالی هم نویز شبکه
* است هم خطای «حداقل انتخاب» را بی‌جا نشان می‌دهد.
*/
it('does not call validate until something is selected', async () => {
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
await waitFor(() => expect(screen.getByText('پیش‌نمایش انتخاب')).toBeInTheDocument());
expect(post).not.toHaveBeenCalledWith('/api/v1/service-selection/validate', expect.anything());
expect(screen.getByText(/چند آیتم انتخاب کنید/)).toBeInTheDocument();
});
it('explains the meaning of an empty maximum', async () => {
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
await waitFor(() =>
expect(screen.getByText(/حداکثرِ خالی یعنی نامحدود/)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,403 @@
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>
);
}
@@ -0,0 +1,462 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import SearchableSelect from './ui/SearchableSelect';
import { api, ApiError, type ApiResponse } from '../lib/api';
import { useBranches } from '../hooks/useBranches';
interface SegmentRequirementDraft {
type_uuid: string;
skill_uuid: string | null;
count: number;
occupancy: 'exclusive' | 'shared';
constraints: string[];
}
interface SegmentDraft {
sequence: number;
name: string;
duration_source: 'fixed' | 'items';
duration_minutes: number;
patient_present: boolean;
mergeable: boolean;
requirements: SegmentRequirementDraft[];
}
interface PlanPreviewSegment {
sequence: number;
name: string;
offset_minutes: number;
duration_minutes: number;
patient_present: boolean;
requirements: { role_name: string; count: number; candidates?: number }[];
}
interface PlanPreview {
total_minutes: number;
segments: PlanPreviewSegment[];
}
interface ResourceType {
uuid: string;
code: string;
name: string;
}
const OCCUPANCY_OPTIONS = [
{ value: 'exclusive', label: 'انحصاری — منبع کامل قفل می‌شود' },
{ value: 'shared', label: 'اشتراکی — از ظرفیت منبع یکی کم می‌شود' },
];
const DURATION_OPTIONS = [
{ value: 'fixed', label: 'مدت ثابت' },
{ value: 'items', label: 'از آیتم‌های انتخابی' },
];
interface Props {
serviceUuid: string;
canEdit: boolean;
}
/**
* بخش‌های نوبت — بخش UI تسک ۰۵.
*
* نوار پیش‌نمایش تنها جایی است که تفاوت «مدت نوبت» و «زمانی که منبع واقعاً درگیر است»
* دیده می‌شود؛ همان تفاوتی که کل تسک ۰۵ برایش وجود دارد: «انتظار اثر کرم» اتاق را
* می‌گیرد ولی اپراتور را آزاد می‌گذارد.
*/
export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
const qc = useQueryClient();
const { branches } = useBranches();
const [segments, setSegments] = useState<SegmentDraft[]>([]);
const [branchUuid, setBranchUuid] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['service-segments', serviceUuid],
queryFn: () =>
api.get<ApiResponse<{ segments: SegmentDraft[] }>>(`/api/v1/service-item/${serviceUuid}/segments`),
enabled: !!serviceUuid,
});
const { data: typesData } = useQuery({
queryKey: ['resource-types-for-segments'],
queryFn: () => api.get<ApiResponse<ResourceType[]>>('/api/v1/resource-types'),
staleTime: 60_000,
});
useEffect(() => {
if (data?.data?.segments) setSegments(data.data.segments);
}, [data]);
const save = useMutation({
mutationFn: (body: SegmentDraft[]) =>
api.put<ApiResponse<{ segments: SegmentDraft[] }>>(
`/api/v1/service-item/${serviceUuid}/segments`,
{ segments: body },
),
onSuccess: () => {
toast.success('بخش‌ها ذخیره شد');
qc.invalidateQueries({ queryKey: ['service-segments', serviceUuid] });
qc.invalidateQueries({ queryKey: ['segments-preview', serviceUuid] });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ بخش‌ها ناموفق بود'),
});
const { data: previewData, error: previewError } = useQuery({
queryKey: ['segments-preview', serviceUuid, branchUuid],
queryFn: () =>
api.post<ApiResponse<PlanPreview>>('/api/v1/appointment-plan/preview', {
service_uuid: serviceUuid,
branch_uuid: branchUuid,
}),
enabled: !!branchUuid,
retry: false,
});
const preview = previewData?.data;
const types = typesData?.data ?? [];
// «اپراتور واقعاً درگیر» — مجموع بخش‌هایی که بیمار حاضر است.
const patientFacingMinutes =
preview?.segments.filter((s) => s.patient_present).reduce((sum, s) => sum + s.duration_minutes, 0) ?? 0;
const patch = (index: number, changes: Partial<SegmentDraft>) =>
setSegments(segments.map((s, i) => (i === index ? { ...s, ...changes } : s)));
const patchRequirement = (
segmentIndex: number,
reqIndex: number,
changes: Partial<SegmentRequirementDraft>,
) =>
setSegments(
segments.map((s, i) =>
i === segmentIndex
? { ...s, requirements: s.requirements.map((r, j) => (j === reqIndex ? { ...r, ...changes } : r)) }
: s,
),
);
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>
{isLoading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>
) : (
segments.map((segment, index) => (
<div
key={index}
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
padding: 14,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
ترتیب
<input
className="input"
style={{ width: 70 }}
type="number"
min={1}
value={segment.sequence}
disabled={!canEdit}
onChange={(e) => patch(index, { sequence: Number(e.target.value) })}
/>
</label>
<input
className="input"
style={{ maxWidth: 220 }}
value={segment.name}
placeholder="نام بخش"
disabled={!canEdit}
onChange={(e) => patch(index, { name: e.target.value })}
/>
<div style={{ minWidth: 180 }}>
<SearchableSelect
value={segment.duration_source}
onChange={(v) => patch(index, { duration_source: (v as 'fixed' | 'items') ?? 'fixed' })}
options={DURATION_OPTIONS}
isDisabled={!canEdit}
/>
</div>
{segment.duration_source === 'fixed' && (
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
دقیقه
<input
className="input"
style={{ width: 90 }}
type="number"
min={1}
value={segment.duration_minutes}
disabled={!canEdit}
onChange={(e) => patch(index, { duration_minutes: Number(e.target.value) })}
/>
</label>
)}
{canEdit && (
<button
type="button"
className="btn secondary sm"
style={{ marginRight: 'auto' }}
onClick={() => setSegments(segments.filter((_, i) => i !== index))}
aria-label="حذف بخش"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={segment.patient_present}
disabled={!canEdit}
onChange={(e) => patch(index, { patient_present: e.target.checked })}
/>
بیمار حاضر است
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={segment.mergeable}
disabled={!canEdit}
onChange={(e) => patch(index, { mergeable: e.target.checked })}
/>
با چند آیتم فقط یک بار بیاید
</label>
</div>
{/* ── نیازمندی‌های منبع ─────────────────────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{segment.requirements.map((requirement, reqIndex) => (
<div key={reqIndex} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ minWidth: 170 }}>
<SearchableSelect
value={requirement.type_uuid}
onChange={(v) => patchRequirement(index, reqIndex, { type_uuid: String(v ?? '') })}
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
placeholder="نقش منبع"
isDisabled={!canEdit}
/>
</div>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
تعداد
<input
className="input"
style={{ width: 70 }}
type="number"
min={1}
value={requirement.count}
disabled={!canEdit}
onChange={(e) =>
patchRequirement(index, reqIndex, { count: Number(e.target.value) })
}
/>
</label>
<div style={{ minWidth: 250 }}>
<SearchableSelect
value={requirement.occupancy}
onChange={(v) =>
patchRequirement(index, reqIndex, {
occupancy: (v as 'exclusive' | 'shared') ?? 'exclusive',
})
}
options={OCCUPANCY_OPTIONS}
isDisabled={!canEdit}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input
type="checkbox"
checked={requirement.constraints.includes('same_gender_as_patient')}
disabled={!canEdit}
onChange={(e) =>
patchRequirement(index, reqIndex, {
constraints: e.target.checked ? ['same_gender_as_patient'] : [],
})
}
/>
همجنس بیمار
</label>
{canEdit && (
<button
type="button"
className="btn secondary sm"
onClick={() =>
patch(index, {
requirements: segment.requirements.filter((_, j) => j !== reqIndex),
})
}
aria-label="حذف نیازمندی"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
))}
{canEdit && (
<div>
<button
type="button"
className="btn secondary sm"
onClick={() =>
patch(index, {
requirements: [
...segment.requirements,
{ type_uuid: '', skill_uuid: null, count: 1, occupancy: 'exclusive', constraints: [] },
],
})
}
>
<PlusIcon style={{ width: 15 }} /> افزودن نیازمندی منبع
</button>
</div>
)}
</div>
</div>
))
)}
{canEdit && (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setSegments([
...segments,
{
sequence: segments.length + 1,
name: '',
duration_source: 'fixed',
duration_minutes: 15,
patient_present: true,
mergeable: false,
requirements: [],
},
])
}
>
<PlusIcon style={{ width: 15 }} /> افزودن بخش
</button>
<button
type="button"
className="btn primary sm"
disabled={save.isPending || segments.some((s) => s.name.trim() === '')}
onClick={() => save.mutate(segments)}
>
ذخیرهٔ بخشها
</button>
</div>
)}
</section>
{/* ── نوار پیش‌نمایش زمانی ──────────────────────────────────────────── */}
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه برای پیشنمایش</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
منابع واجد شرایط از همان شعبه خوانده میشوند، پس پیشنمایش بدون شعبه معنا ندارد.
</span>
</div>
{previewError && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 13, color: 'var(--danger)' }}>
{previewError instanceof ApiError ? previewError.message : 'پیش‌نمایش ساخته نشد'}
</span>
{/* خطای «هیچ منبعی نیست» بدون راه اصلاح، فقط بن‌بست است. */}
<Link className="btn secondary sm" to="/admin/resources" style={{ alignSelf: 'flex-start' }}>
افزودن منبع به این شعبه
</Link>
</div>
)}
{preview && (
<>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
<span>
مدت کل نوبت: <strong>{preview.total_minutes}</strong> دقیقه
</span>
<span style={{ color: 'var(--text-2)' }}>
بیمار واقعاً درگیر: {patientFacingMinutes} دقیقه
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<div style={{ display: 'flex', minWidth: 420, gap: 2 }}>
{preview.segments.map((segment) => (
<div
key={segment.sequence}
title={`${segment.name}${segment.duration_minutes} دقیقه`}
style={{
// عرض متناسب مدت: بخش سی‌دقیقه‌ای باید شش برابر بخش پنج‌دقیقه‌ای دیده شود.
flex: `${Math.max(1, segment.duration_minutes)} 0 0`,
minWidth: 60,
padding: '10px 8px',
borderRadius: 'var(--r-sm)',
background: segment.patient_present ? 'var(--primary-soft)' : 'var(--surface-2)',
border: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span style={{ fontSize: 12, fontWeight: 600 }}>{segment.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
{segment.duration_minutes} دقیقه
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)' }}>
{segment.requirements.length === 0
? 'بدون منبع'
: segment.requirements.map((r) => r.role_name).join('، ')}
</span>
</div>
))}
</div>
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
بخشهای کمرنگ آنهاییاند که بیمار حاضر نیست منبع گرفته میشود ولی کاری
روی بیمار انجام نمیشود.
</span>
</>
)}
</section>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { ServiceItem } from '../types';
/**
* کاتالوگ v2 — گروه انتخاب، رابطهٔ آیتم‌ها، و اعتبارسنجی زندهٔ انتخاب.
*
* `validate` هم مدت و قیمت را می‌دهد هم خطاها؛ همان اندپوینتی که سایت عمومی می‌زند.
* پیش‌نمایش فرم از **همان** مسیر می‌آید تا عددی که اپراتور می‌بیند با عددی که بیمار
* می‌بیند یکی باشد.
*/
export interface ItemGroup {
uuid: string;
name: string;
min_select: number;
/** `null` یعنی نامحدود — نه صفر */
max_select: number | null;
items: { uuid: string; name: string }[];
}
export interface ItemRelation {
related_item_uuid: string;
related_item_name?: string;
type: 'incompatible_with' | 'requires';
}
export interface SelectionBreakdownRow {
item_uuid: string;
item_name: string;
counted_as: 'solo' | 'additional';
minutes: number;
price_rials: number;
}
export interface SelectionValidation {
valid: boolean;
errors: { code: string; message: string; group_uuid?: string; items?: string[] }[];
total_duration_minutes: number;
total_price_rials: number;
breakdown: SelectionBreakdownRow[];
}
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useServiceGroups(serviceUuid: string | undefined) {
const qc = useQueryClient();
const key = ['service-groups', serviceUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<ItemGroup[]>>(`/api/v1/service-item/${serviceUuid}/groups`),
enabled: !!serviceUuid,
});
const invalidate = () => qc.invalidateQueries({ queryKey: key });
const create = useMutation({
mutationFn: (body: { name: string; min_select: number; max_select: number | null }) =>
api.post<ApiResponse<ItemGroup>>(`/api/v1/service-item/${serviceUuid}/groups`, body),
onSuccess: () => {
toast.success('گروه ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت گروه ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}`, body),
onSuccess: () => {
toast.success('گروه به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی گروه ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/item-group/${uuid}`),
onSuccess: () => {
toast.success('گروه حذف شد');
invalidate();
},
onError: (e) => fail(e, 'حذف گروه ناموفق بود'),
});
/** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه. */
const setItems = useMutation({
mutationFn: ({ uuid, itemUuids }: { uuid: string; itemUuids: string[] }) =>
api.put<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}/items`, { item_uuids: itemUuids }),
onSuccess: () => {
toast.success('آیتم‌های گروه ذخیره شد');
invalidate();
},
onError: (e) => fail(e, 'ذخیرهٔ آیتم‌ها ناموفق بود'),
});
return { groups: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setItems };
}
export function useServiceRelations(serviceUuid: string | undefined) {
const qc = useQueryClient();
const save = useMutation({
mutationFn: (relations: ItemRelation[]) =>
api.put<ApiResponse<{ relations: ItemRelation[] }>>(
`/api/v1/service-item/${serviceUuid}/relations`,
{ relations: relations.map((r) => ({ related_item_uuid: r.related_item_uuid, type: r.type })) },
),
onSuccess: () => {
toast.success('روابط ذخیره شد');
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
},
// حلقهٔ پیش‌نیاز همین‌جا ۴۲۲ می‌گیرد؛ پیام سرور دقیق‌تر از هر متن عمومی است.
onError: (e) => fail(e, 'ذخیرهٔ روابط ناموفق بود'),
});
return { save };
}
/** فهرست همهٔ آیتم‌های محیط — ورودی انتخابگرهای گروه و رابطه. */
export function useAllServiceItems() {
const query = useQuery({
queryKey: ['service-items-all'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
return { items: query.data?.data ?? [], loading: query.isLoading };
}
/**
* اعتبارسنجی زندهٔ یک انتخاب.
*
* `enabled` روی انتخاب خالی خاموش است: فراخوانی بدون آیتم فقط نویز شبکه‌ای است و
* خطای «حداقل انتخاب» را هم بی‌جا نشان می‌دهد.
*/
export function useSelectionPreview(itemUuids: string[], serviceUuid?: string, branchUuid?: string) {
const query = useQuery({
queryKey: ['selection-preview', itemUuids, serviceUuid, branchUuid],
queryFn: () =>
api.post<ApiResponse<SelectionValidation>>('/api/v1/service-selection/validate', {
item_uuids: itemUuids,
...(serviceUuid ? { service_uuid: serviceUuid } : {}),
...(branchUuid ? { branch_uuid: branchUuid } : {}),
}),
enabled: itemUuids.length > 0,
});
return { preview: query.data?.data, loading: query.isFetching };
}
+6
View File
@@ -18,6 +18,8 @@ import { usePermissions } from '../hooks/usePermissions';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import ServiceGroupsTab from '../components/ServiceGroupsTab';
import ServiceSegmentsTab from '../components/ServiceSegmentsTab';
interface Tariff {
uuid: string;
@@ -51,6 +53,8 @@ const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'tariffs', label: 'تعرفه‌ها' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'groups', label: 'گروه‌ها و آیتم‌ها' },
{ id: 'segments', label: 'بخش‌های نوبت' },
{ id: 'goods', label: 'کالاهای مرتبط' },
{ id: 'history', label: 'لاگ تغییرات' },
] as const;
@@ -537,6 +541,8 @@ function ServiceDetailPageInner() {
{tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'groups' && <ServiceGroupsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
{tab === 'segments' && <ServiceSegmentsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
{tab === 'history' && <HistoryTab item={item} />}