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>
|
||||
|
||||
Reference in New Issue
Block a user