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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user