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:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -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>
);
}
@@ -100,7 +100,7 @@ function ClinicAppointmentSettingsContent() {
<span style={{ fontWeight: 600 }}>{selectedDoctor?.name}</span>
</div>
<FreeVisitPrice doctorUuid={selected} />
<ScheduleSection doctorUuid={selected} />
<ScheduleSection doctorUuid={selected} clinicUuid={clinicUuid} />
</div>
)}
</>
+60
View File
@@ -13,6 +13,8 @@ import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable';
import { usePermissions } from '../hooks/usePermissions';
// ── Shared Status Maps ────────────────────────────────────────────────────
@@ -1111,14 +1113,72 @@ function RepresentationDashboard() {
);
}
// ── Invited-Doctor Dashboard (doctor working inside a clinic) ─────────────
/**
* پزشکی که با دعوت وارد یک کلینیک شده، در محیط آن کلینیک فقط کار خودش را می‌بیند.
* ارقام مالی اینجا نمایش داده نمی‌شوند و backend هم آن‌ها را برنمی‌گرداند؛ این
* کامپوننت لایهٔ دوم است، نه تنها محافظ.
*/
function InvitedDoctorDashboard() {
const dbUuid = useAuthStore(s => s.dbUuid);
const { can } = usePermissions();
const q = useQuery({
queryKey: ['dashboard-doctor-clinic', dbUuid],
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>(
`/api/v1/dashboard/doctor${dbUuid ? `?clinic_uuid=${encodeURIComponent(dbUuid)}` : ''}`
),
staleTime: 60_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = useMemo<DoctorDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
if (q.isLoading) return <LoadingSkeleton />;
const tiles: Array<{ label: string; value: string }> = [
{ label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0) },
{ label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0) },
{ label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0) },
];
return (
<div className="fade-in">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-[var(--gap)]">
{tiles.map(t => (
<div key={t.label} className="card card-pad">
<b style={{ fontSize: 22 }}>{t.value}</b>
<p className="muted" style={{ fontSize: 13.5, marginTop: 6 }}>{t.label}</p>
</div>
))}
</div>
{can('appointments', 'view') && (
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>لیست نوبتهای جدید</h3>
<Link to="/admin/appointments" className="muted" style={{ fontSize: 13 }}>نوبتها</Link>
</div>
<NewAppointmentsTable rows={d?.today_appointments ?? []} loading={q.isFetching} />
</div>
)}
</div>
);
}
// ── Main Dispatcher ───────────────────────────────────────────────────────
export default function DashboardPage() {
const primaryRole = useAuthStore(s => s.primaryRole);
const scope = useAuthStore(s => s.context?.scope ?? null);
if (!primaryRole) return <LoadingSkeleton />;
if (primaryRole === 'admin') return <AdminDashboard />;
if (primaryRole === 'clinic') return <ClinicDashboard />;
// پزشکِ دعوت‌شده داخل کلینیک، داشبورد شخصی‌اش را نمی‌بیند: نه درآمد، نه کیف پول،
// نه فهرست کلینیک‌ها — فقط نوبت‌های خودش در همان کلینیک.
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
if (primaryRole === 'doctor') return <DoctorDashboard />;
if (primaryRole === 'secretary') return <SecretaryDashboard />;
if (primaryRole === 'representation') return <RepresentationDashboard />;
+11 -1
View File
@@ -1137,10 +1137,20 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const doctorUuid = useAuthStore(s => s.doctorUuid);
const context = useAuthStore(s => s.context);
const availableContexts = useAuthStore(s => s.availableContexts);
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
// صفحهٔ پزشک در پنل کلینیک، تنظیمات نوبت‌دهیِ همان کلینیک را ویرایش می‌کند — نه
// برنامهٔ مطب شخصی پزشک، که فقط خودش به آن دسترسی دارد.
const scheduleClinicUuid = useMemo(() => {
if (isOwnProfile) return null;
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
}, [isOwnProfile, context, dbUuid, availableContexts]);
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleConfirm, setToggleConfirm] = useState(false);
@@ -1589,7 +1599,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
)}
</div>
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} clinicUuid={scheduleClinicUuid} readOnly={isReadOnly} />}
{isOwnProfile && <ClinicInvitationsSection />}