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>
192 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|