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
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
{/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */} {/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */}
<div className="topbar-search"> <div className="topbar-search">
<MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} /> <MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
<input placeholder="جستجو" readOnly /> <input placeholder="جستجو" aria-label="جستجو" readOnly />
</div> </div>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
@@ -6,6 +6,8 @@ interface Props {
placeholder?: string; placeholder?: string;
/** انتخاب سال→ماه→روز را فعال می‌کند (مناسب تاریخ تولد). */ /** انتخاب سال→ماه→روز را فعال می‌کند (مناسب تاریخ تولد). */
enableYearPicker?: boolean; enableYearPicker?: boolean;
/** نام دسترس‌پذیر؛ تریگر `input` نیست پس `<label htmlFor>` رویش نمی‌نشیند. */
ariaLabel?: string;
} }
/** /**
@@ -12,9 +12,14 @@ interface Props {
minWidth?: number; minWidth?: number;
/** انتخاب سال→ماه→روز را فعال می‌کند (پیش‌فرض خاموش). */ /** انتخاب سال→ماه→روز را فعال می‌کند (پیش‌فرض خاموش). */
enableYearPicker?: boolean; enableYearPicker?: boolean;
/**
* نام دسترس‌پذیر. تریگر یک `div` است نه `input`، پس `<label htmlFor>` رویش نمی‌نشیند
* و بدون این، screen reader فقط «button» می‌شنود. پیش‌فرض روی placeholder می‌افتد.
*/
ariaLabel?: string;
} }
export default function PersianDatePicker({ value, onChange, placeholder = 'انتخاب تاریخ', height = 38, minWidth = 148, enableYearPicker = false }: Props) { export default function PersianDatePicker({ value, onChange, placeholder = 'انتخاب تاریخ', height = 38, minWidth = 148, enableYearPicker = false, ariaLabel }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; right: number } | null>(null); const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
@@ -29,7 +34,16 @@ export default function PersianDatePicker({ value, onChange, placeholder = 'ان
return ( return (
<div ref={triggerRef} style={{ position: 'relative', display: 'inline-block' }}> <div ref={triggerRef} style={{ position: 'relative', display: 'inline-block' }}>
<div <div
role="button"
tabIndex={0}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel ?? placeholder}
onClick={toggle} onClick={toggle}
// با موس باز می‌شد و با کیبورد نه؛ Enter/Space همان کاری را می‌کنند که کلیک.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
}}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 7, display: 'flex', alignItems: 'center', gap: 7,
height, padding: '0 10px', borderRadius: 'var(--r-sm)', height, padding: '0 10px', borderRadius: 'var(--r-sm)',
@@ -41,12 +55,17 @@ export default function PersianDatePicker({ value, onChange, placeholder = 'ان
<CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} /> <CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span> <span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
{value && ( {value && (
<span <button
type="button"
aria-label="پاک کردن تاریخ"
onClick={(e) => { e.stopPropagation(); onChange(''); }} onClick={(e) => { e.stopPropagation(); onChange(''); }}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }} style={{
display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)',
background: 'none', border: 0, padding: 0,
}}
> >
<XMarkIcon style={{ width: 13, height: 13 }} /> <XMarkIcon style={{ width: 13, height: 13 }} />
</span> </button>
)} )}
</div> </div>
@@ -106,6 +106,9 @@ export function useHolidays(year: number) {
holidays: query.data?.data?.holidays ?? [], holidays: query.data?.data?.holidays ?? [],
overrides: query.data?.data?.overrides ?? [], overrides: query.data?.data?.overrides ?? [],
loading: query.isLoading, loading: query.isLoading,
// بدون این، درخواستِ شکست‌خورده مثل «سالِ خالی» دیده می‌شود و کاربر دنبال
// تعطیلاتی می‌گردد که ثبت شده‌اند ولی نیامده‌اند.
error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null,
setOverride, setOverride,
removeOverride, removeOverride,
}; };
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
const setOverride = { mutate: vi.fn(), isPending: false };
const removeOverride = { mutate: vi.fn(), isPending: false };
let holidays: Array<{ uuid: string; date: number; jalali_date: string; title: string }> = [];
let overrides: Array<{ uuid: string; date: number; is_working: boolean; note: string | null }> = [];
let error: string | null = null;
vi.mock('../hooks/useResourceCalendar', () => ({
useHolidays: () => ({ holidays, overrides, loading: false, error, setOverride, removeOverride }),
}));
import HolidaysSettingsPage from './HolidaysSettingsPage';
// ۱۴۰۵-۰۵-۱۳ ⇒ ۲۰۲۶-۰۸-۰۴
const MARTYRDOM = {
uuid: 'h-1',
date: Math.floor(new Date(2026, 7, 4).getTime() / 1000),
jalali_date: '1405-05-13',
title: 'شهادت',
};
describe('HolidaysSettingsPage', () => {
beforeEach(() => {
holidays = [MARTYRDOM];
overrides = [];
error = null;
vi.clearAllMocks();
});
/**
* پیش‌تر دو ستون تاریخ بود: «تاریخ» رشتهٔ خامِ `1405-05-13` و «میلادی» که با
* formatDate همان روز را **شمسی** می‌داد. یک تاریخ، دو بار، با برچسبی که دروغ می‌گفت.
*/
it('یک ستون تاریخ دارد و شمسی است، نه رشتهٔ خام', () => {
renderWithProviders(<HolidaysSettingsPage />);
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
expect(screen.queryByText('1405-05-13')).not.toBeInTheDocument();
expect(screen.queryByText('میلادی')).not.toBeInTheDocument();
});
it('روز تعطیل را برای این محیط باز می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(<HolidaysSettingsPage />);
await user.click(screen.getByRole('button', { name: 'این روز باز است' }));
expect(setOverride.mutate).toHaveBeenCalledWith({ date: MARTYRDOM.date, is_working: true });
});
it('روزِ استثنا خورده را دوباره تعطیل می‌کند', async () => {
overrides = [{ uuid: 'o-1', date: MARTYRDOM.date, is_working: true, note: null }];
const user = userEvent.setup();
renderWithProviders(<HolidaysSettingsPage />);
expect(screen.getByText('باز است')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'تعطیل کن' }));
expect(removeOverride.mutate).toHaveBeenCalledWith('o-1');
});
/** بدون این، درخواستِ شکست‌خورده مثل «سالِ خالی» دیده می‌شد. */
it('خطای سرور را نشان می‌دهد، نه سالِ خالی', () => {
error = 'خطای داخلی سرور';
holidays = [];
renderWithProviders(<HolidaysSettingsPage />);
expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument();
});
it('سالِ خالی را با ارقام فارسی می‌گوید', () => {
holidays = [];
renderWithProviders(<HolidaysSettingsPage />, { route: '/admin/holidays?year=1404' });
expect(screen.getByText('برای سال ۱۴۰۴ تعطیلی ثبت نشده است')).toBeInTheDocument();
});
/** انتخابگر سال و ورودی تاریخ هر دو باید نام دسترس‌پذیر داشته باشند. */
it('فیلدها نام دارند', () => {
renderWithProviders(<HolidaysSettingsPage />);
expect(screen.getByRole('button', { name: 'تاریخ تعطیلی' })).toBeInTheDocument();
expect(screen.getByLabelText('توضیح')).toBeInTheDocument();
});
/** ورودی نیتیو میلادی بود و در دارک‌مود سفید می‌ماند؛ باید رفته باشد. */
it('ورودی تاریخ نیتیو ندارد', () => {
const { container } = renderWithProviders(<HolidaysSettingsPage />);
expect(container.querySelector('input[type="date"]')).toBeNull();
});
it('تعطیلی محیط را با تاریخ و توضیح ثبت می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(<HolidaysSettingsPage />);
// بدون تاریخ، ثبت ممکن نیست.
expect(screen.getByRole('button', { name: 'افزودن' })).toBeDisabled();
await user.type(screen.getByLabelText('توضیح'), 'تعطیلی داخلی');
expect(screen.getByRole('button', { name: 'افزودن' })).toBeDisabled();
});
});
+60 -23
View File
@@ -2,6 +2,7 @@ import React from 'react';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable'; import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import { useUrlState } from '../hooks/useUrlState'; import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useHolidays } from '../hooks/useResourceCalendar'; import { useHolidays } from '../hooks/useResourceCalendar';
@@ -9,31 +10,33 @@ import { formatDate, currentJalaliYear } from '../lib/utils';
import type { NationalHoliday } from '../types'; import type { NationalHoliday } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
/** سال بدون جداکنندهٔ هزارگان — `formatNumber` «۱٬۴۰۵» می‌داد. */
const faYear = (y: number) => y.toLocaleString('fa-IR', { useGrouping: false });
/** /**
* تعطیلات رسمی و استثناهای این محیط. * تعطیلات رسمی و استثناهای این محیط.
* *
* خودِ تعطیلات کشوری‌اند و اینجا فقط دیده می‌شوند؛ آنچه محیط تغییر می‌دهد «باز بودن * خودِ تعطیلات کشوری‌اند و اینجا فقط دیده می‌شوند؛ آنچه محیط تغییر می‌دهد «باز بودن
* یا نبودنِ» همان روز برای خودش است. * یا نبودنِ» همان روز برای خودش است. ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است
* (`/admin/national-holidays`).
*/ */
export default function HolidaysSettingsPage() { export default function HolidaysSettingsPage() {
const thisYear = currentJalaliYear(); const thisYear = currentJalaliYear();
const [urlState, setUrlState] = useUrlState({ year: String(thisYear) }); const [urlState, setUrlState] = useUrlState({ year: String(thisYear) });
const year = Number(urlState.year) || 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 { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update'); const canUpdate = can('appointment_settings', 'update');
const overrideByDate = new Map(overrides.map((o) => [o.date, o])); const overrideByDate = new Map(overrides.map((o) => [o.date, o]));
const columns: Column<NationalHoliday>[] = [ 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: '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', key: 'status',
header: 'وضعیت این محیط', header: 'وضعیت این محیط',
@@ -57,20 +60,33 @@ export default function HolidaysSettingsPage() {
backTo="/admin/settings-menu" 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 <DataTable
columns={columns} columns={columns}
data={holidays} data={holidays}
loading={loading} loading={loading}
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`} emptyMessage={`برای سال ${faYear(year)} تعطیلی ثبت نشده است`}
headerExtra={ headerExtra={
<div style={{ minWidth: 160, marginRight: 'auto' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 'auto' }}>
<SearchableSelect <label id="holidays-year-label" style={{ fontSize: 12, color: 'var(--text-2)' }}>سال</label>
options={years.map((y) => ({ value: String(y), label: String(y) }))} <div style={{ minWidth: 130 }}>
value={String(year)} <SearchableSelect
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })} options={years.map((y) => ({ value: String(y), label: faYear(y) }))}
placeholder="سال" value={String(year)}
height={36} onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
/> placeholder="سال"
ariaLabelledBy="holidays-year-label"
height={36}
/>
</div>
</div> </div>
} }
actions={ actions={
@@ -95,7 +111,7 @@ export default function HolidaysSettingsPage() {
disabled={setOverride.isPending} disabled={setOverride.isPending}
onClick={() => setOverride.mutate({ date: h.date, is_working: true })} onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
> >
این روز بازیم این روز باز است
</button> </button>
)} )}
</div> </div>
@@ -136,7 +152,7 @@ function ClosureCard({
const timestamp = date === '' ? null : Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000); const timestamp = date === '' ? null : Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000);
return ( 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> <h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلیهای این محیط</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 12px' }}> <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}` : ''} {formatDate(o.date)}{o.note ? ` · ${o.note}` : ''}
</span> </span>
{canUpdate && ( {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> </button>
)} )}
@@ -163,9 +179,30 @@ function ClosureCard({
)} )}
{canUpdate && ( {canUpdate && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<input type="date" className="field" value={date} onChange={(e) => setDate(e.target.value)} style={{ minWidth: 170 }} /> {/* تقویم شمسی، نه `input type=date`: آن میلادی است، placeholderش انگلیسی
<input className="field" value={note} onChange={(e) => setNote(e.target.value)} placeholder="توضیح (اختیاری)" style={{ flex: 1, minWidth: 180 }} /> (`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 <button
type="button" type="button"
className="btn secondary" className="btn secondary"