From b8e858086769bde86b02b8e0e3666368dbec4cee Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 15:56:49 +0330 Subject: [PATCH] refactor(services): drop the groups and segments tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anything that belongs to a service is defined on the service itself, so the two tabs that managed selection groups and appointment segments come off the service page. Only the UI goes. SegmentTemplate is what makes a service occupy a room and a device at the same time — it is the input to AppointmentPlanBuilder and the reason the resource timeline has anything to draw — and a service without a template already books through singleSegment(). Removing the model would change booking; removing the tabs does not, which tests/Appointment confirms at 314 green. The active tab moved into the query string on the way past. That is what makes the old ?tab=segments link land on the info tab instead of rendering nothing, and it lets back and refresh return to the same tab. Co-Authored-By: Claude Opus 5 --- .../components/ServiceGroupsTab.test.tsx | 91 ---- assets/admin/components/ServiceGroupsTab.tsx | 403 --------------- .../components/ServiceSegmentsTab.test.tsx | 79 --- .../admin/components/ServiceSegmentsTab.tsx | 464 ------------------ assets/admin/pages/ServiceDetailPage.test.tsx | 31 +- assets/admin/pages/ServiceDetailPage.tsx | 12 +- docs/api/clinic-services.md | 5 + 7 files changed, 39 insertions(+), 1046 deletions(-) delete mode 100644 assets/admin/components/ServiceGroupsTab.test.tsx delete mode 100644 assets/admin/components/ServiceGroupsTab.tsx delete mode 100644 assets/admin/components/ServiceSegmentsTab.test.tsx delete mode 100644 assets/admin/components/ServiceSegmentsTab.tsx diff --git a/assets/admin/components/ServiceGroupsTab.test.tsx b/assets/admin/components/ServiceGroupsTab.test.tsx deleted file mode 100644 index 1033eda4..00000000 --- a/assets/admin/components/ServiceGroupsTab.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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; -const post = api.post as ReturnType; -const patch = api.patch as ReturnType; - -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(, { 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(, { 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(, { route: '/admin/services/s1' }); - - await waitFor(() => - expect(screen.getByText(/حداکثرِ خالی یعنی نامحدود/)).toBeInTheDocument(), - ); - }); -}); diff --git a/assets/admin/components/ServiceGroupsTab.tsx b/assets/admin/components/ServiceGroupsTab.tsx deleted file mode 100644 index ede1caa6..00000000 --- a/assets/admin/components/ServiceGroupsTab.tsx +++ /dev/null @@ -1,403 +0,0 @@ -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 = { - 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(null); - const [relations, setRelations] = useState(initialRelations); - - // ── پیش‌نمایش زنده با debounce ─────────────────────────────────────────── - // بدون تأخیر، هر کلیک روی چیپ یک درخواست می‌سازد و اپراتورِ سریع، ده درخواست. - const [selected, setSelected] = useState([]); - const [debounced, setDebounced] = useState([]); - - 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 ( -
- {/* ── گروه‌ها ───────────────────────────────────────────────────────── */} -
-
-

گروه‌های انتخاب

- - «حتماً یک سطح انرژی، فقط یکی» یعنی حداقل ۱ و حداکثر ۱. حداکثرِ خالی یعنی نامحدود. - -
- - {canEdit && ( -
-
- - setNewGroupName(e.target.value)} - placeholder="مثلاً: نواحی بدن" - /> -
- -
- )} - - {loading ? ( - در حال بارگذاری… - ) : groups.length === 0 ? ( - - این سرویس گروه انتخابی ندارد؛ بیمار هر ترکیبی را می‌تواند انتخاب کند. - - ) : ( - groups.map((group) => ( -
-
- { - if (e.target.value.trim() !== '' && e.target.value !== group.name) { - update.mutate({ uuid: group.uuid, body: { name: e.target.value.trim() } }); - } - }} - /> - - - - - - {canEdit && ( - - )} -
- -
- - { - 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} - /> -
- {group.items.map((item) => ( - - ))} - {group.items.length === 0 && ( - - گروه بدون آیتم، حداقلِ انتخابش هرگز برآورده نمی‌شود. - - )} -
-
-
- )) - )} -
- - {/* ── روابط ─────────────────────────────────────────────────────────── */} -
-
-

ناسازگاری و پیش‌نیاز

- - ناسازگاری متقارن است؛ پیش‌نیاز جهت‌دار. حلقهٔ پیش‌نیاز هنگام ذخیره رد می‌شود. - -
- - {relations.map((relation, index) => ( -
-
- - setRelations( - relations.map((r, i) => - i === index ? { ...r, related_item_uuid: String(v ?? '') } : r, - ), - ) - } - options={itemOptions} - isDisabled={!canEdit} - /> -
- -
- - 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} - /> -
- - {canEdit && ( - - )} -
- ))} - - {canEdit && ( -
- - -
- )} -
- - {/* ── پیش‌نمایش زنده ────────────────────────────────────────────────── */} -
-
-

پیش‌نمایش انتخاب

- - همان محاسبه‌ای که بیمار می‌بیند — یک آیتم با «مدت تنها» و بقیه با «مدت اضافه». - -
- - { - const uuid = String(v ?? ''); - if (uuid && !selected.includes(uuid)) setSelected([...selected, uuid]); - }} - options={itemOptions.filter((o) => !selected.includes(o.value))} - placeholder="آیتمی را برای آزمایش انتخاب کنید" - /> - -
- {selected.map((uuid) => ( - - ))} -
- - {selected.length === 0 ? ( - - چند آیتم انتخاب کنید تا مدت، قیمت و خطاهای ترکیب را ببینید. - - ) : previewing ? ( - در حال محاسبه… - ) : preview ? ( -
-
- - مدت: {preview.total_duration_minutes} دقیقه - - قیمت: {formatRial(preview.total_price_rials)} - {preview.valid ? ( - ترکیب معتبر - ) : ( - ترکیب نامعتبر - )} -
- - {/* همهٔ خطاها با هم می‌آیند، نه اولی — کاربر نباید سه بار رفت‌وبرگشت کند. */} - {preview.errors.map((error, index) => ( - - {error.message} - - ))} - -
- - - - - - - - - - - {preview.breakdown.map((row) => ( - - - - - - - ))} - -
آیتمشمرده‌شده به‌عنواندقیقهقیمت
{row.item_name} - {row.counted_as === 'solo' ? 'مدت تنها (لنگر)' : 'مدت اضافه'} - {row.minutes}{formatRial(row.price_rials)}
-
-
- ) : null} -
- - setDeleting(null)} - onConfirm={async () => { - await remove.mutateAsync(deleting!); - setDeleting(null); - }} - /> -
- ); -} diff --git a/assets/admin/components/ServiceSegmentsTab.test.tsx b/assets/admin/components/ServiceSegmentsTab.test.tsx deleted file mode 100644 index abfcdfd7..00000000 --- a/assets/admin/components/ServiceSegmentsTab.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -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 ServiceSegmentsTab from './ServiceSegmentsTab'; - -const get = api.get as ReturnType; -const put = api.put as ReturnType; - -const SEGMENTS = [ - { - sequence: 1, - name: 'آماده‌سازی', - duration_minutes: 10, - duration_source: 'fixed', - patient_present: true, - mergeable: true, - requirements: [], - }, -]; - -function mockApi(segments = SEGMENTS) { - get.mockImplementation((url: string) => { - if (url.includes('resource-types')) return Promise.resolve({ data: [] }); - return Promise.resolve({ data: { segments } }); - }); - put.mockResolvedValue({ data: { segments } }); -} - -describe('ServiceSegmentsTab', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockApi(); - }); - - it('loads the service segments it was given', async () => { - renderWithProviders(); - - expect(await screen.findByDisplayValue('آماده‌سازی')).toBeInTheDocument(); - }); - - /** ⭐ ویرایشگر باید همان چیزی را بفرستد که کاربر می‌بیند، نه یک شکل تازه. */ - it('sends the edited segments back on save', async () => { - const user = userEvent.setup(); - - renderWithProviders(); - - const name = await screen.findByDisplayValue('آماده‌سازی'); - await user.clear(name); - await user.type(name, 'ضدعفونی'); - - await user.click(screen.getByRole('button', { name: /ذخیرهٔ بخش‌ها/ })); - - await waitFor(() => expect(put).toHaveBeenCalled()); - - const [url, body] = put.mock.calls[0]; - expect(url).toBe('/api/v1/service-item/s-1/segments'); - expect(body.segments[0].name).toBe('ضدعفونی'); - }); - - /** بدون اجازهٔ ویرایش، فرم فقط خواندنی است — دکمهٔ ذخیره اصلاً نباید باشد. */ - it('renders read-only without the edit permission', async () => { - renderWithProviders(); - - const name = await screen.findByDisplayValue('آماده‌سازی'); - - expect(name).toBeDisabled(); - expect(screen.queryByRole('button', { name: /ذخیرهٔ بخش‌ها/ })).toBeNull(); - }); -}); diff --git a/assets/admin/components/ServiceSegmentsTab.tsx b/assets/admin/components/ServiceSegmentsTab.tsx deleted file mode 100644 index f9b35683..00000000 --- a/assets/admin/components/ServiceSegmentsTab.tsx +++ /dev/null @@ -1,464 +0,0 @@ -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 { useAddresses } from '../hooks/useAddresses'; - -interface SegmentRequirementDraft { - type_uuid: string; - skill_uuid: string | null; - count: number; - occupancy: 'exclusive' | 'shared' | 'passive'; - 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: 'اشتراکی — از ظرفیت منبع یکی کم می‌شود' }, - // مثل انحصاری قفل می‌کند، ولی در گزارش «کار مفید» حساب نمی‌شود. - { value: 'passive', 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 { addresses } = useAddresses(); - - const [segments, setSegments] = useState([]); - const [branchUuid, setBranchUuid] = useState(''); - - const { data, isLoading } = useQuery({ - queryKey: ['service-segments', serviceUuid], - queryFn: () => - api.get>(`/api/v1/service-item/${serviceUuid}/segments`), - enabled: !!serviceUuid, - }); - - const { data: typesData } = useQuery({ - queryKey: ['resource-types-for-segments'], - queryFn: () => api.get>('/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>( - `/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>('/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) => - setSegments(segments.map((s, i) => (i === index ? { ...s, ...changes } : s))); - - const patchRequirement = ( - segmentIndex: number, - reqIndex: number, - changes: Partial, - ) => - setSegments( - segments.map((s, i) => - i === segmentIndex - ? { ...s, requirements: s.requirements.map((r, j) => (j === reqIndex ? { ...r, ...changes } : r)) } - : s, - ), - ); - - return ( -
-
-
-

بخش‌های نوبت

- - سرویس بدون بخش، یک نوبت پیوسته است که پزشک را می‌گیرد — همان رفتار امروز. - -
- - {isLoading ? ( - در حال بارگذاری… - ) : ( - segments.map((segment, index) => ( -
-
- - - patch(index, { name: e.target.value })} - /> - -
- patch(index, { duration_source: (v as 'fixed' | 'items') ?? 'fixed' })} - options={DURATION_OPTIONS} - isDisabled={!canEdit} - /> -
- - {segment.duration_source === 'fixed' && ( - - )} - - {canEdit && ( - - )} -
- -
- - - -
- - {/* ── نیازمندی‌های منبع ─────────────────────────────────────── */} -
- {segment.requirements.map((requirement, reqIndex) => ( -
-
- patchRequirement(index, reqIndex, { type_uuid: String(v ?? '') })} - options={types.map((t) => ({ value: t.uuid, label: t.name }))} - placeholder="نقش منبع" - isDisabled={!canEdit} - /> -
- - - -
- - patchRequirement(index, reqIndex, { - occupancy: (v as 'exclusive' | 'shared' | 'passive') ?? 'exclusive', - }) - } - options={OCCUPANCY_OPTIONS} - isDisabled={!canEdit} - /> -
- - - - {canEdit && ( - - )} -
- ))} - - {canEdit && ( -
- -
- )} -
-
- )) - )} - - {canEdit && ( -
- - - -
- )} -
- - {/* ── نوار پیش‌نمایش زمانی ──────────────────────────────────────────── */} -
-
-
- - setBranchUuid(String(v ?? ''))} - options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} - placeholder="انتخاب شعبه" - /> -
- - منابع واجد شرایط از همان شعبه خوانده می‌شوند، پس پیش‌نمایش بدون شعبه معنا ندارد. - -
- - {previewError && ( -
- - {previewError instanceof ApiError ? previewError.message : 'پیش‌نمایش ساخته نشد'} - - {/* خطای «هیچ منبعی نیست» بدون راه اصلاح، فقط بن‌بست است. */} - - افزودن منبع به این شعبه - -
- )} - - {preview && ( - <> -
- - مدت کل نوبت: {preview.total_minutes} دقیقه - - - بیمار واقعاً درگیر: {patientFacingMinutes} دقیقه - -
- -
-
- {preview.segments.map((segment) => ( -
- {segment.name} - - {segment.duration_minutes} دقیقه - - - {segment.requirements.length === 0 - ? 'بدون منبع' - : segment.requirements.map((r) => r.role_name).join('، ')} - -
- ))} -
-
- - - بخش‌های کم‌رنگ آن‌هایی‌اند که بیمار حاضر نیست — منبع گرفته می‌شود ولی کاری - روی بیمار انجام نمی‌شود. - - - )} -
-
- ); -} diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx index 9a9ba723..44687324 100644 --- a/assets/admin/pages/ServiceDetailPage.test.tsx +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -75,12 +75,12 @@ beforeEach(() => { }); // صفحه uuid را از useParams می‌گیرد، پس به یک Route واقعی نیاز دارد. -const render = () => +const render = (route = '/admin/clinic-services/it1') => renderWithProviders( } /> , - { route: '/admin/clinic-services/it1' }, + { route }, ); describe('ServiceDetailPage (جزئیات سرویس)', () => { @@ -202,4 +202,31 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => { expect(await screen.findByText('غیرفعال')).toBeInTheDocument(); expect(screen.getByText('فعال‌کردن')).toBeInTheDocument(); }); + + /** + * گروه‌های انتخاب و بخش‌های نوبت از صفحهٔ سرویس برداشته شدند: هر چیزی که به یک سرویس + * مربوط است در خودِ سرویس تعریف می‌شود. مدل و موتور سر جایشان‌اند — تست‌های + * `tests/Appointment` همان رفتار قبلی را قفل می‌کنند. + */ + it('دیگر تب گروه‌ها و بخش‌های نوبت ندارد', async () => { + render(); + await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' }); + + expect(screen.queryByText('گروه‌ها و آیتم‌ها')).not.toBeInTheDocument(); + expect(screen.queryByText('بخش‌های نوبت')).not.toBeInTheDocument(); + }); + + /** لینک قدیمی به تبِ حذف‌شده نباید صفحهٔ خالی بدهد. */ + it('تبِ ناشناخته در URL به اطلاعات برمی‌گردد', async () => { + render('/admin/clinic-services/it1?tab=segments'); + + expect(await screen.findByText('تاریخ ایجاد')).toBeInTheDocument(); + }); + + /** تب در URL می‌نشیند تا «بازگشت» و رفرش همان نما را بدهند. */ + it('تب انتخاب‌شده از URL خوانده می‌شود', async () => { + render('/admin/clinic-services/it1?tab=tariffs'); + + expect(await screen.findByText('سال جاری')).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 269ca232..22b4a027 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -15,11 +15,10 @@ import PageHeader from '../components/ui/PageHeader'; import FeatureGate from '../components/ui/FeatureGate'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import { usePermissions } from '../hooks/usePermissions'; +import { useUrlState } from '../hooks/useUrlState'; 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'; import ServiceCategoryTab from '../components/ServiceCategoryTab'; interface Tariff { @@ -54,8 +53,6 @@ const TABS = [ { id: 'info', label: 'اطلاعات سرویس' }, { id: 'tariffs', label: 'تعرفه‌ها' }, { id: 'insurance', label: 'بیمه‌ها' }, - { id: 'groups', label: 'گروه‌ها و آیتم‌ها' }, - { id: 'segments', label: 'بخش‌های نوبت' }, { id: 'categories',label: 'دسته‌بندی‌ها' }, { id: 'goods', label: 'کالاهای مرتبط' }, { id: 'history', label: 'لاگ تغییرات' }, @@ -458,7 +455,10 @@ function ServiceDetailPageInner() { const { can } = usePermissions(); const canUpdate = can('services', 'update'); - const [tab, setTab] = useState('info'); + const [urlState, setUrlState] = useUrlState({ tab: 'info' }); + // تبِ ناشناخته (لینک قدیمی به گروه‌ها/بخش‌ها) به اطلاعات برمی‌گردد، نه صفحهٔ خالی. + const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId; + const setTab = (id: TabId) => setUrlState({ tab: id }); const [editOpen, setEditOpen] = useState(false); const [tariffOpen, setTariffOpen] = useState(false); const [insuranceOpen, setInsuranceOpen] = useState(false); @@ -543,8 +543,6 @@ function ServiceDetailPageInner() { {tab === 'info' && } {tab === 'tariffs' && setTariffOpen(true)} canUpdate={canUpdate} />} {tab === 'insurance' && setInsuranceOpen(true)} canUpdate={canUpdate} />} - {tab === 'groups' && } - {tab === 'segments' && } {tab === 'categories' && ( **پنل:** تب‌های «گروه‌ها و آیتم‌ها» و «بخش‌های نوبت» از صفحهٔ سرویس برداشته شدند — +> هر چیزی که به یک سرویس مربوط است در خودِ سرویس تعریف می‌شود. **مدل و اندپوینت‌ها +> دست‌نخورده‌اند** و `AppointmentPlanBuilder` همچنان از همان‌ها می‌خواند؛ سرویسی که +> اتاق و دستگاه را با هم می‌گیرد بدونشان می‌شکست. + ## گروه انتخاب `GET/POST /api/v1/service-item/{uuid}/groups` · `PATCH/DELETE /api/v1/item-group/{uuid}` ·