Backend already returned 403 for ungranted secretary actions, but the UI still showed the add/edit/delete buttons (e.g. clinic-services showed «بخش جدید» to a secretary without services.create). Sweep every secretary-reachable page so each create/edit/delete/manage control renders only when the matching usePermissions().can(resource, action) is true. Owner/doctor/clinic are unaffected — can() returns true when there is no permission context — so this restricts only secretaries and mirrors the server checks. Pages/components gated (resource): - services: ClinicServicesPage, ServiceDetailPage (+ its tabs) - inventory: InventoryPage, InventoryItemsTable, InventoryActionsMenu, PackagesView - tags: TagsSettingsPage · staff: StaffPage · discounts: DiscountTab - sms: SmsWalletPage · insurances: TenantInsuranceContracts - clinic_doctors: ClinicDoctorsPage + ClinicDoctorsManager (props, default true) - patients: PatientsListPage, MyPatientsPage, PatientDetailPage (records/notes/ sessions/attachments/calls/wallet — create/update/delete split) - appointments: AppointmentsPage (add + empty-slot booking gated by create), TurnsTable (status dropdown → read-only badge without update_status; actions menu hidden without manage/cancel) - appointment_settings: AppointmentSettingsPage + ClinicAppointmentSettingsPage pass readOnly to ScheduleSection + FreeVisitPrice (new readOnly prop) Not gated: view/read, search, filter, tabs, navigation, export, and modal submit buttons reachable only via an already-gated trigger. tsc clean; full frontend suite 501/501 passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
5.2 KiB
TypeScript
108 lines
5.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
import FreeVisitPrice from '../components/FreeVisitPrice';
|
|
import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
|
import type { AddressData } from '../components/schedule/ScheduleSection';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
|
|
const PERSONAL = 'personal';
|
|
|
|
/**
|
|
* مدیریت نوبت دهی — the doctor's appointment settings: visit price and the full
|
|
* weekly/overrides/holidays schedule.
|
|
*
|
|
* یک پزشک میتواند هم مطب شخصی داشته باشد و هم عضو یک/چند کلینیک باشد. هر محیط
|
|
* برنامهٔ نوبتدهی مستقل خودش را دارد (schedule per-context با clinic_id). پزشک عضو
|
|
* کلینیک آدرس مستقل ثبت نمیکند و از Location همان کلینیک استفاده میکند؛ پس اگر
|
|
* بیش از یک محیط داشته باشد، یک انتخابگر محیط نمایش داده میشود تا برنامهٔ همان
|
|
* محیط را مدیریت کند. پیشفرض روی محیطی میرود که مکان فعال دارد.
|
|
*/
|
|
export default function AppointmentSettingsPage() {
|
|
const doctorUuid = useAuthStore((s) => s.doctorUuid);
|
|
const dbUuid = useAuthStore((s) => s.dbUuid);
|
|
const uuid = doctorUuid ?? dbUuid ?? undefined;
|
|
const { can } = usePermissions();
|
|
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبتدهی، فقط مشاهده میکند.
|
|
const apptReadOnly = !can('appointment_settings', 'update');
|
|
|
|
// کلینیکهایی که پزشک عضوشان است (منبع: پروفایل خود پزشک).
|
|
const profileQ = useQuery({
|
|
queryKey: ['doctor-clinics', uuid],
|
|
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
|
|
enabled: !!uuid,
|
|
staleTime: 300_000,
|
|
});
|
|
// پاسخِ doctor دو لایه تو در تو است: success(['data' => [...]]) → data.data.
|
|
const clinics: { uuid: string; name: string }[] =
|
|
(profileQ.data?.data as any)?.data?.clinics ?? (profileQ.data?.data as any)?.clinics ?? [];
|
|
|
|
// آیا مطب شخصی مکان فعال دارد؟ برای انتخاب پیشفرضِ درست.
|
|
const personalLocationsQ = useQuery({
|
|
queryKey: ['available-locations', uuid, null],
|
|
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/available-locations/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
const personalLocations: AddressData[] =
|
|
(personalLocationsQ.data?.data as any)?.data ?? personalLocationsQ.data?.data ?? [];
|
|
|
|
const options = useMemo(() => [
|
|
{ value: PERSONAL, label: 'مطب شخصی' },
|
|
...clinics.map((c) => ({ value: c.uuid, label: c.name })),
|
|
], [clinics]);
|
|
|
|
const [picked, setPicked] = useState<string | null>(null);
|
|
|
|
// پیشفرض: مطب شخصی اگر مکان فعال دارد یا کلینیکی نیست؛ وگرنه اولین کلینیک —
|
|
// تا پزشکِ عضوِ کلینیکِ بدونِ مطب شخصی پیام «ابتدا آدرس مطب را ثبت کنید» نبیند.
|
|
const defaultContext = useMemo(() => {
|
|
if (personalLocations.length > 0 || clinics.length === 0) return PERSONAL;
|
|
return clinics[0].uuid;
|
|
}, [personalLocations.length, clinics]);
|
|
|
|
const selected = picked ?? defaultContext;
|
|
const clinicUuid = selected === PERSONAL ? null : selected;
|
|
|
|
const ready = !profileQ.isLoading && !personalLocationsQ.isLoading;
|
|
|
|
return (
|
|
<SettingsLayout active="appointment">
|
|
<div
|
|
style={{ background: 'var(--surface)', minWidth: 0 }}
|
|
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
|
>
|
|
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
|
|
|
|
<FreeVisitPrice readOnly={apptReadOnly} />
|
|
|
|
{!uuid ? (
|
|
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
|
این بخش فقط برای پزشک در دسترس است.
|
|
</div>
|
|
) : !ready ? (
|
|
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
|
|
) : (
|
|
<>
|
|
{clinics.length > 0 && (
|
|
<div className="mb-4" style={{ maxWidth: 320 }}>
|
|
<label id="appt-context-label" className="block text-xs mb-1" style={{ color: 'var(--text-3)' }}>محیط نوبتدهی</label>
|
|
<SearchableSelect
|
|
options={options}
|
|
value={selected}
|
|
onChange={(v) => setPicked(v == null ? PERSONAL : String(v))}
|
|
ariaLabelledBy="appt-context-label"
|
|
/>
|
|
</div>
|
|
)}
|
|
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} readOnly={apptReadOnly} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</SettingsLayout>
|
|
);
|
|
}
|