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}` ·