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 */}
<div className="topbar-search">
<MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
<input placeholder="جستجو" readOnly />
<input placeholder="جستجو" aria-label="جستجو" readOnly />
</div>
<div style={{ flex: 1 }} />
@@ -6,6 +6,8 @@ interface Props {
placeholder?: string;
/** انتخاب سال→ماه→روز را فعال می‌کند (مناسب تاریخ تولد). */
enableYearPicker?: boolean;
/** نام دسترس‌پذیر؛ تریگر `input` نیست پس `<label htmlFor>` رویش نمی‌نشیند. */
ariaLabel?: string;
}
/**
@@ -12,9 +12,14 @@ interface Props {
minWidth?: number;
/** انتخاب سال→ماه→روز را فعال می‌کند (پیش‌فرض خاموش). */
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 triggerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
@@ -29,7 +34,16 @@ export default function PersianDatePicker({ value, onChange, placeholder = 'ان
return (
<div ref={triggerRef} style={{ position: 'relative', display: 'inline-block' }}>
<div
role="button"
tabIndex={0}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel ?? placeholder}
onClick={toggle}
// با موس باز می‌شد و با کیبورد نه؛ Enter/Space همان کاری را می‌کنند که کلیک.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
}}
style={{
display: 'flex', alignItems: 'center', gap: 7,
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 }} />
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
{value && (
<span
<button
type="button"
aria-label="پاک کردن تاریخ"
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 }} />
</span>
</button>
)}
</div>
@@ -106,6 +106,9 @@ export function useHolidays(year: number) {
holidays: query.data?.data?.holidays ?? [],
overrides: query.data?.data?.overrides ?? [],
loading: query.isLoading,
// بدون این، درخواستِ شکست‌خورده مثل «سالِ خالی» دیده می‌شود و کاربر دنبال
// تعطیلاتی می‌گردد که ثبت شده‌اند ولی نیامده‌اند.
error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null,
setOverride,
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 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"