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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user