feat: Enhance appointment management by decoupling online booking toggle for admin context

- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
This commit is contained in:
hamed
2026-07-22 16:43:56 +03:30
parent 5507b42fd8
commit ed516c81a8
16 changed files with 658 additions and 83 deletions
@@ -99,7 +99,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
const svcSlotsQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
@@ -61,7 +61,7 @@ export default function ServiceSlotPicker({
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
+13 -18
View File
@@ -50,6 +50,16 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
{ to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
];
/**
* همان زیرمنوها + «رزرو نوبت». برای نقش‌هایی که مالک برنامهٔ نوبت‌دهی‌اند
* (ادمین/کلینیک/پزشک) تا همهٔ عملیات نوبت زیر یک بخش جمع شود. پزشکِ مهمانِ کلینیک
* از نسخهٔ بدون رزرو (`APPOINTMENTS_CHILDREN`) استفاده می‌کند.
*/
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
...APPOINTMENTS_CHILDREN,
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
];
function buildSections(
primaryRole: string | null,
dbUuid: string | null,
@@ -117,12 +127,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/payments",
@@ -232,12 +237,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/patients",
@@ -377,12 +377,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/patients",
@@ -500,6 +500,28 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
const withClinic = (url: string, clinicUuid?: string | null): string =>
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
/**
* وقتی محیط انتخاب‌شده مکانِ نوبت فعالی ندارد. متن به محیط بستگی دارد: پزشک عضو
* کلینیک آدرس مستقل ثبت نمی‌کند، پس در محیط کلینیک نباید «ابتدا آدرس مطب را ثبت
* کنید» ببیند — آدرس از تنظیمات همان کلینیک می‌آید.
*/
function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
const [title, hint] = clinicUuid
? ['این کلینیک هنوز مکان نوبت‌دهی فعال ندارد', 'ابتدا در تنظیمات کلینیک یک آدرس فعال ثبت شود']
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">{hint}</p>
</div>
</div>
);
}
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
@@ -595,17 +617,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
</div>
</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
// نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم.
if (readOnly) {
@@ -1047,17 +1059,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
</div>
</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
return (
<div className="space-y-3">