Files
clinicpro/assets/admin/pages/HolidaysSettingsPage.tsx
T
hamedandClaude Opus 5 9ba4c8d948 feat(settings): manage resource schedules beside the doctors'
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>
2026-08-02 15:49:01 +03:30

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