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:
hamed
2026-08-03 09:24:50 +03:30
parent 4fe0c4f9bf
commit c42679d98c
5 changed files with 511 additions and 218 deletions
@@ -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 (
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
<div>
<BackButton fallback="/admin/settings-menu" />
</div>
<div className="card-title-row">
<div className="fade-in">
<PageHeader
title="مدیریت نوبت‌دهی"
description={description}
backTo="/admin/settings-menu"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* دو انتخابگر پشت سر هم می‌مانند — «نما» بعد «مورد». در اسلات action هدر،
سوییچر به لبهٔ مقابلِ صفحه می‌افتاد و از تبی که کنترل می‌کند جدا می‌شد. */}
<div>
<h1 className="section-title">مدیریت نوبت دهی</h1>
<div className="muted">
{scope === 'doctors'
? (selectedDoctor ? `تنظیمات نوبت‌دهی ${selectedDoctor.name}` : 'تنظیمات نوبت‌دهی پزشکان کلینیک')
: (selectedResource ? `تنظیمات نوبت‌دهی ${selectedResource.name}` : 'تنظیمات نوبت‌دهی منابع کلینیک')}
<span className="field-label" id="appt-scope-tabs">نما</span>
<div className="seg" role="group" aria-labelledby="appt-scope-tabs">
{SCOPES.map((s) => (
<button
key={s.id}
className={scope === s.id ? 'active' : ''}
aria-pressed={scope === s.id}
onClick={() => setUrlState({ scope: s.id })}
>
{s.label}
</button>
))}
</div>
</div>
</div>
<div className="seg" style={{ alignSelf: 'flex-start' }}>
{SCOPES.map((s) => (
<button
key={s.id}
className={scope === s.id ? 'active' : ''}
onClick={() => setUrlState({ scope: s.id })}
>
{s.label}
</button>
))}
{scope === 'doctors' ? (
<DoctorsScope
loading={doctorsQ.isLoading}
doctors={doctorList}
selected={selectedDoctor}
clinicUuid={clinicUuid}
readOnly={apptReadOnly}
onSelect={(uuid) => setUrlState({ doctor: uuid })}
/>
) : (
<ResourcesScope
loading={resourcesLoading}
resources={resources}
selected={selectedResource}
canUpdate={!apptReadOnly}
onSelect={(uuid) => setUrlState({ resource: uuid })}
/>
)}
</div>
{scope === 'doctors' ? (
<DoctorsScope
loading={doctorsQ.isLoading}
doctors={doctorList}
selected={selectedDoctor}
clinicUuid={clinicUuid}
readOnly={apptReadOnly}
onSelect={(uuid) => setUrlState({ doctor: uuid })}
/>
) : (
<ResourcesScope
loading={resourcesLoading}
resources={resources}
selected={selectedResource}
canUpdate={!apptReadOnly}
onSelect={(uuid) => setUrlState({ resource: uuid })}
/>
)}
</div>
);
}
function TabBar<T extends { uuid: string; name: string }>({ items, selected, onSelect }: {
/**
* نوار انتخابِ پزشک/منبع.
*
* بدون کارتِ دورش: تنها محتوایش یک گروه pill بود و کارتِ تمام‌عرض، ~۴۵۰px فضای
* خالی می‌ساخت. لیبل قابل‌مشاهده هم لازم است، وگرنه نوار برای screen reader یک
* ردیف دکمهٔ بی‌عنوان است.
*/
function TabBar<T extends { uuid: string; name: string }>({ items, selected, onSelect, label, labelId }: {
items: T[];
selected: T | null;
onSelect: (uuid: string) => void;
label: string;
labelId: string;
}) {
return (
<div className="card card-pad" style={{ paddingBottom: 12 }}>
<div className="seg" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
<div>
<span className="field-label" id={labelId}>{label}</span>
<div
className="seg"
role="group"
aria-labelledby={labelId}
style={{ maxWidth: '100%', overflowX: 'auto', flexWrap: 'nowrap' }}
>
{items.map((item) => (
<button
key={item.uuid}
className={selected?.uuid === item.uuid ? 'active' : ''}
aria-pressed={selected?.uuid === item.uuid}
style={{ whiteSpace: 'nowrap' }}
onClick={() => onSelect(item.uuid)}
>
@@ -145,14 +169,23 @@ function TabBar<T extends { uuid: string; name: string }>({ items, selected, onS
);
}
function SelectedHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
/** حالت خالی. کلاس `empty` در `styles.css` تعریف نشده بود، پس چیدمان اینجاست. */
function EmptyState({ icon, message, action }: {
icon: React.ReactNode;
message: string;
action: React.ReactNode;
}) {
return (
<div
className="card card-pad"
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 'var(--gap)' }}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 12, padding: '36px 22px', color: 'var(--text-3)',
}}
>
{icon}
<span style={{ fontWeight: 600 }}>{label}</span>
<p className="muted" style={{ fontSize: 13.5 }}>{message}</p>
{action}
</div>
);
}
@@ -169,27 +202,21 @@ function DoctorsScope({ loading, doctors, selected, clinicUuid, readOnly, onSele
if (doctors.length === 0) {
return (
<div className="card card-pad">
<div className="empty" style={{ padding: '20px 0' }}>
<UserGroupIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
<Link className="btn primary sm" to="/admin/settings/clinic-doctors">مدیریت پزشکان کلینیک</Link>
</div>
</div>
<EmptyState
icon={<UserGroupIcon style={{ width: 30, height: 30 }} />}
message="هیچ پزشکی به این کلینیک متصل نیست"
action={<Link className="btn primary sm" to="/admin/settings/clinic-doctors">مدیریت پزشکان کلینیک</Link>}
/>
);
}
return (
<>
<TabBar items={doctors} selected={selected} onSelect={onSelect} />
<TabBar items={doctors} selected={selected} onSelect={onSelect} label="پزشک" labelId="appt-doctor-tabs" />
{/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت می‌کند */}
{selected && (
<div key={selected.uuid}>
<SelectedHeader
icon={<UserGroupIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />}
label={selected.name}
/>
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
<FreeVisitPrice doctorUuid={selected.uuid} readOnly={readOnly} />
<ScheduleSection doctorUuid={selected.uuid} clinicUuid={clinicUuid} readOnly={readOnly} />
</div>
@@ -209,27 +236,21 @@ function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: {
if (resources.length === 0) {
return (
<div className="card card-pad">
<div className="empty" style={{ padding: '20px 0' }}>
<CubeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هنوز منبعی تعریف نشده است</p>
<Link className="btn primary sm" to="/admin/resources">تنظیمات منابع</Link>
</div>
</div>
<EmptyState
icon={<CubeIcon style={{ width: 30, height: 30 }} />}
message="هنوز منبعی تعریف نشده است"
action={<Link className="btn primary sm" to="/admin/resources">تنظیمات منابع</Link>}
/>
);
}
return (
<>
<TabBar items={resources} selected={selected} onSelect={onSelect} />
<TabBar items={resources} selected={selected} onSelect={onSelect} label="منبع" labelId="appt-resource-tabs" />
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمه‌ویرایش‌شده به منبع بعدی می‌چسبد. */}
{selected && (
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
<SelectedHeader
icon={<CubeIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />}
label={`${selected.name} · ${selected.type_name}`}
/>
<ResourceWorkingHoursPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
<ResourceExceptionsPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
</div>
+63 -1
View File
@@ -94,7 +94,7 @@ describe('ResourceDetailPage', () => {
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
expect(screen.getByText('جمعه')).toBeInTheDocument();
expect(screen.getAllByText('بدون شیفت')).toHaveLength(7);
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
});
it('شیفت ذخیره‌شده را به‌صورت ساعت نشان می‌دهد', async () => {
@@ -125,6 +125,68 @@ describe('ResourceDetailPage', () => {
expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
});
/**
* وقتی هیچ روزی شیفت ندارد، ۱۴ سطرِ یکسان نویز است و با یک حالت خالی جمع می‌شود.
* شرط باید تنگ بماند: تست بالا ثابت می‌کند دلیلِ غیر از `no_shift` سطرها را نگه می‌دارد.
*/
it('پیش‌نمایشِ کاملاً تعریف‌نشده را در یک حالت خالی جمع می‌کند', async () => {
mockApi(emptyDays(), [
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
]);
renderPage('/admin/resources/r1?tab=exceptions');
await waitFor(() =>
expect(screen.getByText(/در دو هفتهٔ آینده هیچ ساعتی باز نیست/)).toBeInTheDocument());
expect(screen.queryByText('شیفتی تعریف نشده')).not.toBeInTheDocument();
});
/** نوار ذخیره فقط وقتی ظاهر می‌شود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */
it('نوار ذخیره تا وقتی تغییری نباشد نمی‌آید', async () => {
const user = userEvent.setup();
mockApi();
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).not.toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
expect(await screen.findByText('تغییرات شیفت ذخیره نشده است')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeInTheDocument();
});
/** «بازگرداندن» باید به نسخهٔ سرور برگردد، نه صرفاً آخرین ویرایش را لغو کند. */
it('بازگرداندن، draft را به نسخهٔ سرور برمی‌گرداند', async () => {
const user = userEvent.setup();
mockApi();
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
await user.click(await screen.findByRole('button', { name: 'بازگرداندن' }));
await waitFor(() =>
expect(screen.queryByText('تغییرات شیفت ذخیره نشده است')).not.toBeInTheDocument());
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
});
/** پرتکرارترین حالت یک ساعتِ یکسان برای کل هفته است؛ بدون کپی، ۱۵ تعامل لازم بود. */
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 }];
const user = userEvent.setup();
mockApi(days);
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'کپی شیفت‌های شنبه به همهٔ روزهای هفته' }));
await waitFor(() => expect(screen.getAllByDisplayValue('09:00')).toHaveLength(7));
expect(screen.getAllByDisplayValue('17:00')).toHaveLength(7);
expect(screen.queryByText('بدون شیفت — این روز بسته است')).not.toBeInTheDocument();
});
/** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */
it('پیش‌نمایش را خام معرفی می‌کند', async () => {
mockApi();