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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user