From c42679d98c335c69d6fd656a5f29b249a4e86d87 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 09:24:50 +0330 Subject: [PATCH] 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. --- .../resources/ResourceExceptionsPanel.tsx | 220 +++++++++++----- .../resources/ResourceWorkingHoursPanel.tsx | 243 ++++++++++++------ .../pages/ClinicAppointmentSettingsPage.tsx | 165 ++++++------ .../admin/pages/ResourceDetailPage.test.tsx | 64 ++++- assets/admin/styles.css | 37 +++ 5 files changed, 511 insertions(+), 218 deletions(-) diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx index e647df29..888e3262 100644 --- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -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 = { @@ -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 ( -
- - - create.mutate(payload)} - onDelete={setToDelete} - /> - -
-

پیش‌نمایش دو هفته

-

- ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند. -

- -
- {(availability?.days ?? []).map((day) => ( -
- - {DAY_LABELS[day.day_of_week]} · {formatDate(day.date)} - - {day.intervals.length === 0 ? ( - - {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} - - ) : ( - {day.total_minutes} دقیقه - )} -
- ))} -
+
+
+ + create.mutate(payload)} + onDelete={setToDelete} + />
+ + 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 ( +
+

پیش‌نمایش دو هفته

+

+ ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند. +

+ + {days.length === 0 ? ( +
+ ) : nothingConfigured ? ( +

+ در دو هفتهٔ آینده هیچ ساعتی باز نیست. اول در «شیفت هفتگی» روزهای کاری را تعریف کنید. +

+ ) : ( +
+ {days.map((day) => ( +
+ + {DAY_LABELS[day.day_of_week]} · {formatDate(day.date)} + + {day.intervals.length === 0 ? ( + + {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} + + ) : ( + {formatNumber(day.total_minutes)} دقیقه + )} +
+ ))} +
+ )} +
+ ); +} + 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 ( -
-

مرخصی و سرویس

+
+

+ مرخصی و سرویس +

{exceptions.length === 0 ? ( -

استثنایی ثبت نشده است.

+

+ استثنایی ثبت نشده است. +

) : ( -
+
{exceptions.map((e) => ( -
+
{e.type_label} - + {formatDate(e.starts_at)} تا {formatDate(e.ends_at)} {e.reason ? ` · ${e.reason}` : ''} {canUpdate && ( - )}
@@ -152,29 +212,59 @@ function ExceptionsCard({
)} - {canUpdate && ( -
- setType(v ? String(v) : 'leave')} - placeholder="نوع استثنا" - height={36} - /> + {canUpdate ? ( +
+
+ + setType(v ? String(v) : 'leave')} + placeholder="نوع استثنا" + height={40} + /> +
+ {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */} -
-
- +
+
+ +
-
- +
+ +
- setReason(e.target.value)} placeholder="توضیح (اختیاری)" /> + +
+ + +
+
+ ) : ( +

+ برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبت‌دهی لازم است. +

)}
); diff --git a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx index 529e1b6d..9fc4dfe8 100644 --- a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx +++ b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx @@ -1,6 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { + ExclamationTriangleIcon, PlusIcon, Square2StackIcon, TrashIcon, +} from '@heroicons/react/24/outline'; import { useResourceCalendar } from '../../hooks/useResourceCalendar'; +import { formatNumber } from '../../lib/utils'; /** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; @@ -8,6 +11,7 @@ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌ const MINUTES_IN_DAY = 1440; type Draft = { start: string; end: string; endOfDay: boolean }; +type Days = Record; function toTime(minute: number): string { return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; @@ -25,6 +29,10 @@ function toMinutes(time: string): number | null { * * با حذف دامنهٔ شعبه، این شیفت تنها مرجع ساعت کاری منبع است؛ فقط تعطیلات رسمی و * استثناهای خودِ منبع از آن کسر می‌شوند. + * + * چیدمان عمداً تک‌ستونه است: هفته یک توالی است و گرید دوستونه ترتیبش را زیگزاگ + * می‌کرد. ذخیره در `save-bar` چسبان می‌نشیند تا هم پایینِ فیلدها باشد و هم + * وجودش خودش نشانهٔ «ذخیره‌نشده» باشد. */ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { resourceUuid?: string; @@ -32,12 +40,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { }) { const { calendar, loading, save } = useResourceCalendar(resourceUuid); - const [draft, setDraft] = useState>({}); - const [error, setError] = useState(null); + const [draft, setDraft] = useState({}); + // نسخهٔ سرور به‌صورت رشته نگه داشته می‌شود تا مقایسهٔ dirty یک `===` ساده باشد. + const [baseline, setBaseline] = useState(null); + const [error, setError] = useState<{ day: number; message: string } | null>(null); useEffect(() => { if (!calendar) return; - const next: Record = {}; + const next: Days = {}; DAY_LABELS.forEach((_, day) => { next[day] = (calendar.days[String(day)] ?? []).map((r) => ({ start: toTime(r.start_minute), @@ -46,6 +56,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { })); }); setDraft(next); + setBaseline(JSON.stringify(next)); + setError(null); }, [calendar]); const totalShifts = useMemo( @@ -53,13 +65,39 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { [draft], ); + // پیش از اولین پاسخ سرور baseline نداریم، پس هیچ‌چیز dirty نیست. + const dirty = baseline !== null && JSON.stringify(draft) !== baseline; + const editRange = (day: number, index: number, patch: Partial) => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); + const addRange = (day: number) => + setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] })); + + 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 = () => { const days: Record = {}; 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) { @@ -67,11 +105,11 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); if (start === null || end === null) { - setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); + setError({ day, message: `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید` }); return; } if (end <= start) { - setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`); + setError({ day, message: `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد` }); return; } parsed.push({ start_minute: start, end_minute: end }); @@ -85,103 +123,144 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { }; return ( -
-
-

- روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر می‌شوند. - {totalShifts > 0 && <> · {totalShifts} شیفت} -

- {canUpdate && ( - - )} +
+
+

شیفت هفتگی

+ {totalShifts > 0 && {formatNumber(totalShifts)} شیفت}
+

+ روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر می‌شوند. +

+ {error && (
- {error} + + {error.message}
)} {loading ? ( -
در حال بارگذاری...
+
+ {DAY_LABELS.map((_, i) => ( +
+ ))} +
) : ( -
+
{DAY_LABELS.map((label, day) => { const rows = draft[day] ?? []; + const cls = ['wh-day', rows.length ? 'has-shift' : '', error?.day === day ? 'err' : ''] + .filter(Boolean).join(' '); + return ( -
-
-
- {label} - {rows.length === 0 && بدون شیفت} +
+
{label}
+ + {rows.length === 0 ? ( +
بدون شیفت — این روز بسته است
+ ) : ( +
+ {rows.map((row, index) => ( +
+ + + + + + + {canUpdate && ( + + )} +
+ ))}
- {canUpdate && ( - + )} + - )} -
- -
- {rows.map((row, index) => ( -
- editRange(day, index, { start: e.target.value })} - style={{ width: 116 }} - /> - تا - {row.endOfDay ? ( - ۲۴:۰۰ - ) : ( - editRange(day, index, { end: e.target.value })} - style={{ width: 116 }} - /> - )} - - {canUpdate && ( - - )} -
- ))} -
+
+ )}
); })}
)} + + {canUpdate && dirty && ( +
+
+ + تغییرات شیفت ذخیره نشده است +
+
+ + +
+
+ )}
); } diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx index 5c970a4f..805c666d 100644 --- a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx +++ b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx @@ -13,7 +13,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection'; import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel'; import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel'; import FreeVisitPrice from '../components/FreeVisitPrice'; -import BackButton from '../components/ui/BackButton'; +import PageHeader from '../components/ui/PageHeader'; import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager'; const SCOPES = [ @@ -72,68 +72,92 @@ function ClinicAppointmentSettingsContent() { ); } + // هویت موردِ انتخاب‌شده فقط یک بار گفته می‌شود — در توضیح هدر. پیش‌تر همین جمله + // در تب فعال، در زیرعنوان و در یک کارتِ جداگانه سه بار تکرار می‌شد. + const description = scope === 'doctors' + ? (selectedDoctor ? `تنظیمات نوبت‌دهی ${selectedDoctor.name}` : 'تنظیمات نوبت‌دهی پزشکان کلینیک') + : (selectedResource + ? `تنظیمات نوبت‌دهی ${selectedResource.name} · ${selectedResource.type_name}` + : 'تنظیمات نوبت‌دهی منابع کلینیک'); + return ( -
-
- -
-
+
+ + +
+ {/* دو انتخابگر پشت سر هم می‌مانند — «نما» بعد «مورد». در اسلات action هدر، + سوییچر به لبهٔ مقابلِ صفحه می‌افتاد و از تبی که کنترل می‌کند جدا می‌شد. */}
-

مدیریت نوبت دهی

-
- {scope === 'doctors' - ? (selectedDoctor ? `تنظیمات نوبت‌دهی ${selectedDoctor.name}` : 'تنظیمات نوبت‌دهی پزشکان کلینیک') - : (selectedResource ? `تنظیمات نوبت‌دهی ${selectedResource.name}` : 'تنظیمات نوبت‌دهی منابع کلینیک')} + نما +
+ {SCOPES.map((s) => ( + + ))}
-
-
- {SCOPES.map((s) => ( - - ))} + {scope === 'doctors' ? ( + setUrlState({ doctor: uuid })} + /> + ) : ( + setUrlState({ resource: uuid })} + /> + )}
- - {scope === 'doctors' ? ( - setUrlState({ doctor: uuid })} - /> - ) : ( - setUrlState({ resource: uuid })} - /> - )}
); } -function TabBar({ items, selected, onSelect }: { +/** + * نوار انتخابِ پزشک/منبع. + * + * بدون کارتِ دورش: تنها محتوایش یک گروه pill بود و کارتِ تمام‌عرض، ~۴۵۰px فضای + * خالی می‌ساخت. لیبل قابل‌مشاهده هم لازم است، وگرنه نوار برای screen reader یک + * ردیف دکمهٔ بی‌عنوان است. + */ +function TabBar({ items, selected, onSelect, label, labelId }: { items: T[]; selected: T | null; onSelect: (uuid: string) => void; + label: string; + labelId: string; }) { return ( -
-
+
+ {label} +
{items.map((item) => (