The holiday model was already right — national holidays global, a per-tenant override in both directions, per-doctor and per-resource exceptions — but nothing could create a national holiday. The only writer was an import command, so the calendar the whole product inherits from had no owner. Three admin-only routes give it one. POST upserts, because `date` is unique and re-sending a day should rename it rather than surface a raw database error; PATCH takes only the title, because moving a date means a different holiday. The system admin has no work environment, so the list endpoint now returns the calendar with an empty `overrides` for that role instead of the 403 `pair()` would raise — the person who maintains the calendar has to be able to read it. Both holiday tabs — the doctor's and the resource's — now open with the official calendar above their own exceptions, from one shared card rather than two copies that would drift. Each row can be opted out of with a single click, which is the existing holiday-override endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
8.2 KiB
TypeScript
198 lines
8.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import ConfirmDialog from '../ui/ConfirmDialog';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import PersianDateInput from '../ui/PersianDateInput';
|
|
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
|
|
import { formatDate } from '../../lib/utils';
|
|
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
|
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
|
import type { ResourceException } from '../../types';
|
|
|
|
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
|
const REASON_LABELS: Record<string, string> = {
|
|
national_holiday: 'تعطیل رسمی',
|
|
tenant_holiday: 'تعطیلی این محیط',
|
|
no_shift: 'شیفتی تعریف نشده',
|
|
exception: 'مرخصی یا سرویس',
|
|
resource_inactive: 'منبع غیرفعال است',
|
|
address_inactive: 'محل نوبتدهی غیرفعال است',
|
|
};
|
|
|
|
const EXCEPTION_TYPES = [
|
|
{ value: 'leave', label: 'مرخصی' },
|
|
{ value: 'absence', label: 'غیبت' },
|
|
{ value: 'maintenance', label: 'سرویس دورهای' },
|
|
{ value: 'closure', label: 'تعطیلی موردی' },
|
|
];
|
|
|
|
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
|
function todayMidnight(): number {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return Math.floor(d.getTime() / 1000);
|
|
}
|
|
|
|
/**
|
|
* تعطیلات و استثناهای یک منبع، کنار پیشنمایش دو هفتهٔ ساعت آزاد.
|
|
*
|
|
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
|
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
|
*/
|
|
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
|
resourceUuid?: string;
|
|
canUpdate: boolean;
|
|
}) {
|
|
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
|
|
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
|
|
|
|
const previewFrom = todayMidnight();
|
|
const previewTo = previewFrom + 13 * 86400;
|
|
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
|
|
<NationalHolidaysCard canUpdate={canUpdate} />
|
|
|
|
<ExceptionsCard
|
|
exceptions={exceptions}
|
|
canUpdate={canUpdate}
|
|
saving={create.isPending}
|
|
onCreate={(payload) => create.mutate(payload)}
|
|
onDelete={setToDelete}
|
|
/>
|
|
|
|
<div className="card" style={{ padding: 14 }}>
|
|
<h2 className="section-title" style={{ margin: '0 0 4px' }}>پیشنمایش دو هفته</h2>
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
|
|
ساعت <strong>خام</strong> — نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
|
|
</p>
|
|
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
{(availability?.days ?? []).map((day) => (
|
|
<div
|
|
key={day.date}
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 13 }}
|
|
>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)}
|
|
</span>
|
|
{day.intervals.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
|
|
</span>
|
|
) : (
|
|
<span style={{ fontWeight: 600 }}>{day.total_minutes} دقیقه</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!toDelete}
|
|
title="حذف استثنا"
|
|
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
|
|
confirmLabel="حذف"
|
|
loading={remove.isPending}
|
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExceptionsCard({
|
|
exceptions, canUpdate, saving, onCreate, onDelete,
|
|
}: {
|
|
exceptions: ResourceException[];
|
|
canUpdate: boolean;
|
|
saving: boolean;
|
|
onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void;
|
|
onDelete: (e: ResourceException) => void;
|
|
}) {
|
|
const [type, setType] = useState<string>('leave');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
|
|
const toTimestamp = (value: string): number | null => {
|
|
if (value === '') return null;
|
|
const ms = new Date(`${value}T00:00:00`).getTime();
|
|
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
|
|
};
|
|
|
|
const start = toTimestamp(startDate);
|
|
const end = toTimestamp(endDate);
|
|
// پایان روزِ انتخابشده، نه آغازش: مرخصیِ «تا سهشنبه» شامل خودِ سهشنبه است.
|
|
const endExclusive = end === null ? null : end + 86400;
|
|
const invalid = start === null || endExclusive === null || endExclusive <= start;
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 14 }}>
|
|
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
|
|
|
|
{exceptions.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 10px' }}>استثنایی ثبت نشده است.</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
|
|
{exceptions.map((e) => (
|
|
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
|
|
<span style={{ flex: 1, color: 'var(--text-2)' }}>
|
|
{formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)}
|
|
{e.reason ? ` · ${e.reason}` : ''}
|
|
</span>
|
|
{canUpdate && (
|
|
<button type="button" className="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{canUpdate && (
|
|
<div style={{ display: 'grid', gap: 8 }}>
|
|
<SearchableSelect
|
|
options={EXCEPTION_TYPES}
|
|
value={type}
|
|
onChange={(v) => setType(v ? String(v) : 'leave')}
|
|
placeholder="نوع استثنا"
|
|
height={36}
|
|
/>
|
|
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
|
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
|
|
</div>
|
|
</div>
|
|
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
disabled={saving || invalid}
|
|
onClick={() => {
|
|
onCreate({
|
|
type,
|
|
starts_at: start!,
|
|
ends_at: endExclusive!,
|
|
reason: reason.trim() === '' ? null : reason.trim(),
|
|
});
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setReason('');
|
|
}}
|
|
>
|
|
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|