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>
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|