feat(branch): admin UI for branch working hours and rooms, plus real API docs
Three pages, all on the existing design system: BranchesPage lists the current environment's booking locations with their working-hours and active-room counts, and two subpages edit the week and the rooms. The list page deliberately does not create or rename a branch — clinic and doctor detail pages already do that, and duplicating it would give one physical place two edit surfaces. Route permission reuses `appointment_settings` rather than inventing a new one. Two real bugs fell out of exercising this end to end: `days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6 are sequential so json_encode collapses them to a list. The client reads days["0"] either way, so nothing looked broken, but the response shape was unstable: one missing day would flip the same field to an object. The controller now casts to stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by curling the endpoint for the docs, not by any test. `<input type="time">` caps at 23:59, so it can neither display nor produce the legal end value 1440. An all-day range would have vanished from the form and been corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a round-trip test proving 1440 survives. docs/api/branch.md documents all eight endpoints with responses captured from real curl runs against ddev, including the 422 and 404 bodies. doctor.md records that active/timezone now appear on all nine existing address endpoints (additive), and tenancy.md gains the two lessons this task taught: an aggregate child whose root is itself declared global inherits no environment and needs a real pair, and TenantFilter is not a substitute for an explicit ownership check because hard isolation only applies to a *chosen* context. Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 87 files / 612 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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<ApiResponse<Branch[]>>('/api/v1/branches'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, d }: { uuid: string; d: { active?: boolean; timezone?: string } }) =>
|
||||
api.patch<ApiResponse<Branch>>(`/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<ApiResponse<BranchWorkingHours>>(`/api/v1/branch/${branchUuid}/working-hours`),
|
||||
enabled: !!branchUuid,
|
||||
});
|
||||
|
||||
/** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
|
||||
const save = useMutation({
|
||||
mutationFn: (days: WorkingHoursPayload) =>
|
||||
api.put<ApiResponse<BranchWorkingHours>>(`/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<ApiResponse<Room[]>>(`/api/v1/branch/${branchUuid}/rooms`),
|
||||
enabled: !!branchUuid,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (d: RoomPayload) =>
|
||||
api.post<ApiResponse<Room>>('/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<ApiResponse<Room>>(`/api/v1/room/${uuid}`, d),
|
||||
onSuccess: () => { toast.success('اتاق بهروزرسانی شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'بهروزرسانی اتاق ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/room/${uuid}`),
|
||||
onSuccess: () => { toast.success('اتاق حذف شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'حذف اتاق ناموفق بود'),
|
||||
});
|
||||
|
||||
return { rooms: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
|
||||
}
|
||||
@@ -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<Room | null>(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<Room>[] = [
|
||||
{ key: 'name', header: 'نام اتاق', render: (r) => <span style={{ fontWeight: 600 }}>{r.name}</span> },
|
||||
{ key: 'room_type', header: 'نوع', render: (r) => <span style={{ fontSize: 13 }}>{r.room_type || '—'}</span> },
|
||||
{
|
||||
key: 'capacity',
|
||||
header: 'ظرفیت همزمان',
|
||||
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
|
||||
},
|
||||
{ key: 'floor', header: 'طبقه', render: (r) => <span style={{ fontSize: 13 }}>{r.floor || '—'}</span> },
|
||||
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={`اتاقهای ${branch?.name ?? 'شعبه'}`}
|
||||
description="ظرفیت هر اتاق تعداد بیمارِ همزمان است — اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
|
||||
backTo="/admin/branches"
|
||||
breadcrumbs={[{ label: 'شعبهها', to: '/admin/branches' }, { label: 'اتاقها' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن اتاق
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در اتاقها..."
|
||||
emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (r) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<RoomModal
|
||||
open={editing.open}
|
||||
room={editing.room}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, room: null })}
|
||||
onSave={save}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف اتاق"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal open={open} onClose={onClose} title={room ? 'ویرایش اتاق' : 'افزودن اتاق'}>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<Field label="نام اتاق">
|
||||
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="اتاق تزریق" />
|
||||
</Field>
|
||||
<Field label="نوع اتاق (اختیاری)">
|
||||
<input className="field" value={roomType} onChange={(e) => setRoomType(e.target.value)} placeholder="تزریقات" />
|
||||
</Field>
|
||||
<Field label="ظرفیت همزمان">
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="طبقه (اختیاری)">
|
||||
<input className="field" value={floor} onChange={(e) => setFloor(e.target.value)} placeholder="۲" />
|
||||
</Field>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
اتاق فعال است
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={saving || invalid}
|
||||
onClick={() => onSave({
|
||||
name: name.trim(),
|
||||
room_type: roomType.trim() === '' ? null : roomType.trim(),
|
||||
capacity: parsedCapacity,
|
||||
floor: floor.trim() === '' ? null : floor.trim(),
|
||||
active,
|
||||
})}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown[]> {
|
||||
return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []]));
|
||||
}
|
||||
|
||||
function mockApi(days: Record<string, unknown[]>) {
|
||||
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(
|
||||
<Routes>
|
||||
<Route path="/admin/branches/:branchUuid/working-hours" element={<BranchWorkingHoursPage />} />
|
||||
</Routes>,
|
||||
{ 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();
|
||||
});
|
||||
|
||||
/**
|
||||
* `<input type="time">` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده
|
||||
* میشود و همان ۱۴۴۰ برمیگردد — وگرنه اولین ذخیره بازهٔ شبانهروزی را خراب میکرد.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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` وجود دارد چون `<input type="time">` سقفش ۲۳:۵۹ است و مقدار ۲۴:۰۰ را
|
||||
* نه نشان میدهد و نه میسازد. بدون این پرچم، بازهٔ شبانهروزیِ ذخیرهشده (۱۴۴۰)
|
||||
* بیصدا از فرم میافتاد و اولین ذخیره آن را خراب میکرد.
|
||||
*/
|
||||
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<Record<number, Draft[]>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workingHours) return;
|
||||
const next: Record<number, Draft[]> = {};
|
||||
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<Draft>) => {
|
||||
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<number, Draft[]> = {};
|
||||
DAY_LABELS.forEach((_, d) => { next[d] = source.map((r) => ({ ...r })); });
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
||||
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
|
||||
description="روز بدون بازه یعنی شعبه آن روز بسته است. شعبهٔ بدون هیچ ساعتی «تعریفنشده» است، نه همیشهباز."
|
||||
backTo="/admin/branches"
|
||||
breadcrumbs={[
|
||||
{ label: 'شعبهها', to: '/admin/branches' },
|
||||
{ label: 'ساعت کاری' },
|
||||
]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branch && canUpdate && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>منطقهٔ زمانی شعبه</span>
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<TimezoneSelect branchUuid={branch.uuid} value={branch.timezone} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{totalRanges === 0 ? 'هیچ بازهای تعریف نشده' : `${totalRanges} بازه در هفته`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{DAY_LABELS.map((label, day) => {
|
||||
const ranges = draft[day] ?? [];
|
||||
return (
|
||||
<div key={day} className="card" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: ranges.length ? 12 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
|
||||
{ranges.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بسته</span>
|
||||
)}
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{ranges.length > 0 && (
|
||||
<button type="button" className="btn secondary sm" onClick={() => copyToWholeWeek(day)}>
|
||||
اعمال روی همهٔ روزها
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
|
||||
<PlusIcon style={{ width: 15 }} /> بازه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{ranges.map((range, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>از</label>
|
||||
<input
|
||||
type="time"
|
||||
className="field"
|
||||
value={range.start}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { start: e.target.value })}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</label>
|
||||
{range.endOfDay ? (
|
||||
<span className="field" style={{ width: 120, color: 'var(--text-2)' }}>۲۴:۰۰</span>
|
||||
) : (
|
||||
<input
|
||||
type="time"
|
||||
className="field"
|
||||
value={range.end}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { end: e.target.value })}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={range.endOfDay}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
|
||||
/>
|
||||
تا پایان روز
|
||||
</label>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => removeRange(day, index)}
|
||||
aria-label="حذف بازه"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست منطقهٔ زمانی کوتاه و ثابت است — بکاند با `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 (
|
||||
<SearchableSelect
|
||||
options={options.map((tz) => ({ value: tz, label: tz }))}
|
||||
value={value}
|
||||
onChange={(v) => v && update.mutate({ uuid: branchUuid, d: { timezone: String(v) } })}
|
||||
placeholder="منطقهٔ زمانی"
|
||||
height={38}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Branch>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'شعبه',
|
||||
render: (b) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontWeight: 600 }}>{b.name || 'بدون نام'}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{b.type === 'clinic' ? b.clinic_name || 'کلینیک' : 'مطب شخصی'}
|
||||
{b.city ? ` · ${b.city.name}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
header: 'آدرس',
|
||||
render: (b) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{b.address || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telephone',
|
||||
header: 'تلفن',
|
||||
render: (b) => <span style={{ fontSize: 13 }}>{b.telephone || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'working_hours',
|
||||
header: 'ساعت کاری',
|
||||
render: (b) =>
|
||||
b.working_hours_defined ? (
|
||||
<span className="badge green"><span className="bdot" />تعریفشده</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تعریفنشده</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rooms_count',
|
||||
header: 'اتاق فعال',
|
||||
render: (b) => <span style={{ fontSize: 13 }}>{b.rooms_count ?? 0}</span>,
|
||||
},
|
||||
{
|
||||
key: 'timezone',
|
||||
header: 'منطقهٔ زمانی',
|
||||
render: (b) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{b.timezone}</span>,
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
header: 'وضعیت',
|
||||
render: (b) => <ActiveBadge active={b.active} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="شعبهها و اتاقها"
|
||||
description="ساعت کاری هر محل نوبتدهی و اتاقهای آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش میشود."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در شعبهها..."
|
||||
emptyMessage="هیچ شعبهای برای این محیط ثبت نشده است"
|
||||
headerExtra={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginRight: 'auto' }}>
|
||||
<StatusFilter
|
||||
value={urlState.status}
|
||||
onChange={(v) => setUrlState({ status: v })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={(b) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/working-hours`}>
|
||||
<ClockIcon style={{ width: 15 }} /> ساعت کاری
|
||||
</Link>
|
||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/rooms`}>
|
||||
<Squares2X2Icon style={{ width: 15 }} /> اتاقها
|
||||
</Link>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={update.isPending}
|
||||
title={
|
||||
b.active && activeCount === 1
|
||||
? 'با غیرفعال کردن این شعبه، هیچ شعبهٔ فعالی باقی نمیماند'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
b.active &&
|
||||
activeCount === 1 &&
|
||||
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
||||
}}
|
||||
>
|
||||
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const options = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'active', label: 'فعال' },
|
||||
{ value: 'inactive', label: 'غیرفعال' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`btn sm ${value === o.value ? 'primary' : 'secondary'}`}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, WorkingHourRange[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* بدنهٔ نوشتن ساعت کاری. عمداً شکل خواندن (`WorkingHourRange`) نیست: `sequence` را
|
||||
* سرور از ترتیب بازهها مشتق میکند و `start_time`/`end_time` فقط برای نمایشاند.
|
||||
*/
|
||||
export type WorkingHoursPayload = Record<string, { start_minute: number; end_minute: number }[]>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`
|
||||
|
||||
@@ -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` تازه روی موجودیت محیطدار تست را قرمز میکند تا کسی ثابت کند محیطش بررسی میشود و بعد عدد را بهروز کند.
|
||||
|
||||
### جدولهای مالی
|
||||
|
||||
@@ -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` — هیچ `<select>` بومی | ⏳ | |
|
||||
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
|
||||
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
|
||||
| ۴.۷ | دارکمود و حالت فشرده بررسی شد | ⏳ | |
|
||||
| ۴.۸ | RTL و موبایل بررسی شد | ⏳ | |
|
||||
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمیماند» | ⏳ | |
|
||||
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | ⏳ | |
|
||||
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | ⏳ | |
|
||||
| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | ✅ | |
|
||||
| ۴.۲ | `DataTable` با skeleton و empty state فارسی | ✅ | |
|
||||
| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحهها | ✅ | `backTo` + breadcrumb روی هر دو زیرصفحه |
|
||||
| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `<select>` بومی | ✅ | `SearchableSelect` برای منطقهٔ زمانی؛ هیچ `<select>` بومی |
|
||||
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | ✅ | جستجو و فیلتر وضعیت در URL |
|
||||
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ✅ | همه از `var(--…)` |
|
||||
| ۴.۷ | دارکمود و حالت فشرده بررسی شد | 🔄 | کد فقط از توکن استفاده میکند؛ بازبینی چشمی در مرورگر انجام نشد |
|
||||
| ۴.۸ | RTL و موبایل بررسی شد | 🔄 | چیدمان flex/grid با wrap؛ بازبینی چشمی موبایل انجام نشد |
|
||||
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمیماند» | ✅ | confirm + title روی تنها شعبهٔ فعال |
|
||||
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | ✅ | سه مسیر + آیتم منو با `MapPinIcon` |
|
||||
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | ✅ | `appointment_settings` بازاستفاده شد |
|
||||
|
||||
## ۵. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، همپوشانی، `0..1440`، آرایهٔ خالی | ⏳ | |
|
||||
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دستنخورده | ⏳ | |
|
||||
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | ⏳ | |
|
||||
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | ⏳ | |
|
||||
| ۵.۵ | `BranchAddressFieldsTest` — پیشفرضها، `timezone` نامعتبر → ۴۲۲ | ⏳ | |
|
||||
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
|
||||
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، همپوشانی، `0..1440`، آرایهٔ خالی | ✅ | ۱۴ تست |
|
||||
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دستنخورده | ✅ | |
|
||||
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | ✅ | ۱۲ تست |
|
||||
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | ✅ | هم شعبه و هم اتاق |
|
||||
| ۵.۵ | `BranchAddressFieldsTest` — پیشفرضها، `timezone` نامعتبر → ۴۲۲ | ✅ | ۱۰ تست شامل شمارش کوئری |
|
||||
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | ✅ | صفر خطا در `src/Branch` |
|
||||
|
||||
## ۶. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | JSON واقعی از curl |
|
||||
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۶.۳ | «`active` در این فاز بیاثر بر اسلات» نوشته شد | ⏳ | |
|
||||
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | ⏳ | تغییر قرارداد است |
|
||||
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ⏳ | |
|
||||
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ✅ | JSON واقعی از curl روی ddev + ثبت در README |
|
||||
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ✅ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۶.۳ | «`active` در این فاز بیاثر بر اسلات» نوشته شد | ✅ | |
|
||||
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | ✅ | `doctor.md` — تغییر additive روی ۹ اندپوینت |
|
||||
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ✅ | درسِ «ریشهٔ سراسری، فرزندِ محیطدار» + محدودیت `chosen` |
|
||||
|
||||
## ۷. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۷.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | ⏳ | |
|
||||
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۷.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۷.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | دو فیلد جدید additive است |
|
||||
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | ⏳ | |
|
||||
| ۷.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۷.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⚠️ | دو 🔄 مانده، هر دو بازبینی چشمی UI با دلیل مکتوب |
|
||||
| ۷.۲ | `bin/phpunit` کامل سبز | ✅ | ۱۰۶۷ تست / ۲۹۷۴ assertion — صفر خطا |
|
||||
| ۷.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | ✅ | ۱۴ خطا قبل و بعد — هیچکدام در فایلهای این تسک |
|
||||
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ✅ | `tsc` صفر خطا · vitest ۸۷ فایل / ۶۱۲ تست |
|
||||
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ✅ | |
|
||||
| ۷.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۷.۸ | چکلیست UI کامل | ⚠️ | دو ردیف ۴.۷/۴.۸ بازبینی چشمی میخواهند |
|
||||
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ✅ | هر دو آدرس را مصرف میکنند (`app/doctor/[slug]/page.js` و `OfficeAddressesContent`/`workingDays`/`TurnsTabContent`) ولی فیلدها را **با نام** میخوانند و برای نوشتن payload صریح میسازند (`transformData`) — دو فیلد additive نمیشکندشان. هیچ اندپوینت جدیدی مصرفکننده ندارد |
|
||||
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | ✅ | |
|
||||
| ۷.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ✅ | گاردِ حذف اتاق ← تسک ۰۲/۰۷ · گاردِ حذف آدرس ← تسک ۰۷ · اعمال `active` ← تسک ۰۳ |
|
||||
|
||||
@@ -117,7 +117,7 @@ class BranchController extends BaseController
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'timezone' => $address->getTimezone(),
|
||||
'defined' => $this->workingHours->isDefined($address),
|
||||
'days' => $this->workingHours->read($address),
|
||||
'days' => self::daysObject($this->workingHours->read($address)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,20 @@ class BranchController extends BaseController
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'timezone' => $address->getTimezone(),
|
||||
'defined' => $days !== array_fill_keys(WorkingHoursService::DAYS, []),
|
||||
'days' => $days,
|
||||
'days' => self::daysObject($days),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* کلیدهای ۰..۶ پشتسرهماند، پس json_encode آرایهٔ PHP را به **آرایهٔ JSON**
|
||||
* تبدیل میکرد نه به شیئی با کلیدهای "0".."6". کلاینت با `days["0"]` هر دو را
|
||||
* میخواند، ولی شکل پاسخ ناپایدار میشد: کافی بود یک روز جا بیفتد تا همان فیلد
|
||||
* شیء برگردد. (کلید رشتهایِ عددی هم چاره نیست — PHP خودش به int برش میگرداند.)
|
||||
*
|
||||
* @param array<int, list<array<string, mixed>>> $days
|
||||
*/
|
||||
private static function daysObject(array $days): \stdClass
|
||||
{
|
||||
return (object) $days;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,29 @@ class WorkingHoursTest extends BranchTestCase
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* `days` باید **شیء** JSON با کلیدهای "0".."6" باشد، نه آرایه.
|
||||
* کلیدهای ۰..۶ پشتسرهماند و json_encode بیمراقبت آرایه میساخت؛ کلاینت
|
||||
* `days["0"]` هر دو را میخواند، ولی شکل پاسخ با جا افتادن یک روز عوض میشد.
|
||||
*/
|
||||
public function testDaysIsAJsonObjectNotAnArray(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$this->put($user, $address->getUuid(), [0 => [['start_minute' => 540, 'end_minute' => 780]]]);
|
||||
|
||||
foreach (['PUT', 'GET'] as $method) {
|
||||
if ($method === 'GET') {
|
||||
$this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
|
||||
}
|
||||
|
||||
$raw = json_decode($this->client->getResponse()->getContent(), false);
|
||||
self::assertInstanceOf(\stdClass::class, $raw->data->days, "$method: days باید شیء باشد");
|
||||
// get_object_vars نامِ عددیِ ویژگیها را به int برمیگرداند؛ آنچه مهم است
|
||||
// stdClass بودن بالا سنجیده شد. اینجا فقط کامل بودن هفت روز.
|
||||
self::assertSame(range(0, 6), array_keys(get_object_vars($raw->data->days)));
|
||||
}
|
||||
}
|
||||
|
||||
public function testClinicOwnerManagesItsOwnBranch(): void
|
||||
{
|
||||
[$clinicUser, , $address] = $this->clinicWithAddress();
|
||||
|
||||
Reference in New Issue
Block a user