A resource carries its own working hours and holidays in the resource-first model, so it belongs on the same settings page as a doctor's schedule rather than on a page of its own. The page now has a scope switch — doctors or resources — with the per-item tab bar below it, and both scopes reuse the panels that already existed: ScheduleSection for a doctor, the working-hours and exceptions panels for a resource. The selection lives in the query string, so back and refresh return to the same tab. The screenshot of the finished tab caught two real defects, both fixed here: Dates in the resource panels and the holidays page read as year 57932. formatDate already multiplies seconds by 1000, and five call sites passed `x * 1000` on top of it. This predates the tab — the code was inherited from the old calendar page — but it was invisible until a two-week preview was put on screen. The working-hours panel still told the user their hours were intersected with the branch's. Branches are gone; the shift is the only source now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
import React from 'react';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useHolidays } from '../hooks/useResourceCalendar';
|
|
import { formatDate, currentJalaliYear } from '../lib/utils';
|
|
import type { NationalHoliday } from '../types';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
|
|
/**
|
|
* تعطیلات رسمی و استثناهای این محیط.
|
|
*
|
|
* خودِ تعطیلات کشوریاند و اینجا فقط دیده میشوند؛ آنچه محیط تغییر میدهد «باز بودن
|
|
* یا نبودنِ» همان روز برای خودش است.
|
|
*/
|
|
export default function HolidaysSettingsPage() {
|
|
const thisYear = currentJalaliYear();
|
|
const [urlState, setUrlState] = useUrlState({ year: String(thisYear) });
|
|
const year = Number(urlState.year) || thisYear;
|
|
|
|
const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year);
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('appointment_settings', 'update');
|
|
|
|
const overrideByDate = new Map(overrides.map((o) => [o.date, o]));
|
|
|
|
const columns: Column<NationalHoliday>[] = [
|
|
{ key: 'jalali_date', header: 'تاریخ', render: (h) => <span style={{ fontWeight: 600 }}>{h.jalali_date}</span> },
|
|
{ key: 'title', header: 'مناسبت', render: (h) => <span style={{ fontSize: 13 }}>{h.title}</span> },
|
|
{
|
|
key: 'gregorian',
|
|
header: 'میلادی',
|
|
render: (h) => <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDate(h.date)}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت این محیط',
|
|
render: (h) =>
|
|
overrideByDate.get(h.date)?.is_working ? (
|
|
<span className="badge green"><span className="bdot" />باز است</span>
|
|
) : (
|
|
<span className="badge gray"><span className="bdot" />تعطیل</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i);
|
|
|
|
return (
|
|
<SettingsLayout active="holidays">
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title="تعطیلات رسمی"
|
|
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
|
backTo="/admin/settings-menu"
|
|
/>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={holidays}
|
|
loading={loading}
|
|
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`}
|
|
headerExtra={
|
|
<div style={{ minWidth: 160, marginRight: 'auto' }}>
|
|
<SearchableSelect
|
|
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
|
value={String(year)}
|
|
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
|
placeholder="سال"
|
|
height={36}
|
|
/>
|
|
</div>
|
|
}
|
|
actions={
|
|
canUpdate
|
|
? (h) => {
|
|
const override = overrideByDate.get(h.date);
|
|
return (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
{override?.is_working ? (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
disabled={removeOverride.isPending}
|
|
onClick={() => removeOverride.mutate(override.uuid)}
|
|
>
|
|
تعطیل کن
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
disabled={setOverride.isPending}
|
|
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
|
>
|
|
این روز بازیم
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<ClosureCard
|
|
overrides={overrides.filter((o) => !o.is_working)}
|
|
canUpdate={canUpdate}
|
|
saving={setOverride.isPending}
|
|
onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })}
|
|
onRemove={(uuid) => removeOverride.mutate(uuid)}
|
|
/>
|
|
</div>
|
|
</SettingsLayout>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* جهت دوم: روزی که تعطیل رسمی نیست ولی این محیط بسته است.
|
|
* این با «استثنای منبع» فرق دارد — آن روی یک منبع است و این روی کل محیط.
|
|
*/
|
|
function ClosureCard({
|
|
overrides, canUpdate, saving, onAdd, onRemove,
|
|
}: {
|
|
overrides: { uuid: string; date: number; note: string | null }[];
|
|
canUpdate: boolean;
|
|
saving: boolean;
|
|
onAdd: (date: number, note: string | null) => void;
|
|
onRemove: (uuid: string) => void;
|
|
}) {
|
|
const [date, setDate] = React.useState('');
|
|
const [note, setNote] = React.useState('');
|
|
|
|
const timestamp = date === '' ? null : Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000);
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 16, marginTop: 16 }}>
|
|
<h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلیهای این محیط</h2>
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 12px' }}>
|
|
روزهایی که تعطیل رسمی نیستند ولی این محیط بسته است. برای مرخصی یک نفر یا سرویس یک دستگاه،
|
|
از تقویم همان منبع استفاده کنید.
|
|
</p>
|
|
|
|
{overrides.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>موردی ثبت نشده است.</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
|
|
{overrides.map((o) => (
|
|
<div key={o.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
<span style={{ flex: 1, color: 'var(--text-2)' }}>
|
|
{formatDate(o.date)}{o.note ? ` · ${o.note}` : ''}
|
|
</span>
|
|
{canUpdate && (
|
|
<button type="button" className="btn secondary sm" onClick={() => onRemove(o.uuid)} aria-label="حذف تعطیلی">
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{canUpdate && (
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
<input type="date" className="field" value={date} onChange={(e) => setDate(e.target.value)} style={{ minWidth: 170 }} />
|
|
<input className="field" value={note} onChange={(e) => setNote(e.target.value)} placeholder="توضیح (اختیاری)" style={{ flex: 1, minWidth: 180 }} />
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
disabled={saving || timestamp === null}
|
|
onClick={() => {
|
|
onAdd(timestamp!, note.trim() === '' ? null : note.trim());
|
|
setDate('');
|
|
setNote('');
|
|
}}
|
|
>
|
|
افزودن
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|