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>
This commit is contained in:
hamed
2026-08-02 17:25:56 +03:30
co-authored by Claude Opus 5
parent bd4347f9c5
commit f06efe26c0
6 changed files with 200 additions and 28 deletions
+60 -23
View File
@@ -2,6 +2,7 @@ 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';
@@ -9,31 +10,33 @@ 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, setOverride, removeOverride } = useHolidays(year);
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>[] = [
{ key: 'jalali_date', header: 'تاریخ', render: (h) => <span style={{ fontWeight: 600 }}>{h.jalali_date}</span> },
// یک ستون تاریخ، نه دو تا: پیش‌تر «تاریخ» رشتهٔ خامِ `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: 'gregorian',
header: 'میلادی',
render: (h) => <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDate(h.date)}</span>,
},
{
key: 'status',
header: 'وضعیت این محیط',
@@ -57,20 +60,33 @@ export default function HolidaysSettingsPage() {
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={`برای سال ${year} تعطیلی ثبت نشده است`}
emptyMessage={`برای سال ${faYear(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 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={
@@ -95,7 +111,7 @@ export default function HolidaysSettingsPage() {
disabled={setOverride.isPending}
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
>
این روز بازیم
این روز باز است
</button>
)}
</div>
@@ -136,7 +152,7 @@ function ClosureCard({
const timestamp = date === '' ? null : Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000);
return (
<div className="card" style={{ padding: 16, marginTop: 16 }}>
<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' }}>
روزهایی که تعطیل رسمی نیستند ولی این محیط بسته است. برای مرخصی یک نفر یا سرویس یک دستگاه،
@@ -153,7 +169,7 @@ function ClosureCard({
{formatDate(o.date)}{o.note ? ` · ${o.note}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onRemove(o.uuid)} aria-label="حذف تعطیلی">
<button type="button" className="mini-btn" onClick={() => onRemove(o.uuid)} aria-label="حذف تعطیلی">
</button>
)}
@@ -163,9 +179,30 @@ function ClosureCard({
)}
{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 }} />
<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"