- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { CubeIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { useResources } from '../hooks/useResources';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
|
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
|
import ResourceExceptionsCard from '../components/resources/ResourceExceptionsCard';
|
|
import NationalHolidaysCard from '../components/holidays/NationalHolidaysCard';
|
|
import FreeVisitPrice from '../components/FreeVisitPrice';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
|
|
|
|
const SCOPES = [
|
|
{ id: 'doctors', label: 'پزشکان' },
|
|
{ id: 'resources', label: 'منابع' },
|
|
] as const;
|
|
type Scope = typeof SCOPES[number]['id'];
|
|
|
|
/**
|
|
* تنظیمات نوبتدهی کلینیک — یک تب به ازای هر پزشک، و یک تب به ازای هر منبع.
|
|
*
|
|
* منبع در مدل Resource-First واحدِ ظرفیت است و ساعت کاری و تعطیلات خودش را دارد، پس
|
|
* دقیقاً همانجایی مدیریت میشود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. تب پزشک همان
|
|
* `ScheduleSection` پنل پزشک مستقل است و تب منبع همان پنلهای صفحهٔ منبع؛ هیچکدام
|
|
* نسخهٔ دومی ندارند.
|
|
*/
|
|
function ClinicAppointmentSettingsContent() {
|
|
const { dbUuid, context, availableContexts } = useAuthStore();
|
|
const { can } = usePermissions();
|
|
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبتدهی، فقط مشاهده میکند.
|
|
const apptReadOnly = !can('appointment_settings', 'update');
|
|
|
|
// انتخابها در URL مینشینند تا «بازگشت» و رفرش همان تب را برگردانند.
|
|
const [urlState, setUrlState] = useUrlState({ scope: 'doctors', doctor: '', resource: '' });
|
|
const scope = (SCOPES.some((s) => s.id === urlState.scope) ? urlState.scope : 'doctors') as Scope;
|
|
|
|
// کاربری که هم پزشک است هم مالک کلینیک، dbUuidاش ممکن است uuid پزشک باشد.
|
|
const clinicUuid = useMemo(() => {
|
|
if (context?.type === 'clinic') return dbUuid;
|
|
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
|
|
}, [context, dbUuid, availableContexts]);
|
|
|
|
const doctorsQ = useQuery({
|
|
queryKey: ['clinic-doctors', clinicUuid],
|
|
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
|
enabled: !!clinicUuid,
|
|
});
|
|
|
|
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
|
const raw = doctorsQ.data?.data;
|
|
return (raw as any)?.data ?? raw ?? [];
|
|
}, [doctorsQ.data]);
|
|
|
|
const { resources, loading: resourcesLoading } = useResources({ active: '1' });
|
|
|
|
const selectedDoctor = doctorList.find(d => d.uuid === urlState.doctor) ?? doctorList[0] ?? null;
|
|
const selectedResource = resources.find(r => r.uuid === urlState.resource) ?? resources[0] ?? null;
|
|
|
|
if (!clinicUuid) {
|
|
return (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>
|
|
{dbUuid ? 'کلینیکی برای این حساب کاربری یافت نشد' : 'در حال بارگذاری اطلاعات کلینیک...'}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// هویت موردِ انتخابشده فقط یک بار گفته میشود — در توضیح هدر. پیشتر همین جمله
|
|
// در تب فعال، در زیرعنوان و در یک کارتِ جداگانه سه بار تکرار میشد.
|
|
const description = scope === 'doctors'
|
|
? (selectedDoctor ? `تنظیمات نوبتدهی ${selectedDoctor.name}` : 'تنظیمات نوبتدهی پزشکان کلینیک')
|
|
: (selectedResource
|
|
? `تنظیمات نوبتدهی ${selectedResource.name} · ${selectedResource.type_name}`
|
|
: 'تنظیمات نوبتدهی منابع کلینیک');
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
{/* سوییچر پزشک/منبع در خودِ ردیف عنوان مینشیند: بالاترین نقطهٔ محتوا و همیشه
|
|
در دید، بدون اسکرول. پایینتر از هدر، کاربر باید دنبالش میگشت. */}
|
|
<PageHeader
|
|
title="مدیریت نوبتدهی"
|
|
description={description}
|
|
backTo="/admin/settings-menu"
|
|
action={(
|
|
<div className="seg" role="group" aria-label="نمای تنظیمات">
|
|
{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 style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
|
{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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* نوار انتخابِ پزشک/منبع.
|
|
*
|
|
* بدون کارتِ دورش: تنها محتوایش یک گروه 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>
|
|
<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)}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** حالت خالی. کلاس `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', flexDirection: 'column', alignItems: 'center',
|
|
gap: 12, padding: '36px 22px', color: 'var(--text-3)',
|
|
}}
|
|
>
|
|
{icon}
|
|
<p className="muted" style={{ fontSize: 13.5 }}>{message}</p>
|
|
{action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DoctorsScope({ loading, doctors, selected, clinicUuid, readOnly, onSelect }: {
|
|
loading: boolean;
|
|
doctors: ClinicDoctorItem[];
|
|
selected: ClinicDoctorItem | null;
|
|
clinicUuid: string;
|
|
readOnly: boolean;
|
|
onSelect: (uuid: string) => void;
|
|
}) {
|
|
if (loading) return <div className="card card-pad"><p className="muted">در حال بارگذاری پزشکان...</p></div>;
|
|
|
|
if (doctors.length === 0) {
|
|
return (
|
|
<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} label="پزشک" labelId="appt-doctor-tabs" />
|
|
|
|
{/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت میکند */}
|
|
{selected && (
|
|
<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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: {
|
|
loading: boolean;
|
|
resources: { uuid: string; name: string; type_name: string }[];
|
|
selected: { uuid: string; name: string; type_name: string } | null;
|
|
canUpdate: boolean;
|
|
onSelect: (uuid: string) => void;
|
|
}) {
|
|
if (loading) return <div className="card card-pad"><p className="muted">در حال بارگذاری منابع...</p></div>;
|
|
|
|
if (resources.length === 0) {
|
|
return (
|
|
<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} label="منبع" labelId="appt-resource-tabs" />
|
|
|
|
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمهویرایششده به منبع بعدی میچسبد. */}
|
|
{selected && (
|
|
<ResourceCalendarTabs key={selected.uuid} resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const CALENDAR_TABS = [
|
|
{ id: 'shifts', label: 'شیفت هفتگی' },
|
|
{ id: 'holidays', label: 'تعطیلات رسمی' },
|
|
{ id: 'exceptions', label: 'مرخصی و سرویس' },
|
|
] as const;
|
|
type CalendarTab = typeof CALENDAR_TABS[number]['id'];
|
|
|
|
/**
|
|
* برنامهٔ کاری منبع در سه تب: شیفت هفتگی، تعطیلات رسمی، مرخصی و سرویس.
|
|
*
|
|
* هر سه یک چیز را میسازند («این منبع کِی باز است؟») پس یکجا میمانند؛ ولی همزمان
|
|
* دیدهشدنشان لازم نیست و پشتسرهم چیدنشان صفحه را سه برابر بلند میکرد. تب فعال در
|
|
* URL مینشیند تا «بازگشت» و رفرش همان نما را برگردانند.
|
|
*/
|
|
function ResourceCalendarTabs({ resourceUuid, canUpdate }: { resourceUuid: string; canUpdate: boolean }) {
|
|
const [urlState, setUrlState] = useUrlState({ calendarTab: 'shifts' });
|
|
const tab = (CALENDAR_TABS.some((t) => t.id === urlState.calendarTab)
|
|
? urlState.calendarTab
|
|
: 'shifts') as CalendarTab;
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
<div className="seg" style={{ alignSelf: 'flex-start' }} role="group" aria-label="برنامهٔ کاری منبع">
|
|
{CALENDAR_TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
className={tab === t.id ? 'active' : ''}
|
|
aria-pressed={tab === t.id}
|
|
onClick={() => setUrlState({ calendarTab: t.id })}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'shifts' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
|
{tab === 'holidays' && <NationalHolidaysCard canUpdate={canUpdate} />}
|
|
{tab === 'exceptions' && <ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ClinicAppointmentSettingsPage() {
|
|
return (
|
|
<SettingsLayout active="appointment">
|
|
<ClinicAppointmentSettingsContent />
|
|
</SettingsLayout>
|
|
);
|
|
}
|