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.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
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 } from '../../lib/utils';
|
||||
import { formatDate, formatNumber } from '../../lib/utils';
|
||||
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
||||
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
||||
import type { ResourceException } from '../../types';
|
||||
import type { ResourceAvailability, ResourceException } from '../../types';
|
||||
|
||||
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
@@ -37,6 +39,9 @@ function todayMidnight(): number {
|
||||
*
|
||||
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
||||
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
||||
*
|
||||
* دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودیاند و کنار هم میمانند،
|
||||
* پیشنمایش خروجی است و ستون خودش را میگیرد.
|
||||
*/
|
||||
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||
resourceUuid?: string;
|
||||
@@ -50,49 +55,26 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
|
||||
<NationalHolidaysCard canUpdate={canUpdate} />
|
||||
|
||||
<ExceptionsCard
|
||||
exceptions={exceptions}
|
||||
canUpdate={canUpdate}
|
||||
saving={create.isPending}
|
||||
onCreate={(payload) => create.mutate(payload)}
|
||||
onDelete={setToDelete}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<h2 className="section-title" style={{ margin: '0 0 4px' }}>پیشنمایش دو هفته</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
|
||||
ساعت <strong>خام</strong> — نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{(availability?.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 style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600 }}>{day.total_minutes} دقیقه</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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)}
|
||||
@@ -101,6 +83,61 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پیشنمایش دو هفته.
|
||||
*
|
||||
* ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راهحلدار
|
||||
* جمع میشود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی،
|
||||
* مرخصی) بسته باشد سطرها میمانند، چون آن دلیل خودش اطلاعات است — نه نویز.
|
||||
*/
|
||||
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,
|
||||
}: {
|
||||
@@ -115,6 +152,9 @@ function ExceptionsCard({
|
||||
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();
|
||||
@@ -127,24 +167,44 @@ function ExceptionsCard({
|
||||
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" style={{ padding: 14 }}>
|
||||
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
|
||||
<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 10px' }}>استثنایی ثبت نشده است.</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 14px' }}>
|
||||
استثنایی ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
||||
{exceptions.map((e) => (
|
||||
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<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)' }}>
|
||||
<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="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
|
||||
✕
|
||||
<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>
|
||||
@@ -152,29 +212,59 @@ function ExceptionsCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<SearchableSelect
|
||||
options={EXCEPTION_TYPES}
|
||||
value={type}
|
||||
onChange={(v) => setType(v ? String(v) : 'leave')}
|
||||
placeholder="نوع استثنا"
|
||||
height={36}
|
||||
/>
|
||||
{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: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
|
||||
<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 style={{ flex: 1 }}>
|
||||
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
|
||||
<div className="field-block">
|
||||
<label>تا تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="تا تاریخ"
|
||||
ariaLabel={`تاریخ پایان ${typeLabel}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
|
||||
|
||||
<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 secondary"
|
||||
className="btn primary"
|
||||
disabled={saving || invalid}
|
||||
onClick={() => {
|
||||
onCreate({
|
||||
@@ -191,6 +281,10 @@ function ExceptionsCard({
|
||||
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
|
||||
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user