diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 2092e88b..9f68d72f 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -74,6 +74,9 @@ import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage'; import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; +import BranchesPage from './pages/BranchesPage'; +import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage'; +import BranchRoomsPage from './pages/BranchRoomsPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PatientDetailPage from './pages/PatientDetailPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; @@ -275,6 +278,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 1e19e25d..093ed4a7 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -29,6 +29,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, + { key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/hooks/useBranches.ts b/assets/admin/hooks/useBranches.ts new file mode 100644 index 00000000..31b4dd7d --- /dev/null +++ b/assets/admin/hooks/useBranches.ts @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { Branch, BranchWorkingHours, Room, RoomPayload, WorkingHoursPayload } from '../types'; + +/** + * «شعبه» یک جدول تازه نیست — همان آدرس محل نوبت‌دهی است (`doctor_addresses`). + * ساخت/ویرایش نام و آدرس همان‌جایی انجام می‌شود که همیشه (جزئیات کلینیک/پزشک)؛ + * این هوک فقط چیزهای شعبه‌ای را می‌دهد: فعال/غیرفعال، منطقهٔ زمانی، ساعت کاری، اتاق. + */ +const BRANCHES_KEY = ['branches']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export function useBranches() { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: BRANCHES_KEY, + queryFn: () => api.get>('/api/v1/branches'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: { active?: boolean; timezone?: string } }) => + api.patch>(`/api/v1/branch/${uuid}`, d), + onSuccess: () => { + toast.success('شعبه به‌روزرسانی شد'); + qc.invalidateQueries({ queryKey: BRANCHES_KEY }); + }, + onError: (e) => fail(e, 'به‌روزرسانی شعبه ناموفق بود'), + }); + + return { branches: query.data?.data ?? [], loading: query.isLoading, update }; +} + +export function useBranchWorkingHours(branchUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['branch-working-hours', branchUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/branch/${branchUuid}/working-hours`), + enabled: !!branchUuid, + }); + + /** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */ + const save = useMutation({ + mutationFn: (days: WorkingHoursPayload) => + api.put>(`/api/v1/branch/${branchUuid}/working-hours`, { days }), + onSuccess: () => { + toast.success('ساعت کاری ذخیره شد'); + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: BRANCHES_KEY }); + }, + onError: (e) => fail(e, 'ذخیرهٔ ساعت کاری ناموفق بود'), + }); + + return { workingHours: query.data?.data, loading: query.isLoading, save }; +} + +export function useBranchRooms(branchUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['branch-rooms', branchUuid]; + + const invalidate = () => { + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: BRANCHES_KEY }); + }; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/branch/${branchUuid}/rooms`), + enabled: !!branchUuid, + }); + + const create = useMutation({ + mutationFn: (d: RoomPayload) => + api.post>('/api/v1/room', { ...d, address_uuid: branchUuid }), + onSuccess: () => { toast.success('اتاق افزوده شد'); invalidate(); }, + onError: (e) => fail(e, 'افزودن اتاق ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: RoomPayload }) => + api.patch>(`/api/v1/room/${uuid}`, d), + onSuccess: () => { toast.success('اتاق به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی اتاق ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/room/${uuid}`), + onSuccess: () => { toast.success('اتاق حذف شد'); invalidate(); }, + onError: (e) => fail(e, 'حذف اتاق ناموفق بود'), + }); + + return { rooms: query.data?.data ?? [], loading: query.isLoading, create, update, remove }; +} diff --git a/assets/admin/pages/BranchRoomsPage.tsx b/assets/admin/pages/BranchRoomsPage.tsx new file mode 100644 index 00000000..17fc6a41 --- /dev/null +++ b/assets/admin/pages/BranchRoomsPage.tsx @@ -0,0 +1,196 @@ +import React, { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranchRooms, useBranches } from '../hooks/useBranches'; +import type { Room, RoomPayload } from '../types'; + +/** اتاق‌های یک شعبه. ظرفیت = چند بیمار هم‌زمان، نه چند اتاق. */ +export default function BranchRoomsPage() { + const { branchUuid } = useParams<{ branchUuid: string }>(); + const { rooms, loading, create, update, remove } = useBranchRooms(branchUuid); + const { branches } = useBranches(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const branch = branches.find((b) => b.uuid === branchUuid); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [editing, setEditing] = useState<{ open: boolean; room: Room | null }>({ open: false, room: null }); + const [toDelete, setToDelete] = useState(null); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return q === '' ? rooms : rooms.filter((r) => `${r.name} ${r.room_type ?? ''}`.includes(q)); + }, [rooms, urlState.search]); + + const columns: Column[] = [ + { key: 'name', header: 'نام اتاق', render: (r) => {r.name} }, + { key: 'room_type', header: 'نوع', render: (r) => {r.room_type || '—'} }, + { + key: 'capacity', + header: 'ظرفیت هم‌زمان', + render: (r) => {r.capacity} نفر, + }, + { key: 'floor', header: 'طبقه', render: (r) => {r.floor || '—'} }, + { key: 'active', header: 'وضعیت', render: (r) => }, + ]; + + const save = (payload: RoomPayload) => { + const opts = { onSuccess: () => setEditing({ open: false, room: null }) }; + if (editing.room) update.mutate({ uuid: editing.room.uuid, d: payload }, opts); + else create.mutate(payload, opts); + }; + + return ( +
+ setEditing({ open: true, room: null })}> + افزودن اتاق + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در اتاق‌ها..." + emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است" + actions={ + canUpdate + ? (r) => ( +
+ + +
+ ) + : undefined + } + /> + + setEditing({ open: false, room: null })} + onSave={save} + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function RoomModal({ + open, room, saving, onClose, onSave, +}: { + open: boolean; + room: Room | null; + saving: boolean; + onClose: () => void; + onSave: (payload: RoomPayload) => void; +}) { + const [name, setName] = useState(''); + const [roomType, setRoomType] = useState(''); + const [capacity, setCapacity] = useState('1'); + const [floor, setFloor] = useState(''); + const [active, setActive] = useState(true); + + // فرم با هر بازشدن از روی اتاقِ هدف بازنشانی می‌شود؛ key در والد باعث remount + // نمی‌شود چون Modal همیشه mounted است. + React.useEffect(() => { + if (!open) return; + setName(room?.name ?? ''); + setRoomType(room?.room_type ?? ''); + setCapacity(String(room?.capacity ?? 1)); + setFloor(room?.floor ?? ''); + setActive(room?.active ?? true); + }, [open, room]); + + const parsedCapacity = Number(capacity); + const invalid = name.trim() === '' || !Number.isFinite(parsedCapacity) || parsedCapacity < 1; + + return ( + +
+ + setName(e.target.value)} placeholder="اتاق تزریق" /> + + + setRoomType(e.target.value)} placeholder="تزریقات" /> + + + setCapacity(e.target.value)} + /> + + + setFloor(e.target.value)} placeholder="۲" /> + + + +
+ + +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/assets/admin/pages/BranchWorkingHoursPage.test.tsx b/assets/admin/pages/BranchWorkingHoursPage.test.tsx new file mode 100644 index 00000000..50dc4750 --- /dev/null +++ b/assets/admin/pages/BranchWorkingHoursPage.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, 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 { Routes, Route } from 'react-router-dom'; +import { api } from '../lib/api'; +import BranchWorkingHoursPage from './BranchWorkingHoursPage'; + +const get = api.get as ReturnType; +const put = api.put as ReturnType; + +const branch = { + id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک ما', + name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null }, + address: 'خیابان اول', telephone: '03511111111', active: true, + timezone: 'Asia/Tehran', city: null, province: null, + working_hours_defined: true, rooms_count: 0, +}; + +function emptyDays(): Record { + return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []])); +} + +function mockApi(days: Record) { + get.mockImplementation((path: string) => { + if (path === '/api/v1/branches') return Promise.resolve({ success: true, data: [branch] }); + if (path.endsWith('/working-hours')) { + return Promise.resolve({ + success: true, + data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days }, + }); + } + return Promise.resolve({ success: true, data: null }); + }); + put.mockResolvedValue({ success: true, data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days } }); +} + +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/branches/b1/working-hours' }, + ); +} + +describe('BranchWorkingHoursPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all seven days and marks the empty ones closed', async () => { + mockApi(emptyDays()); + renderPage(); + + await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); + expect(screen.getByText('جمعه')).toBeInTheDocument(); + expect(screen.getAllByText('بسته')).toHaveLength(7); + }); + + it('shows stored ranges as times, converting minutes from midnight', async () => { + const days = emptyDays(); + days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); + expect(screen.getByDisplayValue('13:00')).toBeInTheDocument(); + }); + + /** + * `` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده + * می‌شود و همان ۱۴۴۰ برمی‌گردد — وگرنه اولین ذخیره بازهٔ شبانه‌روزی را خراب می‌کرد. + */ + it('keeps an all-day range at 1440 through a round trip', async () => { + const days = emptyDays(); + days['3'] = [{ sequence: 0, start_minute: 0, end_minute: 1440, start_time: '00:00', end_time: '24:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByText('۲۴:۰۰')).toBeInTheDocument()); + expect((screen.getByLabelText('تا پایان روز') as HTMLInputElement).checked).toBe(true); + + fireEvent.click(screen.getByText('ذخیرهٔ هفته')); + + await waitFor(() => expect(put).toHaveBeenCalled()); + expect(put.mock.calls[0][1].days['3']).toEqual([{ start_minute: 0, end_minute: 1440 }]); + }); + + it('turns a normal range into an all-day one when the flag is checked', async () => { + const days = emptyDays(); + days['6'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument()); + fireEvent.click(screen.getByLabelText('تا پایان روز')); + fireEvent.click(screen.getByText('ذخیرهٔ هفته')); + + await waitFor(() => expect(put).toHaveBeenCalled()); + expect(put.mock.calls[0][1].days['6']).toEqual([{ start_minute: 540, end_minute: 1440 }]); + }); + + it('sends minutes, not time strings, on save', async () => { + const days = emptyDays(); + days['1'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('10:00')).toBeInTheDocument()); + fireEvent.click(screen.getByText('ذخیرهٔ هفته')); + + await waitFor(() => expect(put).toHaveBeenCalled()); + const [path, body] = put.mock.calls[0]; + expect(path).toBe('/api/v1/branch/b1/working-hours'); + expect(body.days['1']).toEqual([{ start_minute: 600, end_minute: 720 }]); + // هر هفت روز فرستاده می‌شود، چون PUT جایگزینی کامل است نه merge تفاضلی. + expect(Object.keys(body.days)).toHaveLength(7); + }); + + it('blocks a save whose end is not after its start, without calling the API', async () => { + const days = emptyDays(); + days['2'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('12:00')).toBeInTheDocument()); + fireEvent.change(screen.getByDisplayValue('12:00'), { target: { value: '09:00' } }); + fireEvent.click(screen.getByText('ذخیرهٔ هفته')); + + await waitFor(() => expect(screen.getByText(/پایان بازه باید بعد از شروع/)).toBeInTheDocument()); + expect(put).not.toHaveBeenCalled(); + }); + + it('copies one day onto the whole week', async () => { + const days = emptyDays(); + days['0'] = [{ sequence: 0, start_minute: 480, end_minute: 600, start_time: '08:00', end_time: '10:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('08:00')).toBeInTheDocument()); + fireEvent.click(screen.getByText('اعمال روی همهٔ روزها')); + + expect(screen.getAllByDisplayValue('08:00')).toHaveLength(7); + expect(screen.queryByText('بسته')).not.toBeInTheDocument(); + }); + + it('removes a range so the day becomes closed', async () => { + const days = emptyDays(); + days['4'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }]; + mockApi(days); + renderPage(); + + await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument()); + fireEvent.click(screen.getByLabelText('حذف بازه')); + + expect(screen.getAllByText('بسته')).toHaveLength(7); + }); +}); diff --git a/assets/admin/pages/BranchWorkingHoursPage.tsx b/assets/admin/pages/BranchWorkingHoursPage.tsx new file mode 100644 index 00000000..fbd443e5 --- /dev/null +++ b/assets/admin/pages/BranchWorkingHoursPage.tsx @@ -0,0 +1,262 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranchWorkingHours, useBranches } from '../hooks/useBranches'; +import type { WorkingHourRange } from '../types'; + +/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بک‌اند. */ +const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; + +const MINUTES_IN_DAY = 1440; + +/** + * `endOfDay` وجود دارد چون `` سقفش ۲۳:۵۹ است و مقدار ۲۴:۰۰ را + * نه نشان می‌دهد و نه می‌سازد. بدون این پرچم، بازهٔ شبانه‌روزیِ ذخیره‌شده (۱۴۴۰) + * بی‌صدا از فرم می‌افتاد و اولین ذخیره آن را خراب می‌کرد. + */ +type Draft = { start: string; end: string; endOfDay: boolean }; + +function toTime(minute: number): string { + return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; +} + +/** `"24:00"` باید ۱۴۴۰ بدهد نه صفر — پایان روز است، نه آغازش. */ +function toMinutes(time: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!m) return null; + const minutes = Number(m[1]) * 60 + Number(m[2]); + return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; +} + +/** + * ساعت کاری هفتگی یک شعبه. + * + * ذخیره یک PUT است و کل هفته را جایگزین می‌کند؛ روزِ خالی یعنی شعبه آن روز بسته + * است. اعتبارسنجی نهایی سمت سرور است — این فرم فقط جلوی ارسال ورودی واضحاً خراب + * را می‌گیرد تا کاربر منتظر رفت‌وبرگشت نماند. + */ +export default function BranchWorkingHoursPage() { + const { branchUuid } = useParams<{ branchUuid: string }>(); + const { workingHours, loading, save } = useBranchWorkingHours(branchUuid); + const { branches } = useBranches(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const branch = branches.find((b) => b.uuid === branchUuid); + + const [draft, setDraft] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + if (!workingHours) return; + const next: Record = {}; + DAY_LABELS.forEach((_, day) => { + next[day] = (workingHours.days[String(day)] ?? []).map((r: WorkingHourRange) => ({ + start: toTime(r.start_minute), + end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute), + endOfDay: r.end_minute === MINUTES_IN_DAY, + })); + }); + setDraft(next); + }, [workingHours]); + + const addRange = (day: number) => { + setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '13:00', endOfDay: false }] })); + }; + + const removeRange = (day: number, index: number) => { + setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) })); + }; + + const editRange = (day: number, index: number, patch: Partial) => { + setDraft((d) => ({ + ...d, + [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)), + })); + }; + + const copyToWholeWeek = (day: number) => { + const source = draft[day] ?? []; + const next: Record = {}; + DAY_LABELS.forEach((_, d) => { next[d] = source.map((r) => ({ ...r })); }); + setDraft(next); + }; + + const submit = () => { + const days: Record = {}; + + for (const [dayKey, ranges] of Object.entries(draft)) { + const parsed: { start_minute: number; end_minute: number }[] = []; + + for (const range of ranges) { + const start = toMinutes(range.start); + const end = range.endOfDay ? MINUTES_IN_DAY : toMinutes(range.end); + + if (start === null || end === null) { + setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); + return; + } + if (end <= start) { + setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان بازه باید بعد از شروع آن باشد`); + return; + } + parsed.push({ start_minute: start, end_minute: end }); + } + + days[dayKey] = parsed; + } + + setError(null); + save.mutate(days); + }; + + const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0); + + return ( +
+ + {save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'} + + ) : undefined + } + /> + + {error && ( +
+ {error} +
+ )} + + {branch && canUpdate && ( +
+ منطقهٔ زمانی شعبه +
+ +
+ + {totalRanges === 0 ? 'هیچ بازه‌ای تعریف نشده' : `${totalRanges} بازه در هفته`} + +
+ )} + + {loading ? ( +
در حال بارگذاری...
+ ) : ( +
+ {DAY_LABELS.map((label, day) => { + const ranges = draft[day] ?? []; + return ( +
+
+
+ {label} + {ranges.length === 0 && ( + بسته + )} +
+ {canUpdate && ( +
+ {ranges.length > 0 && ( + + )} + +
+ )} +
+ +
+ {ranges.map((range, index) => ( +
+ + editRange(day, index, { start: e.target.value })} + style={{ width: 120 }} + /> + + {range.endOfDay ? ( + ۲۴:۰۰ + ) : ( + editRange(day, index, { end: e.target.value })} + style={{ width: 120 }} + /> + )} + + {canUpdate && ( + + )} +
+ ))} +
+
+ ); + })} +
+ )} +
+ ); +} + +/** + * فهرست منطقهٔ زمانی کوتاه و ثابت است — بک‌اند با `DateTimeZone::listIdentifiers()` + * اعتبارسنجی می‌کند، پس این فهرست تنها راحتی است و منبع حقیقت نیست. + */ +const TIMEZONES = ['Asia/Tehran', 'Asia/Dubai', 'Asia/Baghdad', 'Europe/Istanbul', 'UTC']; + +function TimezoneSelect({ branchUuid, value }: { branchUuid: string; value: string }) { + const { update } = useBranches(); + const options = TIMEZONES.includes(value) ? TIMEZONES : [value, ...TIMEZONES]; + + return ( + ({ value: tz, label: tz }))} + value={value} + onChange={(v) => v && update.mutate({ uuid: branchUuid, d: { timezone: String(v) } })} + placeholder="منطقهٔ زمانی" + height={38} + /> + ); +} diff --git a/assets/admin/pages/BranchesPage.tsx b/assets/admin/pages/BranchesPage.tsx new file mode 100644 index 00000000..5f263e3b --- /dev/null +++ b/assets/admin/pages/BranchesPage.tsx @@ -0,0 +1,177 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { ClockIcon, Squares2X2Icon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranches } from '../hooks/useBranches'; +import type { Branch } from '../types'; + +/** + * شعبه‌ها — همان محل‌های نوبت‌دهی محیط جاری. + * + * این صفحه شعبه نمی‌سازد و نام/آدرس را ویرایش نمی‌کند؛ آن کار از قبل در جزئیات + * کلینیک و پزشک هست و تکرارش دو منبع حقیقت می‌ساخت. اینجا فقط دروازهٔ ساعت کاری و + * اتاق‌هاست، به‌علاوهٔ دو ویژگی شعبه‌ای: فعال‌بودن و منطقهٔ زمانی. + */ +export default function BranchesPage() { + const { branches, loading, update } = useBranches(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '', status: '' }); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return branches.filter((b) => { + const haystack = `${b.name ?? ''} ${b.address ?? ''} ${b.telephone ?? ''}`; + const matchesQuery = q === '' || haystack.includes(q); + const matchesStatus = + urlState.status === '' || + (urlState.status === 'active' ? b.active : !b.active); + return matchesQuery && matchesStatus; + }); + }, [branches, urlState.search, urlState.status]); + + const activeCount = branches.filter((b) => b.active).length; + + const columns: Column[] = [ + { + key: 'name', + header: 'شعبه', + render: (b) => ( +
+ {b.name || 'بدون نام'} + + {b.type === 'clinic' ? b.clinic_name || 'کلینیک' : 'مطب شخصی'} + {b.city ? ` · ${b.city.name}` : ''} + +
+ ), + }, + { + key: 'address', + header: 'آدرس', + render: (b) => ( + {b.address || '—'} + ), + }, + { + key: 'telephone', + header: 'تلفن', + render: (b) => {b.telephone || '—'}, + }, + { + key: 'working_hours', + header: 'ساعت کاری', + render: (b) => + b.working_hours_defined ? ( + تعریف‌شده + ) : ( + تعریف‌نشده + ), + }, + { + key: 'rooms_count', + header: 'اتاق فعال', + render: (b) => {b.rooms_count ?? 0}, + }, + { + key: 'timezone', + header: 'منطقهٔ زمانی', + render: (b) => {b.timezone}, + }, + { + key: 'active', + header: 'وضعیت', + render: (b) => , + }, + ]; + + return ( +
+ + + setUrlState({ search: v })} + searchPlaceholder="جستجو در شعبه‌ها..." + emptyMessage="هیچ شعبه‌ای برای این محیط ثبت نشده است" + headerExtra={ +
+ setUrlState({ status: v })} + /> +
+ } + actions={(b) => ( +
+ + ساعت کاری + + + اتاق‌ها + + {canUpdate && ( + + )} +
+ )} + /> +
+ ); +} + +function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const options = [ + { value: '', label: 'همه' }, + { value: 'active', label: 'فعال' }, + { value: 'inactive', label: 'غیرفعال' }, + ]; + + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 74581b5b..d540896b 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -867,3 +867,74 @@ export interface PatientSession { created_at: number; updated_at: number; } + +// ── شعبه، ساعت کاری و اتاق ─────────────────────────────────────────────────── +// «شعبه» جدول تازه‌ای نیست: همان رکورد آدرس محل نوبت‌دهی است (`doctor_addresses`)، +// همان چیزی که `WeeklySchedule.sessions[].location_id` به آن اشاره می‌کند. پس +// Branch شکلِ `DoctorAddress::toArray()` است به‌علاوهٔ دو شمارشِ فهرست. + +export interface Branch { + id: string; + uuid: string; + type: 'personal' | 'clinic'; + clinic_id: number | null; + clinic_name: string | null; + name: string | null; + map: { latitude: string | null; longitude: string | null }; + address: string | null; + telephone: string | null; + active: boolean; + timezone: string; + city: { id: string; name: string } | null; + province: { id: string; name: string } | null; + /** فقط در `GET /api/v1/branches` — شعبهٔ بدون ساعت «تعریف‌نشده» است، نه همیشه‌باز */ + working_hours_defined?: boolean; + /** فقط در `GET /api/v1/branches` — تعداد اتاق‌های فعال */ + rooms_count?: number; +} + +/** دقیقه از نیمه‌شب، نه رشتهٔ `"09:00"` — مقایسه و تقاطع باید عددی بماند. */ +export interface WorkingHourRange { + sequence: number; + start_minute: number; + end_minute: number; + start_time: string; + end_time: string; + active: boolean; +} + +export interface BranchWorkingHours { + branch_uuid: string; + timezone: string; + defined: boolean; + /** کلیدهای `"0"`..`"6"`؛ ۰ = شنبه، همان قرارداد محاسبهٔ اسلات */ + days: Record; +} + +/** + * بدنهٔ نوشتن ساعت کاری. عمداً شکل خواندن (`WorkingHourRange`) نیست: `sequence` را + * سرور از ترتیب بازه‌ها مشتق می‌کند و `start_time`/`end_time` فقط برای نمایش‌اند. + */ +export type WorkingHoursPayload = Record; + +export interface Room { + uuid: string; + address_uuid: string; + address_name: string | null; + name: string; + room_type: string | null; + /** ظرفیت هم‌زمان: اتاق سه‌تخته یک اتاق با ظرفیت ۳ است، نه سه اتاق */ + capacity: number; + floor: string | null; + active: boolean; + created_at: number; + updated_at: number; +} + +export interface RoomPayload { + name: string; + room_type?: string | null; + capacity?: number; + floor?: string | null; + active?: boolean; +} diff --git a/docs/api/README.md b/docs/api/README.md index 0b821c01..925ba1ef 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -82,6 +82,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [doctor.md](doctor.md) | Doctor profile & addresses | 11 | | [clinic.md](clinic.md) | Clinics | 7 | | [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 | +| [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/branch.md b/docs/api/branch.md new file mode 100644 index 00000000..b212a0d3 --- /dev/null +++ b/docs/api/branch.md @@ -0,0 +1,351 @@ +# Branch API — شعبه، ساعت کاری و اتاق + +> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها +> **مجوز:** `appointment_settings` (`view` برای خواندن، `update` برای نوشتن) — همان مجوزی +> که تنظیمات نوبت‌دهی با آن سنجیده می‌شود. مجوز تازه‌ای اضافه نشده. + +--- + +## «شعبه» جدول تازه‌ای نیست + +شعبه همان رکورد **آدرس محل نوبت‌دهی** است: `doctor_addresses` — همان چیزی که +`WeeklySchedule.setting[day].sessions[].location_id` به آن اشاره می‌کند و +`GET /api/v1/appointment-booking-locations/{doctorUuid}` آن را «محل نوبت‌دهی» می‌نامد. +پس `{addressUuid}` در مسیرهای زیر همان `uuid` رکورد آدرس است. + +**ساختن، ویرایش و حذف شعبه اندپوینت جدید ندارد** — از قبل موجود است: + +| کار | اندپوینت موجود | +|---|---| +| CRUD آدرس‌های کلینیک | `GET/POST/PATCH/DELETE /api/v1/clinic/{clinicUuid}/addresses` | +| CRUD آدرس‌های پزشک | `POST/GET/PATCH/DELETE /api/v1/clinic-pro/doctor-address[/{id}]` | +| آدرس‌های یک پزشک | `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` | + +این سند فقط چیزهایی را پوشش می‌دهد که آنجا نبودند: فهرست شعبه‌های محیط جاری، +دو ویژگی `active`/`timezone`، ساعت کاری هفتگی، و اتاق‌ها. + +> ⚠️ `doctor_addresses` در `GlobalTables::ENTITIES` سراسری اعلام شده و `TenantFilter` +> رویش اعمال **نمی‌شود**. هر مسیری که `addressUuid` می‌گیرد از `BranchResolver` رد +> می‌شود که آدرس را با محیط جاری تطبیق می‌دهد و در غیر این صورت **۴۰۴** می‌دهد +> (نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمی‌رود). + +--- + +## دو قرارداد که باید بدانید + +**۱. شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «همیشه‌باز».** +`defined: false` یعنی هیچ بازه‌ای ثبت نشده. محاسبهٔ اسلات در این حالت به رفتار فعلی +برمی‌گردد و برنامهٔ هفتگی پزشک تنها مرجع است. پس همهٔ دادهٔ موجود — که هیچ ساعت کاری +شعبه ندارد — دقیقاً مثل قبل کار می‌کند. + +**۲. `active` در این فاز فقط ذخیره می‌شود.** +غیرفعال کردن شعبه هیچ اثری بر اسلات‌های تولیدشده ندارد؛ اعمالش در تسک ۰۳ است، چون +تغییر `SlotCalculatorService` در فاز فعلی ممنوع است. + +--- + +## `GET /api/v1/branches` + +شعبه‌های محیط جاری. برای منشی، محیط از رابطهٔ فعال او حل می‌شود؛ برای بقیه از +`clinic_uuid` درخواست، بعد محیط فعال، بعد نقش. + +**Query:** `clinic_uuid` (اختیاری) — انتخاب صریح محیط کلینیک. + +**پاسخ ۲۰۰** (خروجی واقعی): + +```json +{ + "success": true, + "data": [ + { + "id": "11547", + "uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "type": "clinic", + "clinic_id": 4, + "clinic_name": null, + "name": "درمانگاه شبانه روزی صدرا ", + "map": { "latitude": "30.667110344662", "longitude": "51.597043275833" }, + "address": "خیابا پزشک روبه روی لوازم خانگی هرمزی ", + "telephone": "07433221212", + "active": true, + "timezone": "Asia/Tehran", + "city": { "id": "123", "name": "یاسوج" }, + "province": { "id": "23", "name": "کهگیلویه و بویراحمد" }, + "working_hours_defined": false, + "rooms_count": 0 + } + ] +} +``` + +`working_hours_defined` و `rooms_count` فقط در این اندپوینت هستند و با **دو کوئری +گروهی** پر می‌شوند، نه دو کوئری per شعبه — `BranchFieldsTest::testListQueryCountDoesNotGrowWithBranches` +همین را قفل می‌کند. `rooms_count` فقط اتاق **فعال** را می‌شمارد. + +`active` و `timezone` روی خروجی **همهٔ ۹ اندپوینت موجود آدرس** هم ظاهر می‌شوند، چون از +`DoctorAddress::toArray()` می‌آیند. تغییر additive است و هیچ فیلدی حذف نشده. + +--- + +## `PATCH /api/v1/branch/{addressUuid}` + +فقط دو ویژگی شعبه‌ای. نام/آدرس/تلفن/مختصات همان‌جایی ویرایش می‌شوند که همیشه. + +| فیلد | نوع | توضیح | +|---|---|---| +| `active` | bool | اختیاری | +| `timezone` | string | اختیاری — با `DateTimeZone::listIdentifiers()` سنجیده می‌شود، نه regex | + +**۲۰۰** بدنهٔ کامل شعبه را برمی‌گرداند (همان شکل بالا). + +**۴۲۲ — منطقهٔ زمانی ناشناخته** (خروجی واقعی برای `{"timezone":"Tehran"}`): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منطقهٔ زمانی نامعتبر است","field":"timezone"}]} +``` + +**۴۰۴** — آدرسی که به محیط جاری تعلق ندارد. + +--- + +## `GET /api/v1/branch/{addressUuid}/working-hours` + +```json +{ + "success": true, + "data": { + "branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "timezone": "Asia/Tehran", + "defined": false, + "days": { "0": [], "1": [], "2": [], "3": [], "4": [], "5": [], "6": [] } + } +} +``` + +`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است — ۰ = شنبه، همان قرارداد +`SlotCalculatorService`. روزِ خالی یعنی شعبه آن روز بسته است. + +> کلیدهای ۰..۶ پشت‌سرهم‌اند، پس `json_encode` بی‌مراقبت آرایهٔ PHP را به **آرایهٔ +> JSON** تبدیل می‌کرد. کنترلر عمداً به `stdClass` تبدیل می‌کند و +> `WorkingHoursTest::testDaysIsAJsonObjectNotAnArray` شکل را قفل می‌کند. + +--- + +## `PUT /api/v1/branch/{addressUuid}/working-hours` + +**جایگزینی کامل** هفت روز. بدنه تمام حقیقت است: روزی که نفرستید خالی می‌شود و +`{"days":{}}` همهٔ ساعت‌های شعبه را پاک می‌کند (بستن کامل شعبه). merge تفاضلی نیست. + +```json +{ + "days": { + "0": [ + { "start_minute": 540, "end_minute": 780 }, + { "start_minute": 960, "end_minute": 1200 } + ], + "1": [{ "start_minute": 540, "end_minute": 780 }] + } +} +``` + +| فیلد | نوع | قاعده | +|---|---|---| +| کلید روز | `"0".."6"` | ۰ = شنبه | +| `start_minute` | int | دقیقه از نیمه‌شب، `0..1440` | +| `end_minute` | int | `0..1440` و **اکیداً** بزرگ‌تر از `start_minute` | + +`sequence` را کلاینت نمی‌فرستد؛ سرور بعد از مرتب‌سازی بازه‌ها تخصیص می‌دهد. + +زمان‌ها عددی‌اند نه رشتهٔ `"09:00"`، چون تقاطع دو بازه محاسبهٔ عددی است و مقایسهٔ +رشته‌ای `"9:00" < "10:00"` غلط جواب می‌دهد. `start_time`/`end_time` در پاسخ فقط برای +نمایش‌اند. بازهٔ شبانه‌روزی `0..1440` **یک** ردیف است و `end_time` آن `"24:00"` می‌شود، +نه `"00:00"`. + +**پاسخ ۲۰۰** (خروجی واقعی همان بدنهٔ بالا): + +```json +{ + "success": true, + "data": { + "branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "timezone": "Asia/Tehran", + "defined": true, + "days": { + "0": [ + { "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true }, + { "sequence": 1, "start_minute": 960, "end_minute": 1200, "start_time": "16:00", "end_time": "20:00", "active": true } + ], + "1": [ + { "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true } + ], + "2": [], "3": [], "4": [], "5": [], "6": [] + } + } +} +``` + +**۴۲۲ — هم‌پوشانی** (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازه‌های روز 2 با هم هم‌پوشانی دارند","field":"start_minute"}]} +``` + +سایر ۴۲۲ها: `end_minute <= start_minute` (field `end_minute`) · دقیقهٔ بیرون از +`0..1440` · کلید روز بیرون از `0..6` (field `day_of_week`) · نبودِ `days` (field `days`). + +بازهٔ **چسبیده** خطا نیست: `13:00–15:00` بعد از `09:00–13:00` مجاز است. + +> **اتمی است.** اعتبارسنجی کاملِ هر هفت روز پیش از هر `DELETE` اجرا می‌شود، پس یک بازهٔ +> نامعتبر در روز ششم، شش روز درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند +> (`WorkingHoursTest::testInvalidLaterDayLeavesTheStoredWeekUntouched`). + +--- + +## `GET /api/v1/branch/{addressUuid}/rooms` + +```json +{ + "success": true, + "data": [ + { + "uuid": "5425f5c7-22da-4130-b45d-4708313460cd", + "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "address_name": "درمانگاه شبانه روزی صدرا ", + "name": "اتاق تزریقات", + "room_type": "تزریقات", + "capacity": 3, + "floor": "۱", + "active": true, + "created_at": 1785416929, + "updated_at": 1785416929 + } + ] +} +``` + +هم فعال و هم غیرفعال برمی‌گردد؛ فیلتر در UI است. + +--- + +## `POST /api/v1/room` + +| فیلد | نوع | الزامی | توضیح | +|---|---|---|---| +| `address_uuid` | string | ✅ | شعبه‌ای که اتاق در آن است | +| `name` | string | ✅ | حداکثر ۱۲۰ نویسه | +| `room_type` | string\|null | — | متن آزاد؛ نوع اتاق را کلینیک تعریف می‌کند | +| `capacity` | int | — | پیش‌فرض ۱، حداقل ۱ | +| `floor` | string\|null | — | حداکثر ۲۰ نویسه | +| `active` | bool | — | پیش‌فرض `true` | + +**`capacity` تعداد بیمار هم‌زمان است.** اتاق تزریق سه‌تخته **یک** اتاق با ظرفیت ۳ است، +نه سه اتاق (بند ۶ مستند طراحی). + +> جفت محیط اتاق در سازندهٔ entity **از خودِ آدرس مشتق** می‌شود، نه از بدنهٔ درخواست: +> آدرس `type=clinic` ⇒ `(clinic, clinic_id)` و `type=personal` ⇒ `(doctor, doctor_id)`. +> پس کلاینت نمی‌تواند اتاقی را به محیط دیگری بچسباند. + +**۲۰۱** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "5425f5c7-22da-4130-b45d-4708313460cd", + "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "address_name": "درمانگاه شبانه روزی صدرا ", + "name": "اتاق تزریقات", + "room_type": "تزریقات", + "capacity": 3, + "floor": "۱", + "active": true, + "created_at": 1785416929, + "updated_at": 1785416929 + } +} +``` + +**۴۲۲ — ظرفیت صفر** (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"ظرفیت اتاق حداقل ۱ است","field":"capacity"}]} +``` + +سایر ۴۲۲ها: `address_uuid` نبود (field `address_uuid`) · نام خالی یا فقط فاصله +(field `name`، کد `ERR_VALIDATION_002`). +**۴۰۴** — آدرس متعلق به محیط جاری نیست. + +--- + +## `PATCH /api/v1/room/{uuid}` + +همان فیلدهای `POST` منهای `address_uuid` — اتاق بین شعبه‌ها جابه‌جا نمی‌شود (جفت محیطش +از آدرس مشتق شده و write-once است). فیلدِ نفرستاده دست‌نخورده می‌ماند؛ رشتهٔ خالی روی +`room_type`/`floor` یعنی «پاک کن» و `null` ذخیره می‌شود. + +**۲۰۰** (خروجی واقعی برای `{"capacity":2,"active":false}`): + +```json +{ + "success": true, + "data": { + "uuid": "5425f5c7-22da-4130-b45d-4708313460cd", + "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "address_name": "درمانگاه شبانه روزی صدرا ", + "name": "اتاق تزریقات", + "room_type": "تزریقات", + "capacity": 2, + "floor": "۱", + "active": false, + "created_at": 1785416929, + "updated_at": 1785416942 + } +} +``` + +**۴۰۴** — اتاق محیط دیگر. + +> مالکیت **صریح** سنجیده می‌شود و به `TenantFilter` تکیه نمی‌شود: جداسازی سختِ فیلتر +> فقط روی محیطِ *انتخاب‌شده* اعمال می‌شود، پس پزشکی که هنوز محیطی برنگزیده بود +> می‌توانست اتاق کلینیک دیگری را PATCH کند. با +> `RoomCrudTest::testForeignRoomIsNotFound` گرفته و بسته شد. + +--- + +## `DELETE /api/v1/room/{uuid}` + +**۲۰۰** (خروجی واقعی): `{"success":true,"data":null}` +**۴۰۴** — اتاق محیط دیگر. + +در این فاز حذف اتاق قید ندارد، چون اتاق هنوز وابستهٔ زنده‌ای ندارد. دلایل منع حذف +از راه `RoomDeletionGuardInterface` تزریق می‌شوند: تسک ۰۲ (منبع فعال روی اتاق) و تسک ۰۷ +(نوبت آیندهٔ آن منابع) هرکدام یک پیاده‌سازی اضافه می‌کنند و `RoomService` دست نمی‌خورد. + +مسیر اصلیِ «کنار گذاشتن» اتاق `active=false` است، نه `DELETE`. + +⚠️ حذف **آدرس** ساعت‌های کاری و اتاق‌هایش را با `ON DELETE CASCADE` می‌برد. تا وقتی +نوبت به اتاق وصل نشده (تسک ۰۷) بی‌خطر است؛ آنجا باید گاردِ حذف آدرس اضافه شود. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `doctor_addresses` | `GlobalTables::ENTITIES` — سراسری، محافظش `BranchResolver` | +| `branch_working_hours` | جفت `(entity_type, entity_id)` مشتق از آدرس در سازنده | +| `rooms` | جفت `(entity_type, entity_id)` مشتق از آدرس در سازنده | + +`branch_working_hours` اول به‌عنوان فرزند aggregate با ریشهٔ `DoctorAddress` ثبت شد و +`TenantSchemaCoverageTest` درست ردش کرد: آن ریشه خودش سراسری است، پس آن مسیر هیچ +تضمینی نمی‌داد. حالا جفت واقعی دارد. + +--- + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Branch # ۳۶ تست / ۱۰۱ assertion +ddev exec php bin/phpunit --group=slot-mode-frozen # منطق اسلاتی دست‌نخورده +npx vitest run assets/admin/pages/BranchWorkingHoursPage.test.tsx +``` diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 24813dfe..c75d5dd3 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -445,6 +445,8 @@ Get all practice addresses for a doctor, including addresses of clinics the doct "name": "مطب تهران", "address": "تهران، خیابان...", "telephone": "02112345678", + "active": true, + "timezone": "Asia/Tehran", "map": { "latitude": "35.6892", "longitude": "51.3890" }, "city": { "id": "1", "name": "تهران" }, "province": { "id": "1", "name": "تهران" } @@ -458,6 +460,8 @@ Get all practice addresses for a doctor, including addresses of clinics the doct "name": null, "address": "اصفهان، خیابان...", "telephone": "03112345678", + "active": true, + "timezone": "Asia/Tehran", "map": { "latitude": null, "longitude": null }, "city": { "id": "3", "name": "اصفهان" }, "province": { "id": "2", "name": "اصفهان" } @@ -468,6 +472,16 @@ Get all practice addresses for a doctor, including addresses of clinics the doct > **نکته:** آدرس‌های با `type: "clinic"` از کلینیک‌هایی که پزشک عضو آن‌هاست می‌آیند و `clinic_name` نام کلینیک را نشان می‌دهد. +> **`active` و `timezone` (افزوده‌شده در تسک شعبه):** هر آدرس یک «شعبه» است و این دو +> ویژگی روی خروجی **همهٔ** اندپوینت‌های آدرس ظاهر می‌شوند، چون از +> `DoctorAddress::toArray()` می‌آیند. هر دو ستون `NOT NULL DEFAULT` دارند، پس ردیف‌های +> قدیمی هم `active: true` و `timezone: "Asia/Tehran"` می‌دهند؛ تغییر additive است. +> +> `active` در فاز فعلی **فقط ذخیره می‌شود** و هیچ اثری بر محاسبهٔ اسلات ندارد. نوشتن +> این دو فیلد از راه اندپوینت‌های همین سند انجام نمی‌شود؛ برای آن +> `PATCH /api/v1/branch/{addressUuid}` است — همراه با ساعت کاری هفتگی و اتاق‌ها در +> [branch.md](branch.md). + --- ## POST `/api/v1/clinic-pro/doctor-address` diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 1bc6268f..af133619 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -10,7 +10,7 @@ | ستون | مقدار | |---|---| -| `entity_type` | `doctor` یا `clinic` — `VARCHAR(10)` در هر ۲۰ جدول tenant-دار | +| `entity_type` | `doctor` یا `clinic` — `VARCHAR(10)` در همهٔ جدول‌های tenant-دار (طولِ یکسان، وگرنه JOIN به collation mismatch می‌خورد) | | `entity_id` | شناسهٔ همان پزشک یا کلینیک | موجودیت‌ها این جفت را از trait مشترک می‌گیرند: @@ -132,6 +132,28 @@ public function __construct(ServiceSection $section, ...) { `RequestReachableChildTenantTest` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود. +### ⚠️ ریشهٔ سراسری، فرزندِ محیط‌دار — پروندهٔ `doctor_addresses` + +`doctor_addresses` عمداً در `ENTITIES` سراسری است («آدرس‌های پزشک؛ در همهٔ محیط‌های او +یکسان است»)، پس **فیلتر رویش اعمال نمی‌شود** و `findOneBy(['uuid' => …])` آدرس کلینیک +دیگر را هم برمی‌گرداند. دو جدولِ تسک شعبه روی همین ریشه نشستند و درس دادند: + +| جدول | طبقه‌بندی | چرا | +|---|---|---| +| `branch_working_hours` | جفت محیط **خودش** | اول به‌عنوان فرزند aggregate با ریشهٔ `DoctorAddress` ثبت شد و `TenantSchemaCoverageTest` ردش کرد: ریشه‌ای که خودش سراسری است، هیچ محیطی برای ارث دادن ندارد | +| `rooms` | جفت محیط خودش | uuidش از درخواست می‌آید — همان قاعدهٔ فاز ۸ | + +جفت از **`type` آدرس** مشتق می‌شود، که نگاشتی کامل است: +`personal ⇒ (doctor, doctor_id)` و `clinic ⇒ (clinic, clinic_id)`. چون آدرس هم فقط در +محیط خودش فهرست می‌شود، هیچ ردیفی بی‌دلیل پنهان نمی‌شود. + +خودِ آدرس محافظ دستی دارد: `App\Branch\Service\BranchResolver` تک‌نقطهٔ تبدیل +«uuid شعبه در request» به آدرسِ محیط جاری است و در غیر این صورت **۴۰۴** می‌دهد — همان +رفتار فیلتر، نه ۴۰۳. + +**درسِ عملیاتی:** آنجا که `AGGREGATE_CHILDREN` بی‌فایده است، فقط طبقه‌بندی عوض نکن؛ +جفت واقعی بده. و برای ریشهٔ سراسری یک resolver واحد بساز، نه بررسی تکراری در هر کنترلر. + ### uuid از درخواست — خطرناک‌ترین الگو سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: @@ -152,6 +174,13 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است. +**فیلتر جایگزین این بررسی نیست، حتی روی جدولِ جفت‌دار.** جداسازی سختِ `TenantFilter` +فقط روی محیطِ **انتخاب‌شده** اعمال می‌شود ({@see `EntityContext::$chosen`}). پزشکی که +هنوز محیطی برنگزیده در هیچ محیطی «نیست»، پس فیلتر برایش خاموش است و +`PATCH /api/v1/room/{uuid}` می‌توانست اتاق کلینیک دیگری را ویرایش کند — با +`RoomCrudTest::testForeignRoomIsNotFound` گرفته شد که قبل از اصلاح ۲۰۰ می‌داد. +هر کنترلری که uuid را از request می‌گیرد باید `belongsToPair()` را خودش صدا بزند. + `TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### جدول‌های مالی diff --git a/docs/new_feture/taskes/task-01-branch-room/checklist.md b/docs/new_feture/taskes/task-01-branch-room/checklist.md index 85764179..4cd0f73f 100644 --- a/docs/new_feture/taskes/task-01-branch-room/checklist.md +++ b/docs/new_feture/taskes/task-01-branch-room/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۱ (شعبه و اتاق) -**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تکمیل‌شده (۲ ردیف 🔄 بازبینی چشمی) · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) · @@ -12,11 +12,11 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `SlotCalculatorService` دست‌نخورده | ⏳ | این تسک به آن کاری ندارد | -| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | ⏳ | شعبه = همان `doctor_addresses.id` | -| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `active` و `timezone` با `DEFAULT` | -| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | ⏳ | اعمالش تسک ۰۳ است | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | `--group=slot-mode-frozen` — ۳ تست / ۸ assertion سبز | +| ۰.۲ | `SlotCalculatorService` دست‌نخورده | ✅ | صفر تغییر در فایل | +| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | ✅ | شعبه = همان `doctor_addresses.id`؛ JSON دست‌نخورده | +| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ✅ | فقط دو ستون `NOT NULL DEFAULT` | +| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | ✅ | فقط ذخیره می‌شود؛ در `branch.md` نوشته شد | ## ۱. طرح @@ -30,81 +30,81 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `DoctorAddress` += `active` + `timezone` | ⏳ | | -| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی می‌شود، نه regex | ⏳ | | -| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | ⏳ | | -| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | ⏳ | نه از بدنهٔ request | -| ۲.۵ | `BranchResolver` — تک‌نقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | ⏳ | `TenantFilter` روی `doctor_addresses` کار نمی‌کند | -| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | ⏳ | | -| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | | -| ۲.۸ | `sequence` سمت سرور تخصیص می‌یابد، نه کلاینت | ⏳ | | -| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه می‌کنند | -| ۲.۱۰ | شش endpoint ساخته شد | ⏳ | صفر endpoint CRUD شعبه — موجود است | -| ۲.۱۱ | پزشک مستقل هم شعبه دارد | ⏳ | `type='personal'` از قبل کار می‌کند | -| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | | +| ۲.۱ | `DoctorAddress` += `active` + `timezone` | ✅ | | +| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی می‌شود، نه regex | ✅ | `DateTimeZone::listIdentifiers()` در setter | +| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | ✅ | ولی **جفت tenant** گرفت نه فرزند aggregate — ردیف ۳.۵ | +| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | ✅ | جفت در سازنده از `tenantEntityType/Id()` آدرس | +| ۲.۵ | `BranchResolver` — تک‌نقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | ✅ | ۴۰۴ می‌دهد؛ منشی هم پوشش دارد | +| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | ✅ | `WorkingHoursTest::testInvalidLaterDayLeavesTheStoredWeekUntouched` | +| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ✅ | | +| ۲.۸ | `sequence` سمت سرور تخصیص می‌یابد، نه کلاینت | ✅ | بعد از `usort` تخصیص می‌یابد | +| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | ✅ | `RoomDeletionGuardInterface` + `AutowireIterator` + `_instanceof` | +| ۲.۱۰ | شش endpoint ساخته شد | ✅ | هشت شد نه شش: `GET /branches` و `PATCH /branch/{uuid}` هم لازم بودند | +| ۲.۱۱ | پزشک مستقل هم شعبه دارد | ✅ | `type=personal` از قبل کار می‌کرد | +| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | ✅ | | ## ۳. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `branch_working_hours` · `rooms` | ⏳ | | -| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | ⏳ | | -| ۳.۳ | `timezone` روی آدرس از روز اول | ⏳ | افزودن بعدی = backfill زمان‌دار | -| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | ⏳ | اتاق سه‌تخته = یک ردیف با ۳ | -| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | ⏳ | | -| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | ⏳ | تاریخچهٔ migration جدا | -| ۳.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | | -| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | ⏳ | | +| ۳.۱ | `branch_working_hours` · `rooms` | ✅ | `Version20260730125038` | +| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | ✅ | `idx_rooms_tenant` و `idx_bwh_tenant` | +| ۳.۳ | `timezone` روی آدرس از روز اول | ✅ | افزودن بعدی = backfill زمان‌دار | +| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | ✅ | حداقل ۱ در setter و سرویس | +| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | ✅ | **رد شد** — ریشه سراسری است، پس جفت واقعی گرفت | +| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | ✅ | دستی، چون `db_test` تاریخچهٔ جدا دارد | +| ۳.۷ | `TenantSchemaCoverageTest` سبز | ✅ | | +| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | ✅ | سبز بدون نیاز به ثبت تازه | ## ۴. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | ⏳ | | -| ۴.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | | -| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | ⏳ | | -| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `` بومی | ✅ | `SearchableSelect` برای منطقهٔ زمانی؛ هیچ `