feat: enhance resource calendar validation and UI

- 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.
This commit is contained in:
hamed
2026-08-03 09:37:22 +03:30
parent c42679d98c
commit 348e1cf517
9 changed files with 535 additions and 289 deletions
@@ -1,7 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ExclamationTriangleIcon, PlusIcon, Square2StackIcon, TrashIcon,
} from '@heroicons/react/24/outline';
import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
import { formatNumber } from '../../lib/utils';
@@ -24,6 +22,40 @@ function toMinutes(time: string): number | null {
return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null;
}
/**
* یک روز را اعتبارسنجی می‌کند و اولین ایراد را برمی‌گرداند (یا `null`).
*
* سرور هم همین سه قاعده را دارد (`ResourceCalendarService::assertRanges`) و مرجع
* نهایی اوست؛ این نسخه فقط بازخورد را از «بعد از کلیک ذخیره» به «حین تایپ» می‌آورد،
* چون تداخل ۰۹:۰۰–۱۳:۰۰ با ۱۲:۰۰–۱۷:۰۰ را باید همان‌جا که ساخته می‌شود دید.
*/
function validateDay(day: number, rows: Draft[]): string | null {
const parsed: { start: number; end: number }[] = [];
for (const row of rows) {
const start = toMinutes(row.start);
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
if (start === null || end === null) {
return `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید`;
}
if (end <= start) {
return `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد`;
}
parsed.push({ start, end });
}
// مرتب‌سازی لازم است: تداخل بین شیفت اول و سوم هم باید دیده شود، نه فقط همسایه‌ها.
const sorted = [...parsed].sort((a, b) => a.start - b.start);
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].start < sorted[i - 1].end) {
return `شیفت‌های روز ${DAY_LABELS[day]} با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند`;
}
}
return null;
}
/**
* شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز.
*
@@ -43,7 +75,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
const [draft, setDraft] = useState<Days>({});
// نسخهٔ سرور به‌صورت رشته نگه داشته می‌شود تا مقایسهٔ dirty یک `===` ساده باشد.
const [baseline, setBaseline] = useState<string | null>(null);
const [error, setError] = useState<{ day: number; message: string } | null>(null);
useEffect(() => {
if (!calendar) return;
@@ -57,7 +88,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
});
setDraft(next);
setBaseline(JSON.stringify(next));
setError(null);
}, [calendar]);
const totalShifts = useMemo(
@@ -65,6 +95,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
[draft],
);
// خطا مشتقِ draft است، نه state جدا: با state، پیامِ یک ویرایشِ اصلاح‌شده تا
// کلیکِ بعدیِ ذخیره روی صفحه می‌ماند.
const issues = useMemo(
() => DAY_LABELS.map((_, day) => validateDay(day, draft[day] ?? [])),
[draft],
);
const firstIssue = issues.find((m): m is string => m !== null) ?? null;
// پیش از اولین پاسخ سرور baseline نداریم، پس هیچ‌چیز dirty نیست.
const dirty = baseline !== null && JSON.stringify(draft) !== baseline;
@@ -77,48 +115,24 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
const removeRange = (day: number, index: number) =>
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }));
/** پرتکرارترین حالت، یک ساعتِ یکسان برای همهٔ روزهای کاری است؛ بدون این، تعریف
* یک هفتهٔ ساده ۱۵ تعامل می‌خواست. */
const copyToAllDays = (day: number) =>
setDraft((d) => {
const source = (d[day] ?? []).map((r) => ({ ...r }));
const next: Days = {};
DAY_LABELS.forEach((_, i) => { next[i] = source.map((r) => ({ ...r })); });
return next;
});
const reset = () => {
if (baseline === null) return;
setDraft(JSON.parse(baseline) as Days);
setError(null);
};
const submit = () => {
// دکمه هم غیرفعال است، ولی گاردِ دوم می‌ماند: ذخیره از راه دیگری هم صدا زده می‌شود.
if (firstIssue) return;
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
for (const [dayKey, rows] of Object.entries(draft)) {
const day = Number(dayKey);
const parsed: { start_minute: number; end_minute: number }[] = [];
for (const row of rows) {
const start = toMinutes(row.start);
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
if (start === null || end === null) {
setError({ day, message: `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید` });
return;
}
if (end <= start) {
setError({ day, message: `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد` });
return;
}
parsed.push({ start_minute: start, end_minute: end });
}
days[dayKey] = parsed;
days[dayKey] = rows.map((row) => ({
start_minute: toMinutes(row.start)!,
end_minute: row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end)!,
}));
}
setError(null);
save.mutate(days);
};
@@ -133,20 +147,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
</p>
{error && (
<div
role="alert"
style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12,
padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)', color: 'var(--danger)', fontSize: 13, fontWeight: 600,
}}
>
<ExclamationTriangleIcon style={{ width: 16, flexShrink: 0 }} />
{error.message}
</div>
)}
{loading ? (
<div style={{ display: 'grid', gap: 10 }}>
{DAY_LABELS.map((_, i) => (
@@ -157,7 +157,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
<div style={{ display: 'grid', gap: 10 }}>
{DAY_LABELS.map((label, day) => {
const rows = draft[day] ?? [];
const cls = ['wh-day', rows.length ? 'has-shift' : '', error?.day === day ? 'err' : '']
const issue = issues[day];
const cls = ['wh-day', rows.length ? 'has-shift' : '', issue ? 'err' : '']
.filter(Boolean).join(' ');
return (
@@ -218,22 +219,15 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
)}
</div>
))}
{issue && (
<p className="field-err" role="alert" style={{ marginTop: 2 }}>{issue}</p>
)}
</div>
)}
{canUpdate && (
<div className="wh-actions">
{rows.length > 0 && (
<button
type="button"
className="mini-btn"
onClick={() => copyToAllDays(day)}
title="کپی به همهٔ روزهای هفته"
aria-label={`کپی شیفت‌های ${label} به همهٔ روزهای هفته`}
>
<Square2StackIcon style={{ width: 16 }} />
</button>
)}
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
<PlusIcon style={{ width: 15 }} /> شیفت
</button>
@@ -247,15 +241,22 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
{canUpdate && dirty && (
<div className="save-bar">
<div className="sb-msg">
<ExclamationTriangleIcon style={{ width: 17, color: 'var(--warning)' }} />
تغییرات شیفت ذخیره نشده است
<div className="sb-msg" style={firstIssue ? { color: 'var(--danger)' } : undefined}>
<ExclamationTriangleIcon
style={{ width: 17, flexShrink: 0, color: firstIssue ? 'var(--danger)' : 'var(--warning)' }}
/>
{firstIssue ?? 'تغییرات شیفت ذخیره نشده است'}
</div>
<div className="sb-actions">
<button type="button" className="btn ghost" disabled={save.isPending} onClick={reset}>
بازگرداندن
</button>
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
<button
type="button"
className="btn primary"
disabled={save.isPending || !!firstIssue}
onClick={submit}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
</div>