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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user