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
+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;
}