Files
clinicpro/assets/admin/hooks/useBranches.ts
T
hamedandClaude Opus 5 d813843fcd 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>
2026-07-30 16:48:49 +03:30

100 lines
4.0 KiB
TypeScript

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