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:
hamed
2026-07-30 16:48:49 +03:30
co-authored by Claude Opus 5
parent eebb363b9f
commit d813843fcd
15 changed files with 1472 additions and 63 deletions
+6
View File
@@ -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'] },
+99
View File
@@ -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 };
}
+196
View File
@@ -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}
/>
);
}
+177
View File
@@ -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>
);
}
+71
View File
@@ -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;
}