- 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.
107 lines
4.7 KiB
TypeScript
107 lines
4.7 KiB
TypeScript
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 ResourceExceptionsCard from './ResourceExceptionsCard';
|
|
import type { ResourceAvailability } from '../../types';
|
|
|
|
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
|
const REASON_LABELS: Record<string, string> = {
|
|
national_holiday: 'تعطیل رسمی',
|
|
tenant_holiday: 'تعطیلی این محیط',
|
|
no_shift: 'شیفتی تعریف نشده',
|
|
exception: 'مرخصی یا سرویس',
|
|
resource_inactive: 'منبع غیرفعال است',
|
|
address_inactive: 'محل نوبتدهی غیرفعال است',
|
|
};
|
|
|
|
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
|
function todayMidnight(): number {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return Math.floor(d.getTime() / 1000);
|
|
}
|
|
|
|
/**
|
|
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، مرخصی و سرویس، و پیشنمایش دو هفته.
|
|
*
|
|
* این ترکیب مخصوص صفحهٔ جزئیات منبع است، جایی که کل صفحه دربارهٔ همان یک منبع است
|
|
* و پیشنمایش پاسخِ «بعد از این تنظیمات، چه ساعتی باز میماند؟» را میدهد. صفحهٔ
|
|
* تنظیمات نوبتدهی همان دو کارت را بدون پیشنمایش و بهصورت تب میچیند.
|
|
*/
|
|
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
|
resourceUuid?: string;
|
|
canUpdate: boolean;
|
|
}) {
|
|
const previewFrom = todayMidnight();
|
|
const previewTo = previewFrom + 13 * 86400;
|
|
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
|
|
|
return (
|
|
<div className="wh-two-col">
|
|
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
|
<NationalHolidaysCard canUpdate={canUpdate} />
|
|
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
|
|
</div>
|
|
|
|
<PreviewCard availability={availability} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* پیشنمایش دو هفته.
|
|
*
|
|
* ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راهحلدار
|
|
* جمع میشود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی،
|
|
* مرخصی) بسته باشد سطرها میمانند، چون آن دلیل خودش اطلاعات است — نه نویز.
|
|
*/
|
|
function PreviewCard({ availability }: { availability?: ResourceAvailability }) {
|
|
const days = availability?.days ?? [];
|
|
const nothingConfigured = days.length > 0
|
|
&& days.every((d) => d.intervals.length === 0
|
|
&& d.reasons.length > 0
|
|
&& d.reasons.every((r) => r === 'no_shift'));
|
|
|
|
return (
|
|
<div className="card card-pad" style={{ position: 'sticky', top: 'var(--gap)' }}>
|
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>پیشنمایش دو هفته</h2>
|
|
<p className="field-hint" style={{ marginTop: 4, marginBottom: 14 }}>
|
|
ساعت <strong>خام</strong> — نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
|
|
</p>
|
|
|
|
{days.length === 0 ? (
|
|
<div className="skeleton" style={{ height: 120, borderRadius: 'var(--r-sm)' }} />
|
|
) : nothingConfigured ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 1.9 }}>
|
|
در دو هفتهٔ آینده هیچ ساعتی باز نیست. اول در «شیفت هفتگی» روزهای کاری را تعریف کنید.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 7 }}>
|
|
{days.map((day) => (
|
|
<div
|
|
key={day.date}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
gap: 8, fontSize: 13,
|
|
}}
|
|
>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date)}
|
|
</span>
|
|
{day.intervals.length === 0 ? (
|
|
<span className="badge gray" style={{ fontSize: 11 }}>
|
|
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
|
|
</span>
|
|
) : (
|
|
<span style={{ fontWeight: 700 }}>{formatNumber(day.total_minutes)} دقیقه</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|