feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules. - Updated unique constraints and indexes to accommodate the new clinic context. feat(command): create AssignScheduleClinicCommand to move schedules - Added a command to move a doctor's personal weekly schedule into a clinic context. - Implemented checks to ensure sessions align with the target clinic. feat(context): implement EntityContext and EntityContextResolver - Created EntityContext to represent the effective working environment of a request (doctor or clinic). - Developed EntityContextResolver to determine the execution context based on user roles and active contexts. test: add ServiceModeContextTest for appointment scheduling - Implemented tests to ensure service booking respects clinic and personal contexts. - Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
@@ -493,7 +493,14 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
|
||||
|
||||
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
|
||||
|
||||
export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) {
|
||||
/**
|
||||
* هر درخواست تنظیمات نوبتدهی باید محیطش را حمل کند: بدون clinic_uuid یعنی مطب
|
||||
* شخصی پزشک، و با آن یعنی همان پزشک داخل آن کلینیک. این دو دادهی جدا دارند.
|
||||
*/
|
||||
const withClinic = (url: string, clinicUuid?: string | null): string =>
|
||||
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
|
||||
|
||||
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);
|
||||
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
||||
@@ -504,8 +511,8 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
const [confirmMode, setConfirmMode] = useState(false);
|
||||
|
||||
const scheduleQ = useQuery({
|
||||
queryKey: ['doctor-schedule', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`),
|
||||
queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null],
|
||||
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, clinicUuid)),
|
||||
staleTime: 0,
|
||||
retry: (count, err) => !(err instanceof ApiError && err.status === 404),
|
||||
});
|
||||
@@ -550,15 +557,15 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
|
||||
if (missingLocation) throw new Error('مکان مطب برای همه بازههای فعال الزامی است');
|
||||
return scheduleUuid
|
||||
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
|
||||
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
|
||||
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
|
||||
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
|
||||
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
|
||||
setModeLocked(true); // پس از ثبت، نوع نوبتدهی قفل میشود
|
||||
toast.success('برنامه هفتگی ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
|
||||
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
@@ -712,7 +719,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
|
||||
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">سرویسها</span> تعریف کنید، وگرنه ذخیره نمیشود.
|
||||
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">{clinicUuid ? 'سرویسهای کلینیک' : 'سرویسها'}</span> تعریف کنید، وگرنه ذخیره نمیشود.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -875,9 +882,9 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
|
||||
// ── Date Override Modal ────────────────────────────────────────────────────
|
||||
|
||||
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addresses }: {
|
||||
function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved, addresses }: {
|
||||
open: boolean; onClose: () => void;
|
||||
existing: DateOverrideData | null; doctorUuid: string;
|
||||
existing: DateOverrideData | null; doctorUuid: string; clinicUuid?: string | null;
|
||||
onSaved: () => void; addresses: AddressData[];
|
||||
}) {
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
@@ -917,7 +924,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
|
||||
const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] };
|
||||
if (existing)
|
||||
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
|
||||
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid });
|
||||
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
|
||||
},
|
||||
onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -1015,15 +1022,15 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
|
||||
|
||||
// ── Date Overrides Tab ─────────────────────────────────────────────────────
|
||||
|
||||
function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) {
|
||||
function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DateOverrideData | null>(null);
|
||||
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['doctor-overrides', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`),
|
||||
queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null],
|
||||
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`, clinicUuid)),
|
||||
staleTime: 0,
|
||||
});
|
||||
const overrides: DateOverrideData[] = useMemo(
|
||||
@@ -1032,7 +1039,7 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${uuid}`),
|
||||
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] }); },
|
||||
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
@@ -1103,8 +1110,8 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
|
||||
</div>
|
||||
)}
|
||||
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
|
||||
existing={editing} doctorUuid={doctorUuid} addresses={addresses}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
|
||||
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={addresses}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] })} />
|
||||
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
|
||||
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
|
||||
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
|
||||
@@ -1114,9 +1121,9 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
|
||||
|
||||
// ── Holiday Modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
||||
function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved }: {
|
||||
open: boolean; onClose: () => void;
|
||||
existing: HolidayData | null; doctorUuid: string;
|
||||
existing: HolidayData | null; doctorUuid: string; clinicUuid?: string | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState('');
|
||||
@@ -1143,7 +1150,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
||||
const body = { start_date: startDate, end_date: endDate, reason: reason || undefined };
|
||||
if (existing)
|
||||
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body);
|
||||
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid });
|
||||
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
|
||||
},
|
||||
onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -1187,15 +1194,15 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
||||
|
||||
// ── Holidays Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
|
||||
function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HolidayData | null>(null);
|
||||
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['doctor-holidays', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`),
|
||||
queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null],
|
||||
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`, clinicUuid)),
|
||||
staleTime: 0,
|
||||
});
|
||||
const holidays: HolidayData[] = useMemo(
|
||||
@@ -1204,13 +1211,13 @@ function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; rea
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${uuid}`),
|
||||
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
|
||||
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: (h: HolidayData) => api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${h.uuid}`, { active: !h.active }),
|
||||
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
|
||||
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
@@ -1284,8 +1291,8 @@ function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; rea
|
||||
</div>
|
||||
)}
|
||||
<HolidayModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
|
||||
existing={editing} doctorUuid={doctorUuid}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} />
|
||||
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] })} />
|
||||
<ConfirmDialog open={!!deletingUuid} title="حذف تعطیلات" message="آیا از حذف این تعطیلات اطمینان دارید؟"
|
||||
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
|
||||
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
|
||||
@@ -1301,12 +1308,12 @@ const SCHEDULE_TABS = [
|
||||
{ id: 'holidays' as const, label: 'تعطیلات' },
|
||||
];
|
||||
|
||||
export function ScheduleSection({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
|
||||
export function ScheduleSection({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
|
||||
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
|
||||
|
||||
const locationsQ = useQuery({
|
||||
queryKey: ['available-locations', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`),
|
||||
queryKey: ['available-locations', doctorUuid, clinicUuid ?? null],
|
||||
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/available-locations/${doctorUuid}`, clinicUuid)),
|
||||
enabled: !!doctorUuid,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -1327,9 +1334,9 @@ export function ScheduleSection({ doctorUuid, readOnly = false }: { doctorUuid:
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} readOnly={readOnly} />}
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} readOnly={readOnly} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user