Files
clinicpro/assets/admin/pages/BranchWorkingHoursPage.tsx
T
hamedandClaude Opus 5 4380e64a5d fix(settings): every settings page renders the settings shell
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:49 +03:30

266 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';
import SettingsLayout from '../components/layout/SettingsLayout';
/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بک‌اند. */
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 (
<SettingsLayout active="branches">
<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>
</SettingsLayout>
);
}
/**
* فهرست منطقهٔ زمانی کوتاه و ثابت است — بک‌اند با `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}
/>
);
}