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
@@ -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 />}