feat: Implement secretary permissions enforcement across multiple resources

- Added SecretaryAccessChecker to manage resource access for secretaries.
- Integrated permission checks for payments, inventory, and tags in relevant controllers.
- Updated PaymentController and PaymentMethodController to enforce secretary permissions.
- Enhanced TenantTagController to check permissions for tag management actions.
- Introduced tests for secretary resource enforcement, ensuring proper access control.
- Updated DoctorSecretary entity to include inventory and tags permissions.
- Created a comprehensive audit document for secretary permissions coverage and enforcement.
- Fixed potential crashes in SecretaryDashboard when rendering without doctor data.
This commit is contained in:
hamed
2026-07-23 16:36:35 +03:30
parent f00ed23f00
commit 5c4976d65f
24 changed files with 790 additions and 87 deletions
+27 -8
View File
@@ -869,7 +869,9 @@ function DoctorDashboard() {
// ── Secretary Dashboard ───────────────────────────────────────────────────
interface SecretaryDashboardData {
doctor: { uuid: string; name: string; degree: string | null };
scope: 'doctor' | 'clinic';
doctor?: { uuid: string; name: string; degree: string | null };
clinic?: { uuid: string; name: string };
permissions: Record<string, unknown>;
stats: { today_appointments: number; tomorrow_appointments: number };
today_appointments: ApptRow[];
@@ -888,20 +890,37 @@ function SecretaryDashboard() {
if (q.isLoading) return <LoadingSkeleton />;
if (q.isError || !d) {
return (
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
<p className="muted" style={{ fontSize: 13.5 }}>در حال حاضر اطلاعات داشبورد در دسترس نیست.</p>
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
تلاش دوباره
</button>
</div>
);
}
// منشیِ scope=clinic فیلد doctor ندارد و scope=doctor فیلد clinic؛ نام محیط بر اساس scope.
const scopeName = d.scope === 'clinic' ? (d.clinic?.name ?? '') : displayDoctorName(d.doctor?.name);
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
const kpiCards = [
{ label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'نوبت‌های امروز', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'نوبت‌های فردا', value: formatNumber(d.stats?.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false;
const canViewAppts = (d.permissions as any)?.resources?.appointments?.view ?? false;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">داشبورد منشی</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {displayDoctorName(d?.doctor.name)}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {scopeLabel} {scopeName}</div>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
@@ -910,10 +929,10 @@ function SecretaryDashboard() {
</div>
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
<AvatarEl initials={(scopeName || 'D').slice(0, 1)} hue={256} size="lg" />
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{displayDoctorName(d?.doctor.name) || '—'}</div>
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
<div style={{ fontWeight: 700, fontSize: 16 }}>{scopeName || '—'}</div>
{d.scope === 'doctor' && d.doctor?.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
</div>
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>