Files
clinicpro/assets/admin/components/ui/DoctorPermissionsModal.tsx
T
hamedandClaude Opus 4.8 f33c7a3eab feat(clinic-doctor): full permission coverage + enforcement, parity with secretary
The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the
secretary system: only 6 resources, enforced in ~6 places, dead toggles
(services.update never checked), and a sidebar showing just appointments+patients.
Bring it to parity so a clinic owner can control exactly what each member doctor
does — while an independent doctor stays completely unrestricted.

Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to
ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal
(subscription/clinic_doctors stay owner-only by design).

New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to
SecretaryAccessChecker):
- denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor
  in the clinic context; owner/admin/secretary/independent-doctor pass through.
- memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the
  role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them
  their personal tenant in clinic context.

Enforcement wired into 10 controllers alongside the existing secretary gates:
ClinicService (services), Insurance (insurances), Patient (patients+payments),
Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod.

Frontend: the guest-doctor sidebar branch now exposes every permitted resource
(gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription
Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor,
not just secretaries; my-payments route gets the missing payments permission.
CRUD-button gating already applies (usePermissions is role-agnostic).

Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed +
independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass,
frontend 503 pass. docs/api/clinic.md updated with the full resource set +
enforcement notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:09:16 +03:30

206 lines
7.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
/** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */
export interface PermissionEnvelope {
version: number;
resources: Record<string, Record<string, boolean>>;
}
export interface ClinicDoctorPermissionPayload {
uuid: string;
clinic_uuid: string;
doctor_uuid: string;
doctor_name: string;
active: boolean;
permissions: PermissionEnvelope;
}
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
appointments: {
label: 'نوبت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
},
appointment_settings: {
label: 'تنظیمات نوبت‌دهی',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
patients: {
label: 'پرونده بیماران',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
payments: {
label: 'پرداخت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
services: {
label: 'خدمات',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
clinic_info: {
label: 'اطلاعات کلینیک',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
insurances: {
label: 'بیمه‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
addresses: {
label: 'آدرس‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
inventory: {
label: 'انبارداری',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
tags: {
label: 'تگ‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
staff: {
label: 'پرسنل',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
discounts: {
label: 'تخفیف‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
sms: {
label: 'پیامک‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
};
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorName, onClose }: {
clinicUuid: string;
doctorUuid: string;
doctorName: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
const [active, setActive] = useState(true);
const permQ = useQuery({
queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid],
queryFn: () => api.get<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
),
});
useEffect(() => {
const payload = permQ.data?.data;
if (!payload) return;
setResources(payload.permissions?.resources ?? {});
setActive(payload.active);
}, [permQ.data]);
const saveMut = useMutation({
mutationFn: () => api.patch<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
{ permissions: { resources }, active },
),
onSuccess: () => {
toast.success('دسترسی‌های پزشک ذخیره شد');
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid] });
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const toggle = (resource: string, action: string) => {
setResources(prev => ({
...prev,
[resource]: { ...prev[resource], [action]: !prev[resource]?.[action] },
}));
};
return (
<Modal
open
size="lg"
title={`دسترسی‌های ${doctorName}`}
onClose={onClose}
footer={
<>
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
disabled={saveMut.isPending || permQ.isLoading}
onClick={() => saveMut.mutate()}
>
ذخیره
</button>
</>
}
>
{permQ.isLoading ? (
<p className="muted">در حال بارگذاری...</p>
) : (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<input
type="checkbox"
checked={active}
onChange={() => setActive(v => !v)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
<span>دسترسی این پزشک به کلینیک فعال باشد</span>
</label>
<div style={{ overflowX: 'auto' }}>
<table className="t">
<thead>
<tr>
<th>بخش</th>
{ACTION_HEADERS.map(h => (
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{Object.keys(RESOURCE_LABELS).map(resource => {
const config = RESOURCE_LABELS[resource];
return (
<tr key={resource}>
<td><b>{config.label}</b></td>
{ACTION_COLUMNS.map(action => {
if (!config.actions[action]) {
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action} style={{ textAlign: 'center' }}>
<input
type="checkbox"
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<p className="muted" style={{ fontSize: 12, marginTop: 12 }}>
این دسترسی‌ها فقط داخل همین کلینیک اعمال می‌شوند؛ مطب شخصی پزشک تحت تأثیر قرار نمی‌گیرد.
</p>
</>
)}
</Modal>
);
}