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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user