diff --git a/assets/admin/components/ServiceGroupsTab.test.tsx b/assets/admin/components/ServiceGroupsTab.test.tsx new file mode 100644 index 00000000..1033eda4 --- /dev/null +++ b/assets/admin/components/ServiceGroupsTab.test.tsx @@ -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; +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 new file mode 100644 index 00000000..8a473f76 --- /dev/null +++ b/assets/admin/components/ServiceGroupsTab.tsx @@ -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 = { + 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.tsx b/assets/admin/components/ServiceSegmentsTab.tsx new file mode 100644 index 00000000..a9189fd4 --- /dev/null +++ b/assets/admin/components/ServiceSegmentsTab.tsx @@ -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([]); + 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') ?? 'exclusive', + }) + } + options={OCCUPANCY_OPTIONS} + isDisabled={!canEdit} + /> +
+ + + + {canEdit && ( + + )} +
+ ))} + + {canEdit && ( +
+ +
+ )} +
+
+ )) + )} + + {canEdit && ( +
+ + + +
+ )} +
+ + {/* ── نوار پیش‌نمایش زمانی ──────────────────────────────────────────── */} +
+
+
+ + setBranchUuid(String(v ?? ''))} + options={branches.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/hooks/useServiceCatalog.ts b/assets/admin/hooks/useServiceCatalog.ts new file mode 100644 index 00000000..24f068e1 --- /dev/null +++ b/assets/admin/hooks/useServiceCatalog.ts @@ -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>(`/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>(`/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 }) => + api.patch>(`/api/v1/item-group/${uuid}`, body), + onSuccess: () => { + toast.success('گروه به‌روزرسانی شد'); + invalidate(); + }, + onError: (e) => fail(e, 'به‌روزرسانی گروه ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/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>(`/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>( + `/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>('/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>('/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 }; +} diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 0be34aba..8cf7640e 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -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' && } {tab === 'tariffs' && setTariffOpen(true)} canUpdate={canUpdate} />} {tab === 'insurance' && setInsuranceOpen(true)} canUpdate={canUpdate} />} + {tab === 'groups' && } + {tab === 'segments' && } {tab === 'goods' && setEditOpen(true)} canUpdate={canUpdate} />} {tab === 'history' && } diff --git a/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md b/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md index d695966e..a1997f76 100644 --- a/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md +++ b/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۴ (کاتالوگ خدمات v2) -**وضعیت کلی:** ✅ بک‌اند و مستندات تکمیل (بخش ۴ UI ⏳ با مقصد صریح) · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده — بک‌اند، مستندات و UI · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -62,18 +62,19 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | تب «گروه‌ها و آیتم‌ها» در `ServiceDetailPage` موجود | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۲ | ویرایش inline `min/max` گروه | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۳ | جدول آیتم‌ها: نام، زمان تنها، زمان اضافه، قیمت، فعال | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۴ | ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۵ | **پیش‌نمایش زنده مدت** با debounce ۴۰۰ms | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۶ | قیمت با `PriceInput` | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۷ | هیچ رنگ/شعاع hard-code | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۸ | دارک‌مود و حالت فشرده | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۹ | RTL و موبایل | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۱۰ | فرم با React Hook Form + Zod | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | -| ۴.۱۲ | خطاهای اعتبارسنجی **زیر همان گروه** نمایش داده می‌شوند | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ | +| ۴.۱ | تب «گروه‌ها و آیتم‌ها» در `ServiceDetailPage` | ✅ | `components/ServiceGroupsTab.tsx` | +| ۴.۲ | ویرایش inline `min/max` گروه | ✅ | روی `blur` ذخیره می‌شود؛ خالی‌بودن حداکثر یعنی نامحدود و همان‌جا نوشته شده | +| ۴.۳ | جدول آیتم‌ها: نام، زمان تنها/اضافه، قیمت | ⚠️ | جدول در **پیش‌نمایش انتخاب** است (`breakdown`): نام، «شمرده‌شده به‌عنوان»، دقیقه، قیمت. جدول جدای ویرایش آیتم ساخته نشد چون `ServiceItemFormModal` موجود همان کار را می‌کند | +| ۴.۴ | ناسازگاری/پیش‌نیاز | ✅ | ردیف‌های رابطه با `SearchableSelect`؛ `PUT` جایگزینی کامل | +| ۴.۵ | پیش‌نمایش زنده با debounce ۴۰۰ms | ✅ | ⭐ از **همان** `service-selection/validate` که سایت عمومی می‌زند | +| ۴.۶ | قیمت با `PriceInput` | ⚠️ | قیمت در این تب فقط **نمایش** داده می‌شود (`formatRial`)؛ ویرایشش در `ServiceItemFormModal` موجود است که از قبل `PriceInput` دارد | +| ۴.۷ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` | +| ۴.۸ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد | +| ۴.۹ | RTL و موبایل | ✅ | جدول breakdown اسکرول افقی داخلی دارد | +| ۴.۱۰ | فرم با React Hook Form + Zod | ⚠️ | این تب فرمِ ثبت‌ونهایی ندارد — هر تغییر مستقل و فوری ذخیره می‌شود (inline). RHF برای فرمی که دکمهٔ submit ندارد فقط لایه بود | +| ۴.۱۱ | همهٔ رشته‌ها فارسی | ✅ | | +| ۴.۱۲ | خطاهای اعتبارسنجی زیر همان گروه | ⚠️ | همهٔ خطاها زیر پیش‌نمایش با هم می‌آیند (همان قرارداد «همهٔ خطاها با هم»)؛ نگاشت `group_uuid` به کارت همان گروه انجام نشد | +| ۴.۱۳ | تست فرانت | ✅ | `ServiceGroupsTab.test.tsx` — سه تست، شامل «بدون انتخاب، درخواستی نمی‌رود» | ## ۵. تست diff --git a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md index 71a4f34f..7b8f863e 100644 --- a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md +++ b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md @@ -1,6 +1,9 @@ # چک‌لیست — تسک ۰۵ (بخش‌های نوبت و سازندهٔ برنامه) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ + +> این چک‌لیست تا امروز روی «شروع نشده» مانده بود در حالی که کد تسک از همان روز ساخته و +> کامیت شده بود — خطای پیگیری، نه خطای پیاده‌سازی. حالا با وضعیت واقعی پر شده. قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,94 +14,97 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `SlotCalculatorService` دست‌نخورده | ⏳ | | -| ۰.۳ | سرویس بدون `SegmentTemplate` → یک بخش مجازی با منبع `type=doctor` | ⏳ | ⭐ سازگاری حالت `service` فعلی | -| ۰.۴ | هیچ جدولی برای «برنامهٔ ساخته‌شده» ساخته نشد | ⏳ | DTO درون‌حافظه؛ ذخیره کار تسک ۰۷ | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | `SlotCalculatorService` دست‌نخورده | ✅ | | +| ۰.۳ | سرویس بدون الگو → یک بخش با منبع `doctor` | ✅ | ⭐ `singleSegment()` — سازگاری کامل با رفتار امروز | +| ۰.۴ | هیچ جدولی برای «برنامهٔ ساخته‌شده» نیست | ✅ | فقط DTO درون‌حافظه؛ ذخیره کار تسک ۰۷ شد | ## ۱. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `SegmentTemplate` · `SegmentRequirement` | ⏳ | | -| ۱.۲ | DTO های `AppointmentPlan` · `PlannedSegment` · `PlannedRequirement` (immutable) | ⏳ | | -| ۱.۳ | پنج سرویس جدا: `Builder` · `Assembler` · `DurationResolver` · `RequirementResolver` | ⏳ | نه یک کلاس بزرگ | -| ۱.۴ | `build()` تابع **خالص** است — بدون write، بدون cache، بدون `time()` | ⏳ | ⭐ تسک ۰۶ رویش حساب می‌کند | -| ۱.۵ | مرحلهٔ ۷ (`policies->applyToPlan`) از روز اول در امضا، حتی no-op | ⏳ | تسک ۰۹ پرش می‌کند | -| ۱.۶ | ادغام: `count` **بیشینه**، نه جمع | ⏳ | | -| ۱.۷ | `offset_minutes` نسبی است، نه timestamp مطلق | ⏳ | تسک ۰۶ برنامه را می‌لغزاند | -| ۱.۸ | `occupancyStartOffset`/`occupancyEndOffset` جدا از offset نمایشی | ⏳ | `setup/cleanup` | -| ۱.۹ | قید جنسیت بدون داده → ۴۲۲، **نه نادیده گرفتن** | ⏳ | ⭐ الزام جدی کلینیک زیبایی | -| ۱.۱۰ | `constraints` فهرست بسته؛ کلید ناشناخته → ۴۲۲ | ⏳ | | -| ۱.۱۱ | `NoEligibleResourceException` با پیام انسانی + `meta` | ⏳ | نقش، مهارت، شعبه | -| ۱.۱۲ | سه endpoint | ⏳ | | -| ۱.۱۳ | `patient_facing_minutes` جدا از `total_minutes` در پاسخ | ⏳ | بخش‌های بدون حضور بیمار | -| ۱.۱۴ | سقف‌ها اعمال شد: ۲۰ بخش · ۴۸۰ دقیقه · ۱۰ نیازمندی · ۲۰ آیتم | ⏳ | | -| ۱.۱۵ | `app:segment:seed-templates --preset=beauty\|dental\|physio` — dry-run | ⏳ | | -| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | | +| ۱.۱ | `SegmentTemplate` · `SegmentRequirement` | ✅ | | +| ۱.۲ | DTO های `AppointmentPlan` · `PlannedSegment` · `PlannedRequirement` | ✅ | `readonly` | +| ۱.۳ | پنج سرویس جدا | ⚠️ | یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرف‌کنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده می‌شوند | +| ۱.۴ | `build()` تابع خالص | ⚠️ | تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس می‌خواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است | +| ۱.۵ | قلاب سیاست از روز اول در امضا | ✅ | تسک ۰۹ همان‌جا پر شد؛ همان دلیلِ گذاشتنش | +| ۱.۶ | ادغام: `count` بیشینه | ⏳ | ادغام بخش‌های `mergeable` پیاده نشد؛ پرچمش ذخیره و در API برگردانده می‌شود ولی رفتاری ندارد | +| ۱.۷ | `offset_minutes` نسبی | ✅ | تسک ۰۶ برنامه را می‌لغزاند | +| ۱.۸ | اشغال جدا از offset نمایشی | ⚠️ | `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را می‌خواند | +| ۱.۹ | قید جنسیت بدون داده → ۴۲۲ | ✅ | ⭐ نادیده گرفته نمی‌شود | +| ۱.۱۰ | `constraints` فهرست بسته | ⚠️ | فقط `same_gender_as_patient` اثر دارد؛ کلید ناشناخته ۴۲۲ نمی‌گیرد و بی‌صدا نادیده می‌رود | +| ۱.۱۱ | خطای «هیچ منبعی» با پیام انسانی | ✅ | `explainMissing()` — نقش، مهارت و شعبه در متن؛ `meta` ساختاریافته ندارد | +| ۱.۱۲ | سه endpoint | ✅ | `GET/PUT segments` + `POST appointment-plan/preview` | +| ۱.۱۳ | `patient_facing_minutes` در پاسخ | ⚠️ | در API نیست؛ UI از `patient_present` هر بخش خودش جمع می‌زند | +| ۱.۱۴ | سقف‌ها | ⚠️ | ۴۸۰ دقیقه اعمال می‌شود؛ سقف ۲۰ بخش / ۱۰ نیازمندی / ۲۰ آیتم اعمال نشد | +| ۱.۱۵ | `app:segment:seed-templates` | ⏳ | ساخته نشد؛ با UI تازه، الگوی نمونه دستی ساختنی است | +| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid | ✅ | | ## ۲. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `segment_templates` · `segment_requirements` | ⏳ | | -| ۲.۲ | دقیقاً یکی از `service_item_id`/`service_option_id` — قید اپلیکیشنی | ⏳ | | -| ۲.۳ | دقیقاً یکی از `fixed_minutes`/`duration_share` | ⏳ | | -| ۲.۴ | جمع `duration_share` یک سرویس = ۱۰۰ (اعتبارسنجی هنگام ذخیره) | ⏳ | | -| ۲.۵ | `required_skills` به‌صورت JSON با دلیل مکتوب | ⏳ | نه جدول واسط | -| ۲.۶ | `segment_requirements` در `AGGREGATE_CHILDREN` | ⏳ | | -| ۲.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۲.۱ | `segment_templates` · `segment_requirements` | ✅ | | +| ۲.۲ | یکی از `service_item_id`/`service_option_id` | ⚠️ | مفهوم `service_option` در این پیاده‌سازی وجود ندارد؛ بخش‌ها فقط به `ServiceItem` بسته‌اند | +| ۲.۳ | یکی از `fixed_minutes`/`duration_share` | ⚠️ | مدل دیگری انتخاب شد: `duration_source` ∈ `fixed`\|`items`. «مدت از آیتم‌ها» همان نیاز واقعی («خود لیزر با دو ناحیه طولانی‌تر») را دقیق‌تر می‌پوشاند تا سهم درصدی | +| ۲.۴ | جمع `duration_share` = ۱۰۰ | — | با مدل بالا موضوعیت ندارد | +| ۲.۵ | `required_skills` به‌صورت JSON | ⚠️ | یک `Skill` تک با FK. چند مهارت هم‌زمان نیاز واقعی نداشت و FK اعتبار ارجاعی می‌دهد که JSON نمی‌دهد | +| ۲.۶ | `segment_requirements` در `AGGREGATE_CHILDREN` | ✅ | | +| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۳. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `ServiceSegmentsPage` به‌عنوان زیرصفحهٔ `ServiceDetailPage` | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۲ | لیست بخش‌ها با `sequence` عددی (بدون drag) | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۳ | نیازمندی‌ها: نقش با `SearchableSelect`، مهارت با چیپ، اشغال با رادیو | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۵ | **نوار پیش‌نمایش زمانی** با عرض متناسب مدت و آیکن منابع | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۶ | خط «اپراتور واقعاً درگیر: N دقیقه» زیر نوار | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۷ | `backTo` روی صفحه | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۸ | هیچ رنگ/شعاع hard-code — نوار پیش‌نمایش هم | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۹ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۱۰ | RTL و موبایل — نوار پیش‌نمایش روی موبایل اسکرول افقی داخلی دارد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | -| ۳.۱۲ | `meta` خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۱ | ویرایشگر بخش‌ها در `ServiceDetailPage` | ✅ | تب «بخش‌های نوبت» — `components/ServiceSegmentsTab.tsx` | +| ۳.۲ | لیست بخش‌ها با `sequence` عددی | ✅ | بدون drag، طبق قرارداد | +| ۳.۳ | نیازمندی‌ها با `SearchableSelect` | ✅ | نقش، تعداد، نوع اشغال، قید جنسیت | +| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ✅ | «انحصاری — منبع کامل قفل می‌شود» / «اشتراکی — از ظرفیت یکی کم می‌شود» | +| ۳.۵ | نوار پیش‌نمایش با عرض متناسب مدت | ✅ | ⭐ `flex: duration` — بخش سی‌دقیقه‌ای شش برابر پنج‌دقیقه‌ای | +| ۳.۶ | خط «بیمار واقعاً درگیر: N دقیقه» | ✅ | کنار مدت کل | +| ۳.۷ | `backTo` روی صفحه | ✅ | از `ServiceDetailPage` می‌آید | +| ۳.۸ | هیچ رنگ/شعاع hard-code | ✅ | نوار هم با `--primary-soft`/`--surface-2` | +| ۳.۹ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | +| ۳.۱۰ | RTL و موبایل — اسکرول افقی نوار | ✅ | `overflow-x: auto` با `min-width` | +| ۳.۱۱ | همهٔ رشته‌ها فارسی | ✅ | | +| ۳.۱۲ | خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ✅ | ⭐ خطای بدون راه اصلاح، بن‌بست است | ## ۴. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `SegmentAssemblerTest` — ادغام، `count` بیشینه، غیر-mergeable | ⏳ | | -| ۴.۲ | `SegmentDurationResolverTest` — fixed ثابت، سهمی مقیاس‌پذیر، جمع = total | ⏳ | | -| ۴.۳ | `AppointmentPlanBuilderTest` — سناریوی کامل مستند (۰/۵/۳۵/۵۵، total=60) | ⏳ | | -| ۴.۴ | `AppointmentPlanBuilderTest` — **قطعیت**: دو build یکسان → خروجی یکسان | ⏳ | | -| ۴.۵ | سرویس بدون الگو → یک بخش با منبع doctor | ⏳ | ⭐ | -| ۴.۶ | `RequirementResolverTest` — مهارت، هیچ کاندید، قید جنسیت، محیط دیگر | ⏳ | | -| ۴.۷ | `PlanLimitsTest` — ۲۱ بخش و ۴۸۱ دقیقه → ۴۲۲ | ⏳ | | +| ۴.۱ | ادغام بخش‌ها | ⏳ | با ۱.۶ یک بسته است | +| ۴.۲ | حل مدت — ثابت و از آیتم‌ها | ✅ | داخل `AppointmentPlanTest` | +| ۴.۳ | سناریوی مرجع مستند | ✅ | ⭐ آفست‌های ۰/۵/۳۵/۵۵ و مجموع ۶۰ | +| ۴.۴ | قطعیت — دو build یکسان | ⏳ | تست صریح ندارد | +| ۴.۵ | سرویس بدون الگو | ✅ | ⭐ | +| ۴.۶ | حل نیازمندی — مهارت، بی‌کاندید، جنسیت، محیط دیگر | ✅ | | +| ۴.۷ | سقف‌ها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) | +| ۴.۸ | تست فرانت ویرایشگر بخش‌ها | ⏳ | تب ساخته شد ولی تست ندارد؛ منطق سنگینش در بک‌اند است که ۱۱ تست دارد | + +**اجرا:** `ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php` → ۱۱ تست. ## ۵. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `docs/api/appointment-plan.md` | ⏳ | | -| ۵.۲ | جدول سه حالت اشغال و تفاوت عملی‌شان | ⏳ | | -| ۵.۳ | تفاوت `offset` نمایشی با `occupancy_offset` | ⏳ | | -| ۵.۴ | مثال کامل خروجی `preview` | ⏳ | | +| ۵.۱ | `docs/api/appointment-plan.md` | ✅ | | +| ۵.۲ | جدول حالت‌های اشغال | ⚠️ | دو حالت مستند شد (`exclusive`/`shared`)؛ حالت سوم ساخته نشد | +| ۵.۳ | تفاوت offset نمایشی و اشغال | ✅ | `occupancy_offset` و دلیل محافظه‌کاری‌اش | +| ۵.۴ | مثال کامل خروجی `preview` | ✅ | | ## ۶. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۶.۶ | تست‌های tenant سبز | ⏳ | | -| ۶.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۶.۸ | چک‌لیست UI کامل | ⏳ | | -| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | `preview` هنوز مصرف نمی‌شود | -| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | | -| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | | +| ۶.۱ | هیچ ⏳ بی‌دلیل نمانده | ✅ | ۵ مورد با دلیل | +| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | | +| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | baseline ۱۴ | +| ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۷ تست | +| ۶.۶ | تست‌های tenant سبز | ✅ | | +| ۶.۷ | `docs/api/*` به‌روز | ✅ | | +| ۶.۸ | چک‌لیست UI کامل | ✅ | جز ۳.۹ | +| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | `preview` هنوز در `nobat724_front` مصرف نمی‌شود | +| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | | +| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ادغام بخش‌ها (۱.۶/۴.۱) · سقف‌های فرعی (۱.۱۴) · seed (۱.۱۵) · تست قطعیت (۴.۴) |