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 React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useResourceAvailability } from '../../hooks/useResourceCalendar';
|
||||||
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 { formatDate, formatNumber } from '../../lib/utils';
|
import { formatDate, formatNumber } from '../../lib/utils';
|
||||||
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
||||||
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
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> = {
|
const REASON_LABELS: Record<string, string> = {
|
||||||
@@ -20,13 +16,6 @@ const REASON_LABELS: Record<string, string> = {
|
|||||||
address_inactive: 'محل نوبتدهی غیرفعال است',
|
address_inactive: 'محل نوبتدهی غیرفعال است',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXCEPTION_TYPES = [
|
|
||||||
{ value: 'leave', label: 'مرخصی' },
|
|
||||||
{ value: 'absence', label: 'غیبت' },
|
|
||||||
{ value: 'maintenance', label: 'سرویس دورهای' },
|
|
||||||
{ value: 'closure', label: 'تعطیلی موردی' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
||||||
function todayMidnight(): number {
|
function todayMidnight(): number {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -35,21 +24,16 @@ function todayMidnight(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تعطیلات و استثناهای یک منبع، کنار پیشنمایش دو هفتهٔ ساعت آزاد.
|
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، مرخصی و سرویس، و پیشنمایش دو هفته.
|
||||||
*
|
*
|
||||||
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
* این ترکیب مخصوص صفحهٔ جزئیات منبع است، جایی که کل صفحه دربارهٔ همان یک منبع است
|
||||||
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
* و پیشنمایش پاسخِ «بعد از این تنظیمات، چه ساعتی باز میماند؟» را میدهد. صفحهٔ
|
||||||
*
|
* تنظیمات نوبتدهی همان دو کارت را بدون پیشنمایش و بهصورت تب میچیند.
|
||||||
* دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودیاند و کنار هم میمانند،
|
|
||||||
* پیشنمایش خروجی است و ستون خودش را میگیرد.
|
|
||||||
*/
|
*/
|
||||||
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||||
resourceUuid?: string;
|
resourceUuid?: string;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
|
|
||||||
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
|
|
||||||
|
|
||||||
const previewFrom = todayMidnight();
|
const previewFrom = todayMidnight();
|
||||||
const previewTo = previewFrom + 13 * 86400;
|
const previewTo = previewFrom + 13 * 86400;
|
||||||
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
||||||
@@ -58,27 +42,10 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
|||||||
<div className="wh-two-col">
|
<div className="wh-two-col">
|
||||||
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||||
<NationalHolidaysCard canUpdate={canUpdate} />
|
<NationalHolidaysCard canUpdate={canUpdate} />
|
||||||
<ExceptionsCard
|
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
|
||||||
exceptions={exceptions}
|
|
||||||
canUpdate={canUpdate}
|
|
||||||
saving={create.isPending}
|
|
||||||
onCreate={(payload) => create.mutate(payload)}
|
|
||||||
onDelete={setToDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PreviewCard availability={availability} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,155 +104,3 @@ function PreviewCard({ availability }: { availability?: ResourceAvailability })
|
|||||||
</div>
|
</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 React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||||
ExclamationTriangleIcon, PlusIcon, Square2StackIcon, TrashIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
|
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
|
||||||
import { formatNumber } from '../../lib/utils';
|
import { formatNumber } from '../../lib/utils';
|
||||||
|
|
||||||
@@ -24,6 +22,40 @@ function toMinutes(time: string): number | null {
|
|||||||
return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : 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>({});
|
const [draft, setDraft] = useState<Days>({});
|
||||||
// نسخهٔ سرور بهصورت رشته نگه داشته میشود تا مقایسهٔ dirty یک `===` ساده باشد.
|
// نسخهٔ سرور بهصورت رشته نگه داشته میشود تا مقایسهٔ dirty یک `===` ساده باشد.
|
||||||
const [baseline, setBaseline] = useState<string | null>(null);
|
const [baseline, setBaseline] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<{ day: number; message: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!calendar) return;
|
if (!calendar) return;
|
||||||
@@ -57,7 +88,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
});
|
});
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
setBaseline(JSON.stringify(next));
|
setBaseline(JSON.stringify(next));
|
||||||
setError(null);
|
|
||||||
}, [calendar]);
|
}, [calendar]);
|
||||||
|
|
||||||
const totalShifts = useMemo(
|
const totalShifts = useMemo(
|
||||||
@@ -65,6 +95,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
[draft],
|
[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 نیست.
|
// پیش از اولین پاسخ سرور baseline نداریم، پس هیچچیز dirty نیست.
|
||||||
const dirty = baseline !== null && JSON.stringify(draft) !== baseline;
|
const dirty = baseline !== null && JSON.stringify(draft) !== baseline;
|
||||||
|
|
||||||
@@ -77,48 +115,24 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
const removeRange = (day: number, index: number) =>
|
const removeRange = (day: number, index: number) =>
|
||||||
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }));
|
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 = () => {
|
const reset = () => {
|
||||||
if (baseline === null) return;
|
if (baseline === null) return;
|
||||||
setDraft(JSON.parse(baseline) as Days);
|
setDraft(JSON.parse(baseline) as Days);
|
||||||
setError(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
// دکمه هم غیرفعال است، ولی گاردِ دوم میماند: ذخیره از راه دیگری هم صدا زده میشود.
|
||||||
|
if (firstIssue) return;
|
||||||
|
|
||||||
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
||||||
|
|
||||||
for (const [dayKey, rows] of Object.entries(draft)) {
|
for (const [dayKey, rows] of Object.entries(draft)) {
|
||||||
const day = Number(dayKey);
|
days[dayKey] = rows.map((row) => ({
|
||||||
const parsed: { start_minute: number; end_minute: number }[] = [];
|
start_minute: toMinutes(row.start)!,
|
||||||
|
end_minute: row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end)!,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
|
||||||
save.mutate(days);
|
save.mutate(days);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,20 +147,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
|
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
|
||||||
</p>
|
</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 ? (
|
{loading ? (
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
{DAY_LABELS.map((_, i) => (
|
{DAY_LABELS.map((_, i) => (
|
||||||
@@ -157,7 +157,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
{DAY_LABELS.map((label, day) => {
|
{DAY_LABELS.map((label, day) => {
|
||||||
const rows = draft[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(' ');
|
.filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -218,22 +219,15 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{issue && (
|
||||||
|
<p className="field-err" role="alert" style={{ marginTop: 2 }}>{issue}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<div className="wh-actions">
|
<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)}>
|
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
|
||||||
<PlusIcon style={{ width: 15 }} /> شیفت
|
<PlusIcon style={{ width: 15 }} /> شیفت
|
||||||
</button>
|
</button>
|
||||||
@@ -247,15 +241,22 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
|
|
||||||
{canUpdate && dirty && (
|
{canUpdate && dirty && (
|
||||||
<div className="save-bar">
|
<div className="save-bar">
|
||||||
<div className="sb-msg">
|
<div className="sb-msg" style={firstIssue ? { color: 'var(--danger)' } : undefined}>
|
||||||
<ExclamationTriangleIcon style={{ width: 17, color: 'var(--warning)' }} />
|
<ExclamationTriangleIcon
|
||||||
تغییرات شیفت ذخیره نشده است
|
style={{ width: 17, flexShrink: 0, color: firstIssue ? 'var(--danger)' : 'var(--warning)' }}
|
||||||
|
/>
|
||||||
|
{firstIssue ?? 'تغییرات شیفت ذخیره نشده است'}
|
||||||
</div>
|
</div>
|
||||||
<div className="sb-actions">
|
<div className="sb-actions">
|
||||||
<button type="button" className="btn ghost" disabled={save.isPending} onClick={reset}>
|
<button type="button" className="btn ghost" disabled={save.isPending} onClick={reset}>
|
||||||
بازگرداندن
|
بازگرداندن
|
||||||
</button>
|
</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 ? 'در حال ذخیره...' : 'ذخیرهٔ شیفتها'}
|
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفتها'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ vi.mock('../components/resources/ResourceWorkingHoursPanel', () => ({
|
|||||||
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>ساعات کاری {resourceUuid}</div>,
|
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>ساعات کاری {resourceUuid}</div>,
|
||||||
DAY_LABELS: [],
|
DAY_LABELS: [],
|
||||||
}));
|
}));
|
||||||
vi.mock('../components/resources/ResourceExceptionsPanel', () => ({
|
vi.mock('../components/resources/ResourceExceptionsCard', () => ({
|
||||||
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>تعطیلات {resourceUuid}</div>,
|
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>مرخصی {resourceUuid}</div>,
|
||||||
|
}));
|
||||||
|
vi.mock('../components/holidays/NationalHolidaysCard', () => ({
|
||||||
|
default: () => <div>تعطیلات رسمی سال</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let resources: Array<{ uuid: string; name: string; type_name: string }> = [];
|
let resources: Array<{ uuid: string; name: string; type_name: string }> = [];
|
||||||
@@ -60,17 +63,65 @@ describe('ClinicAppointmentSettingsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** منبع همانجایی مدیریت میشود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. */
|
/** منبع همانجایی مدیریت میشود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. */
|
||||||
it('تب منابع، ساعت کاری و تعطیلات منبع را میآورد', async () => {
|
it('تب منابع، برنامهٔ کاری منبع را میآورد', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(<ClinicAppointmentSettingsPage />);
|
renderWithProviders(<ClinicAppointmentSettingsPage />);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'منابع' }));
|
await user.click(screen.getByRole('button', { name: 'منابع' }));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
|
||||||
expect(screen.getByText('تعطیلات r-1')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('برنامهٔ پزشک')).not.toBeInTheDocument();
|
expect(screen.queryByText('برنامهٔ پزشک')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سه تب، نه سه بخشِ پشتسرهم: هر سه یک سؤال را جواب میدهند ولی همزمان لازم
|
||||||
|
* نیستند و چیدنشان زیر هم صفحه را سه برابر بلند میکرد.
|
||||||
|
*/
|
||||||
|
it('شیفت، تعطیلات و مرخصی سه تب مجزا هستند', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ClinicAppointmentSettingsPage />, {
|
||||||
|
route: '/admin/settings/appointment-settings?scope=resources',
|
||||||
|
});
|
||||||
|
|
||||||
|
// پیشفرض روی شیفت هفتگی است — کارِ اصلی صفحه.
|
||||||
|
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('تعطیلات رسمی سال')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('مرخصی r-1')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'تعطیلات رسمی' }));
|
||||||
|
expect(await screen.findByText('تعطیلات رسمی سال')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ساعات کاری r-1')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'مرخصی و سرویس' }));
|
||||||
|
expect(await screen.findByText('مرخصی r-1')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('تعطیلات رسمی سال')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** تب از URL خوانده میشود، پس رفرش و «بازگشت» همان نما را نگه میدارند. */
|
||||||
|
it('تب برنامهٔ کاری را از URL میخواند', async () => {
|
||||||
|
renderWithProviders(<ClinicAppointmentSettingsPage />, {
|
||||||
|
route: '/admin/settings/appointment-settings?scope=resources&calendarTab=exceptions',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('مرخصی r-1')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ساعات کاری r-1')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پیشنمایش دو هفته در این صفحه کاربردی ندارد و فقط ارتفاع اضافه میکرد. */
|
||||||
|
it('پیشنمایش دو هفته را نشان نمیدهد', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ClinicAppointmentSettingsPage />, {
|
||||||
|
route: '/admin/settings/appointment-settings?scope=resources',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('پیشنمایش دو هفته')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'مرخصی و سرویس' }));
|
||||||
|
await screen.findByText('مرخصی r-1');
|
||||||
|
expect(screen.queryByText('پیشنمایش دو هفته')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('بین منابع جابهجا میشود', async () => {
|
it('بین منابع جابهجا میشود', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(<ClinicAppointmentSettingsPage />);
|
renderWithProviders(<ClinicAppointmentSettingsPage />);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { useResources } from '../hooks/useResources';
|
|||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
||||||
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
||||||
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
|
import ResourceExceptionsCard from '../components/resources/ResourceExceptionsCard';
|
||||||
|
import NationalHolidaysCard from '../components/holidays/NationalHolidaysCard';
|
||||||
import FreeVisitPrice from '../components/FreeVisitPrice';
|
import FreeVisitPrice from '../components/FreeVisitPrice';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
|
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
|
||||||
@@ -82,18 +83,14 @@ function ClinicAppointmentSettingsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
|
{/* سوییچر پزشک/منبع در خودِ ردیف عنوان مینشیند: بالاترین نقطهٔ محتوا و همیشه
|
||||||
|
در دید، بدون اسکرول. پایینتر از هدر، کاربر باید دنبالش میگشت. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت نوبتدهی"
|
title="مدیریت نوبتدهی"
|
||||||
description={description}
|
description={description}
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
/>
|
action={(
|
||||||
|
<div className="seg" role="group" aria-label="نمای تنظیمات">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
|
||||||
{/* دو انتخابگر پشت سر هم میمانند — «نما» بعد «مورد». در اسلات action هدر،
|
|
||||||
سوییچر به لبهٔ مقابلِ صفحه میافتاد و از تبی که کنترل میکند جدا میشد. */}
|
|
||||||
<div>
|
|
||||||
<span className="field-label" id="appt-scope-tabs">نما</span>
|
|
||||||
<div className="seg" role="group" aria-labelledby="appt-scope-tabs">
|
|
||||||
{SCOPES.map((s) => (
|
{SCOPES.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
@@ -105,8 +102,10 @@ function ClinicAppointmentSettingsContent() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||||
{scope === 'doctors' ? (
|
{scope === 'doctors' ? (
|
||||||
<DoctorsScope
|
<DoctorsScope
|
||||||
loading={doctorsQ.isLoading}
|
loading={doctorsQ.isLoading}
|
||||||
@@ -250,15 +249,54 @@ function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: {
|
|||||||
|
|
||||||
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمهویرایششده به منبع بعدی میچسبد. */}
|
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمهویرایششده به منبع بعدی میچسبد. */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
|
<ResourceCalendarTabs key={selected.uuid} resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
||||||
<ResourceWorkingHoursPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
|
||||||
<ResourceExceptionsPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CALENDAR_TABS = [
|
||||||
|
{ id: 'shifts', label: 'شیفت هفتگی' },
|
||||||
|
{ id: 'holidays', label: 'تعطیلات رسمی' },
|
||||||
|
{ id: 'exceptions', label: 'مرخصی و سرویس' },
|
||||||
|
] as const;
|
||||||
|
type CalendarTab = typeof CALENDAR_TABS[number]['id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* برنامهٔ کاری منبع در سه تب: شیفت هفتگی، تعطیلات رسمی، مرخصی و سرویس.
|
||||||
|
*
|
||||||
|
* هر سه یک چیز را میسازند («این منبع کِی باز است؟») پس یکجا میمانند؛ ولی همزمان
|
||||||
|
* دیدهشدنشان لازم نیست و پشتسرهم چیدنشان صفحه را سه برابر بلند میکرد. تب فعال در
|
||||||
|
* URL مینشیند تا «بازگشت» و رفرش همان نما را برگردانند.
|
||||||
|
*/
|
||||||
|
function ResourceCalendarTabs({ resourceUuid, canUpdate }: { resourceUuid: string; canUpdate: boolean }) {
|
||||||
|
const [urlState, setUrlState] = useUrlState({ calendarTab: 'shifts' });
|
||||||
|
const tab = (CALENDAR_TABS.some((t) => t.id === urlState.calendarTab)
|
||||||
|
? urlState.calendarTab
|
||||||
|
: 'shifts') as CalendarTab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
<div className="seg" style={{ alignSelf: 'flex-start' }} role="group" aria-label="برنامهٔ کاری منبع">
|
||||||
|
{CALENDAR_TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={tab === t.id ? 'active' : ''}
|
||||||
|
aria-pressed={tab === t.id}
|
||||||
|
onClick={() => setUrlState({ calendarTab: t.id })}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'shifts' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
||||||
|
{tab === 'holidays' && <NationalHolidaysCard canUpdate={canUpdate} />}
|
||||||
|
{tab === 'exceptions' && <ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClinicAppointmentSettingsPage() {
|
export default function ClinicAppointmentSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout active="appointment">
|
<SettingsLayout active="appointment">
|
||||||
|
|||||||
@@ -171,20 +171,60 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** پرتکرارترین حالت یک ساعتِ یکسان برای کل هفته است؛ بدون کپی، ۱۵ تعامل لازم بود. */
|
/**
|
||||||
it('شیفت یک روز را به همهٔ روزهای هفته کپی میکند', async () => {
|
* دو شیفتِ متداخل در یک روز نباید ذخیره شود. سرور هم همین را رد میکند، ولی خطا
|
||||||
|
* باید حین ویرایش دیده شود نه بعد از رفتوبرگشت شبکه.
|
||||||
|
*/
|
||||||
|
it('شیفتهای متداخل را رد میکند و ذخیره را میبندد', async () => {
|
||||||
const days = emptyDays();
|
const days = emptyDays();
|
||||||
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
days['0'] = [
|
||||||
|
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
|
||||||
|
{ sequence: 1, start_minute: 1020, end_minute: 1140, start_time: '17:00', end_time: '19:00', active: true },
|
||||||
|
];
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApi(days);
|
||||||
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('17:00')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// ۱۷:۰۰–۱۹:۰۰ به ۱۲:۰۰ کشیده میشود و با ۰۹:۰۰–۱۳:۰۰ تداخل پیدا میکند.
|
||||||
|
await user.clear(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'));
|
||||||
|
await user.type(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'), '12:00');
|
||||||
|
|
||||||
|
// پیام دو جا میآید و باید بیاید: کنار خودِ روز، و در نوار ذخیره که دلیل قفل را میگوید.
|
||||||
|
expect(await screen.findAllByText(/شیفتهای روز شنبه با هم تداخل دارند/)).toHaveLength(2);
|
||||||
|
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** بازهٔ بدون تداخل نباید قربانی اعتبارسنجی شود — مرز چسبیده مجاز است. */
|
||||||
|
it('شیفتهای چسبیده اما بدون همپوشانی را میپذیرد', async () => {
|
||||||
|
const days = emptyDays();
|
||||||
|
days['0'] = [
|
||||||
|
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
|
||||||
|
{ sequence: 1, start_minute: 780, end_minute: 1020, start_time: '13:00', end_time: '17:00', active: true },
|
||||||
|
];
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockApi(days);
|
mockApi(days);
|
||||||
renderPage('/admin/resources/r1?tab=hours');
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
||||||
await user.click(screen.getByRole('button', { name: 'کپی شیفتهای شنبه به همهٔ روزهای هفته' }));
|
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getAllByDisplayValue('09:00')).toHaveLength(7));
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[1]);
|
||||||
expect(screen.getAllByDisplayValue('17:00')).toHaveLength(7);
|
|
||||||
expect(screen.queryByText('بدون شیفت — این روز بسته است')).not.toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** آیکون کپی گمراهکننده بود و حذف شد. */
|
||||||
|
it('دکمهٔ کپی شیفت ندارد', async () => {
|
||||||
|
const days = emptyDays();
|
||||||
|
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
||||||
|
mockApi(days);
|
||||||
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
||||||
|
|||||||
@@ -58,6 +58,22 @@
|
|||||||
{ "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } }
|
{ "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### خطاهای اعتبارسنجی (۴۲۲)
|
||||||
|
|
||||||
|
| شرط | `field` | پیام |
|
||||||
|
|---|---|---|
|
||||||
|
| `start_minute`/`end_minute` نبود یا عدد نبود | همان فیلد | `در روز شنبه مقدار start_minute الزامی است` |
|
||||||
|
| خارج از `0..1440` | همان فیلد | `در روز شنبه مقدار end_minute باید بین ۰ و ۱۴۴۰ باشد` |
|
||||||
|
| `end <= start` | `end_minute` | `در روز شنبه، پایان شیفت باید بعد از شروع آن باشد` |
|
||||||
|
| دو بازهٔ متداخل در یک روز | `start_minute` | `شیفتهای روز شنبه با هم تداخل دارند — بازهها نباید همپوشانی داشته باشند` |
|
||||||
|
|
||||||
|
پیامها **نام روز** را میگویند نه اندیس عددی را، چون مستقیم به کاربر نشان داده میشوند.
|
||||||
|
|
||||||
|
همپوشانی پس از مرتبسازی بازهها بررسی میشود، پس ترتیب ارسال مهم نیست و تداخل بین
|
||||||
|
بازهٔ اول و سوم هم گرفته میشود. مرزِ چسبیده همپوشانی **نیست**: `09:00–13:00` کنار
|
||||||
|
`13:00–17:00` معتبر است (شیفت صبح و عصر). پنل ادمین همین قواعد را حین ویرایش هم اجرا
|
||||||
|
میکند و تا رفع تداخل، دکمهٔ ذخیره را غیرفعال نگه میدارد؛ ولی مرجع نهایی سرور است.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی
|
## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ final class ResourceCalendarService
|
|||||||
{
|
{
|
||||||
public const DAYS = [0, 1, 2, 3, 4, 5, 6];
|
public const DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
/** ۰ = شنبه. پیام خطا باید نام روز را بگوید، نه اندیس خام را. */
|
||||||
|
private const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ResourceCalendarRepository $calendars,
|
private readonly ResourceCalendarRepository $calendars,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
@@ -115,7 +118,7 @@ final class ResourceCalendarService
|
|||||||
if ($end <= $start) {
|
if ($end <= $start) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCodes::ERR_VALIDATION_001,
|
ErrorCodes::ERR_VALIDATION_001,
|
||||||
sprintf('در روز %d، پایان شیفت باید بعد از شروع آن باشد', $day),
|
sprintf('در روز %s، پایان شیفت باید بعد از شروع آن باشد', self::DAY_LABELS[$day]),
|
||||||
422,
|
422,
|
||||||
'end_minute',
|
'end_minute',
|
||||||
);
|
);
|
||||||
@@ -130,7 +133,7 @@ final class ResourceCalendarService
|
|||||||
if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) {
|
if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCodes::ERR_VALIDATION_001,
|
ErrorCodes::ERR_VALIDATION_001,
|
||||||
sprintf('شیفتهای روز %d با هم همپوشانی دارند', $day),
|
sprintf('شیفتهای روز %s با هم تداخل دارند — بازهها نباید همپوشانی داشته باشند', self::DAY_LABELS[$day]),
|
||||||
422,
|
422,
|
||||||
'start_minute',
|
'start_minute',
|
||||||
);
|
);
|
||||||
@@ -145,7 +148,7 @@ final class ResourceCalendarService
|
|||||||
if (!is_numeric($value)) {
|
if (!is_numeric($value)) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCodes::ERR_VALIDATION_002,
|
ErrorCodes::ERR_VALIDATION_002,
|
||||||
sprintf('در روز %d مقدار %s الزامی است', $day, $field),
|
sprintf('در روز %s مقدار %s الزامی است', self::DAY_LABELS[$day], $field),
|
||||||
422,
|
422,
|
||||||
$field,
|
$field,
|
||||||
);
|
);
|
||||||
@@ -156,7 +159,7 @@ final class ResourceCalendarService
|
|||||||
if ($minute < 0 || $minute > ResourceCalendar::MINUTES_IN_DAY) {
|
if ($minute < 0 || $minute > ResourceCalendar::MINUTES_IN_DAY) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCodes::ERR_VALIDATION_001,
|
ErrorCodes::ERR_VALIDATION_001,
|
||||||
sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field),
|
sprintf('در روز %s مقدار %s باید بین ۰ و ۱۴۴۰ باشد', self::DAY_LABELS[$day], $field),
|
||||||
422,
|
422,
|
||||||
$field,
|
$field,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قواعد اعتبارسنجی شیفت هفتگی منبع.
|
||||||
|
*
|
||||||
|
* پنل ادمین همین سه قاعده را حین تایپ هم اجرا میکند، ولی مرجع نهایی سرور است:
|
||||||
|
* کلاینت قدیمی، اسکریپت، و درخواست مستقیم به API نباید بتوانند شیفت متداخل بنشانند.
|
||||||
|
*/
|
||||||
|
class ResourceCalendarValidationTest extends ResourceTestCase
|
||||||
|
{
|
||||||
|
/** @param array<int|string, mixed> $days */
|
||||||
|
private function putCalendar(array $days): array
|
||||||
|
{
|
||||||
|
[$user, , $address] = $this->clinicWithAddress();
|
||||||
|
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||||
|
|
||||||
|
return $this->authJson(
|
||||||
|
'PUT',
|
||||||
|
"/api/v1/resource/{$resource['data']['uuid']}/calendar",
|
||||||
|
$user,
|
||||||
|
['days' => $days],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ۰۹:۰۰–۱۳:۰۰ کنار ۱۲:۰۰–۱۷:۰۰ یعنی یک ساعت ظرفیتِ دوبارهفروختهشده. */
|
||||||
|
public function testOverlappingShiftsInTheSameDayAreRejected(): void
|
||||||
|
{
|
||||||
|
$body = $this->putCalendar([
|
||||||
|
0 => [
|
||||||
|
['start_minute' => 540, 'end_minute' => 780], // ۰۹:۰۰–۱۳:۰۰
|
||||||
|
['start_minute' => 720, 'end_minute' => 1020], // ۱۲:۰۰–۱۷:۰۰
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||||
|
self::assertSame('start_minute', $body['errors'][0]['field']);
|
||||||
|
self::assertStringContainsString('شنبه', $body['errors'][0]['message'], 'پیام باید نام روز را بگوید نه اندیس');
|
||||||
|
self::assertStringContainsString('تداخل', $body['errors'][0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ترتیب ورودی نباید مهم باشد: تداخل بین شیفت اول و سوم هم باید دیده شود. */
|
||||||
|
public function testOverlapIsDetectedRegardlessOfInputOrder(): void
|
||||||
|
{
|
||||||
|
$body = $this->putCalendar([
|
||||||
|
2 => [
|
||||||
|
['start_minute' => 900, 'end_minute' => 1020], // ۱۵:۰۰–۱۷:۰۰
|
||||||
|
['start_minute' => 540, 'end_minute' => 600], // ۰۹:۰۰–۱۰:۰۰
|
||||||
|
['start_minute' => 960, 'end_minute' => 1080], // ۱۶:۰۰–۱۸:۰۰ ← با اولی تداخل دارد
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
self::assertStringContainsString('دوشنبه', $body['errors'][0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرزِ چسبیده همپوشانی نیست: ۱۳:۰۰ پایانِ یکی و شروعِ بعدی، شیفت صبح و عصر است. */
|
||||||
|
public function testBackToBackShiftsAreAccepted(): void
|
||||||
|
{
|
||||||
|
$body = $this->putCalendar([
|
||||||
|
0 => [
|
||||||
|
['start_minute' => 540, 'end_minute' => 780],
|
||||||
|
['start_minute' => 780, 'end_minute' => 1020],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||||
|
self::assertCount(2, $body['data']['days']['0']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اعتبارسنجی کامل پیش از هر حذفی — روز نامعتبر نباید روزهای درست را پاک کند. */
|
||||||
|
public function testAnInvalidDayLeavesTheExistingCalendarUntouched(): void
|
||||||
|
{
|
||||||
|
[$user, , $address] = $this->clinicWithAddress();
|
||||||
|
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||||
|
$uuid = $resource['data']['uuid'];
|
||||||
|
|
||||||
|
$this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [
|
||||||
|
'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]],
|
||||||
|
]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [
|
||||||
|
'days' => [
|
||||||
|
0 => [['start_minute' => 540, 'end_minute' => 1020]],
|
||||||
|
1 => [
|
||||||
|
['start_minute' => 540, 'end_minute' => 780],
|
||||||
|
['start_minute' => 600, 'end_minute' => 900],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', "/api/v1/resource/$uuid/calendar", $user);
|
||||||
|
self::assertCount(1, $body['data']['days']['0'], 'شنبهٔ قبلی سرِ جایش مانده');
|
||||||
|
self::assertSame([], $body['data']['days']['1']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user