Files
clinicpro/assets/admin/pages/HolidaysSettingsPage.tsx
T
hamedandClaude Opus 5 4380e64a5d fix(settings): every settings page renders the settings shell
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:49 +03:30

192 lines
7.8 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 { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useHolidays } from '../hooks/useResourceCalendar';
import { formatDate } from '../lib/utils';
import type { NationalHoliday } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** سالِ شمسی جاری از همان فرمت‌کنندهٔ شمسیِ مرورگر گرفته می‌شود، نه با محاسبهٔ دستی. */
function currentJalaliYear(): number {
const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date());
return Number(formatted.replace(/\D/g, '')) || 1405;
}
/**
* تعطیلات رسمی و استثناهای این محیط.
*
* خودِ تعطیلات کشوری‌اند و اینجا فقط دیده می‌شوند؛ آنچه محیط تغییر می‌دهد «باز بودن
* یا نبودنِ» همان روز برای خودش است.
*/
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 { 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> },
{ 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 * 1000)}</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"
/>
<DataTable
columns={columns}
data={holidays}
loading={loading}
emptyMessage={`برای سال ${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>
}
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" style={{ padding: 16, marginTop: 16 }}>
<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 * 1000)}{o.note ? ` · ${o.note}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onRemove(o.uuid)} aria-label="حذف تعطیلی">
</button>
)}
</div>
))}
</div>
)}
{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 }} />
<button
type="button"
className="btn secondary"
disabled={saving || timestamp === null}
onClick={() => {
onAdd(timestamp!, note.trim() === '' ? null : note.trim());
setDate('');
setNote('');
}}
>
افزودن
</button>
</div>
)}
</div>
);
}