Files
clinicpro/assets/admin/pages/HolidaysSettingsPage.tsx
T
hamedandClaude Opus 5 f06efe26c0 fix(holidays): honest dates and reachable fields on the holidays page
The table had two date columns for one date: "تاریخ" printed the raw
1405-05-13 string in Latin digits, and the column labelled "میلادی" ran the
same day through formatDate — which returns Jalali. One date, twice, under a
label that lied. It is now a single formatted Jalali column.

The closure form used a native <input type="date">: Gregorian, an English
mm/dd/yyyy placeholder in an RTL Persian panel, and a white box in dark mode
because a native control does not follow the theme. It is the shared Persian
picker now.

That picker turned out to be a div with an onClick — no role, no tab stop, no
accessible name, and its clear button was a span. Since every page that picks
a date goes through it, it gained role/tabIndex/Enter-Space, an ariaLabel
prop, and a real button for clear. The page passes labels for the year select
and both form fields, and the global topbar search got an aria-label, which
takes the runtime accessibility probe on this page to clean.

useHolidays now returns an error, so a failed request reads as an error
instead of an empty year — previously indistinguishable.

The page had no test file; it has eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:25:56 +03:30

223 lines
9.2 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 PersianDateInput from '../components/ui/PersianDateInput';
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';
/** سال بدون جداکنندهٔ هزارگان — `formatNumber` «۱٬۴۰۵» می‌داد. */
const faYear = (y: number) => y.toLocaleString('fa-IR', { useGrouping: false });
/**
* تعطیلات رسمی و استثناهای این محیط.
*
* خودِ تعطیلات کشوری‌اند و اینجا فقط دیده می‌شوند؛ آنچه محیط تغییر می‌دهد «باز بودن
* یا نبودنِ» همان روز برای خودش است. ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است
* (`/admin/national-holidays`).
*/
export default function HolidaysSettingsPage() {
const thisYear = currentJalaliYear();
const [urlState, setUrlState] = useUrlState({ year: String(thisYear) });
const year = Number(urlState.year) || thisYear;
const { holidays, overrides, loading, error, 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>[] = [
// یک ستون تاریخ، نه دو تا: پیش‌تر «تاریخ» رشتهٔ خامِ `1405-05-13` بود و ستونِ
// «میلادی» با formatDate همان روز را **شمسی** نشان می‌داد — یک تاریخ، دو بار،
// با برچسبی که دروغ می‌گفت.
{ key: 'date', header: 'تاریخ', render: (h) => <span style={{ fontWeight: 600 }}>{formatDate(h.date)}</span> },
{ key: 'title', header: 'مناسبت', render: (h) => <span style={{ fontSize: 13 }}>{h.title}</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"
/>
{error && (
<div
className="card card-pad"
style={{ marginBottom: 'var(--gap)', color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
>
{error}
</div>
)}
<DataTable
columns={columns}
data={holidays}
loading={loading}
emptyMessage={`برای سال ${faYear(year)} تعطیلی ثبت نشده است`}
headerExtra={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 'auto' }}>
<label id="holidays-year-label" style={{ fontSize: 12, color: 'var(--text-2)' }}>سال</label>
<div style={{ minWidth: 130 }}>
<SearchableSelect
options={years.map((y) => ({ value: String(y), label: faYear(y) }))}
value={String(year)}
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
placeholder="سال"
ariaLabelledBy="holidays-year-label"
height={36}
/>
</div>
</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 card-pad" style={{ marginTop: 'var(--gap)' }}>
<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="mini-btn" onClick={() => onRemove(o.uuid)} aria-label="حذف تعطیلی">
</button>
)}
</div>
))}
</div>
)}
{canUpdate && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* تقویم شمسی، نه `input type=date`: آن میلادی است، placeholderش انگلیسی
(`mm/dd/yyyy`) و در دارک‌مود سفید می‌ماند چون کنترل نیتیو تم را نمی‌شناسد. */}
<div style={{ display: 'grid', gap: 6, minWidth: 190 }}>
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>تاریخ</span>
<PersianDateInput
value={date}
onChange={setDate}
placeholder="روز تعطیل را انتخاب کنید"
ariaLabel="تاریخ تعطیلی"
/>
</div>
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="closure-note">توضیح</label>
<input
id="closure-note"
className="field"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="اختیاری"
/>
</div>
<button
type="button"
className="btn secondary"
disabled={saving || timestamp === null}
onClick={() => {
onAdd(timestamp!, note.trim() === '' ? null : note.trim());
setDate('');
setNote('');
}}
>
افزودن
</button>
</div>
)}
</div>
);
}