- Implement real-time validation for overlapping shifts in the ResourceWorkingHoursPanel. - Remove the copy shift functionality to simplify the UI and prevent confusion. - Introduce ResourceExceptionsCard to manage resource exceptions, including leave and maintenance. - Update ClinicAppointmentSettingsPage to utilize new components and improve tab navigation for resource management. - Add comprehensive validation tests for resource calendar to ensure overlapping shifts are correctly handled. - Update API documentation to reflect new validation error messages and rules.
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
import React, { useId, useMemo, useState } from 'react';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
import ConfirmDialog from '../ui/ConfirmDialog';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import PersianDateInput from '../ui/PersianDateInput';
|
|
import { useResourceExceptions } from '../../hooks/useResourceCalendar';
|
|
import { formatDate } from '../../lib/utils';
|
|
import type { ResourceException } from '../../types';
|
|
|
|
const EXCEPTION_TYPES = [
|
|
{ value: 'leave', label: 'مرخصی' },
|
|
{ value: 'absence', label: 'غیبت' },
|
|
{ value: 'maintenance', label: 'سرویس دورهای' },
|
|
{ value: 'closure', label: 'تعطیلی موردی' },
|
|
];
|
|
|
|
/**
|
|
* مرخصی و سرویسِ یک منبع — فهرست استثناهای ثبتشده و فرم ثبت استثنای تازه.
|
|
*
|
|
* کارتِ مستقل است چون دو ترکیب متفاوت دارد: در صفحهٔ جزئیات منبع کنار تعطیلات رسمی
|
|
* و پیشنمایش مینشیند (`ResourceExceptionsPanel`)، و در تنظیمات نوبتدهی یکی از دو
|
|
* تبِ زیر شیفت هفتگی است. حذفِ دوبارهنویسی، نه انتزاعِ زودرس.
|
|
*/
|
|
export default function ResourceExceptionsCard({ resourceUuid, canUpdate }: {
|
|
resourceUuid?: string;
|
|
canUpdate: boolean;
|
|
}) {
|
|
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
|
|
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
|
|
|
|
const [type, setType] = useState<string>('leave');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
|
|
// id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبتدهی هر دو رندر میشود.
|
|
const uid = useId();
|
|
|
|
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;
|
|
|
|
const typeLabel = useMemo(
|
|
() => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '',
|
|
[type],
|
|
);
|
|
|
|
return (
|
|
<div className="card card-pad">
|
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>
|
|
مرخصی و سرویس
|
|
</h2>
|
|
|
|
{exceptions.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 14px' }}>
|
|
استثنایی ثبت نشده است.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
|
{exceptions.map((e) => (
|
|
<div
|
|
key={e.uuid}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13,
|
|
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
|
}}
|
|
>
|
|
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
|
|
<span style={{ flex: 1, color: 'var(--text-2)', minWidth: 0 }}>
|
|
{formatDate(e.starts_at)} تا {formatDate(e.ends_at)}
|
|
{e.reason ? ` · ${e.reason}` : ''}
|
|
</span>
|
|
{canUpdate && (
|
|
<button
|
|
type="button"
|
|
className="mini-btn danger"
|
|
onClick={() => setToDelete(e)}
|
|
aria-label={`حذف ${e.type_label} از ${formatDate(e.starts_at)}`}
|
|
>
|
|
<XMarkIcon style={{ width: 16 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{canUpdate ? (
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
<div className="field-block">
|
|
<label id={`${uid}-type-label`} htmlFor={`${uid}-type`}>نوع استثنا</label>
|
|
<SearchableSelect
|
|
inputId={`${uid}-type`}
|
|
ariaLabelledBy={`${uid}-type-label`}
|
|
options={EXCEPTION_TYPES}
|
|
value={type}
|
|
onChange={(v) => setType(v ? String(v) : 'leave')}
|
|
placeholder="نوع استثنا"
|
|
height={40}
|
|
/>
|
|
</div>
|
|
|
|
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
|
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div className="field-block">
|
|
<label>از تاریخ</label>
|
|
<PersianDateInput
|
|
value={startDate}
|
|
onChange={setStartDate}
|
|
placeholder="از تاریخ"
|
|
ariaLabel={`تاریخ شروع ${typeLabel}`}
|
|
/>
|
|
</div>
|
|
<div className="field-block">
|
|
<label>تا تاریخ</label>
|
|
<PersianDateInput
|
|
value={endDate}
|
|
onChange={setEndDate}
|
|
placeholder="تا تاریخ"
|
|
ariaLabel={`تاریخ پایان ${typeLabel}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="field-block">
|
|
<label htmlFor={`${uid}-reason`}>توضیح <span className="opt">(اختیاری)</span></label>
|
|
<label className="field">
|
|
<input
|
|
id={`${uid}-reason`}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="مثلاً سرویس سالانهٔ دستگاه"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={create.isPending || invalid}
|
|
onClick={() => {
|
|
create.mutate({
|
|
type,
|
|
starts_at: start!,
|
|
ends_at: endExclusive!,
|
|
reason: reason.trim() === '' ? null : reason.trim(),
|
|
});
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setReason('');
|
|
}}
|
|
>
|
|
{create.isPending ? 'در حال ثبت...' : 'ثبت استثنا'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
|
|
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
|
|
</p>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!toDelete}
|
|
title="حذف استثنا"
|
|
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={remove.isPending}
|
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|