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