Files
clinicpro/assets/admin/components/resources/ResourceExceptionsPanel.tsx
T
hamed c42679d98c feat: enhance ResourceWorkingHoursPanel with improved error handling and UI updates
- Added new icons and improved error messaging for better user feedback.
- Refactored state management to include baseline comparison for dirty state detection.
- Introduced functionality to copy shifts across all days and reset to baseline.
- Updated UI layout for better responsiveness and usability.
- Enhanced tests to cover new features and ensure proper functionality.

refactor: update ClinicAppointmentSettingsPage to use PageHeader component

- Replaced BackButton with PageHeader for a more consistent header layout.
- Simplified the structure of the appointment settings page for better readability.

test: improve ResourceDetailPage tests for shift management

- Updated tests to reflect changes in shift display and error handling.
- Added tests for new features including the reset functionality and copying shifts.

style: add styles for weekly shift layout in ResourceWorkingHoursPanel

- Introduced new CSS classes for better layout and responsiveness of the weekly shift display.
- Ensured styles are consistent with the overall design system.
2026-08-03 09:24:50 +03:30

292 lines
12 KiB
TypeScript

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 { formatDate, formatNumber } from '../../lib/utils';
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import type { ResourceAvailability, ResourceException } from '../../types';
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
const REASON_LABELS: Record<string, string> = {
national_holiday: 'تعطیل رسمی',
tenant_holiday: 'تعطیلی این محیط',
no_shift: 'شیفتی تعریف نشده',
exception: 'مرخصی یا سرویس',
resource_inactive: 'منبع غیرفعال است',
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();
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
/**
* تعطیلات و استثناهای یک منبع، کنار پیش‌نمایش دو هفتهٔ ساعت آزاد.
*
* پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن
* کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد.
*
* دو ستون، نه `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);
return (
<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}
/>
</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>
);
}
/**
* پیش‌نمایش دو هفته.
*
* ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راه‌حل‌دار
* جمع می‌شود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی،
* مرخصی) بسته باشد سطرها می‌مانند، چون آن دلیل خودش اطلاعات است — نه نویز.
*/
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>
);
}
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>
);
}