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
@@ -0,0 +1,183 @@
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>
);
}
@@ -1,14 +1,10 @@
import React, { useId, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
import ConfirmDialog from '../ui/ConfirmDialog';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
import React from 'react';
import { useResourceAvailability } from '../../hooks/useResourceCalendar';
import { formatDate, formatNumber } from '../../lib/utils';
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import type { ResourceAvailability, ResourceException } from '../../types';
import ResourceExceptionsCard from './ResourceExceptionsCard';
import type { ResourceAvailability } from '../../types';
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
const REASON_LABELS: Record<string, string> = {
@@ -20,13 +16,6 @@ const REASON_LABELS: Record<string, string> = {
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();
@@ -35,21 +24,16 @@ function todayMidnight(): number {
}
/**
* تعطیلات و استثناهای یک منبع، کنار پیش‌نمایش دو هفتهٔ ساعت آزاد.
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، مرخصی و سرویس، و پیش‌نمایش دو هفته.
*
* پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن
* کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد.
*
* دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودی‌اند و کنار هم می‌مانند،
* پیش‌نمایش خروجی است و ستون خودش را می‌گیرد.
* این ترکیب مخصوص صفحهٔ جزئیات منبع است، جایی که کل صفحه دربارهٔ همان یک منبع است
* و پیش‌نمایش پاسخِ «بعد از این تنظیمات، چه ساعتی باز می‌ماند؟» را می‌دهد. صفحهٔ
* تنظیمات نوبت‌دهی همان دو کارت را بدون پیش‌نمایش و به‌صورت تب می‌چیند.
*/
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);
@@ -58,27 +42,10 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
<div className="wh-two-col">
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
<NationalHolidaysCard canUpdate={canUpdate} />
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
saving={create.isPending}
onCreate={(payload) => create.mutate(payload)}
onDelete={setToDelete}
/>
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
</div>
<PreviewCard availability={availability} />
<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>
);
}
@@ -137,155 +104,3 @@ function PreviewCard({ availability }: { availability?: ResourceAvailability })
</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('');
// 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={() => onDelete(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={saving || invalid}
onClick={() => {
onCreate({
type,
starts_at: start!,
ends_at: endExclusive!,
reason: reason.trim() === '' ? null : reason.trim(),
});
setStartDate('');
setEndDate('');
setReason('');
}}
>
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
</button>
</div>
) : (
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
</p>
)}
</div>
);
}
@@ -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>