feat: add per-doctor permissions management in clinics
- Implement DoctorPermissionsModal for managing doctor permissions in clinics. - Create usePermissions hook to handle user permissions context. - Add migration for clinic_doctor_permissions table with default permissions. - Develop ClinicDoctorPermissionController for handling permissions API. - Create ClinicDoctorPermission entity to manage permissions data. - Implement ClinicDoctorPermissionRepository for database interactions. - Add ClinicDoctorPermissionChecker for permission validation logic. - Write tests for clinic doctor permissions functionality.
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
|
||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
@@ -10,6 +10,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
@@ -58,6 +59,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
||||
|
||||
const doctorsQ = useQuery({
|
||||
queryKey: ['clinic-doctors', clinicUuid],
|
||||
@@ -166,13 +168,22 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مدیریت دسترسیها"
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,6 +285,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
onCancel={() => setDetachDoctorConfirm(null)}
|
||||
/>
|
||||
|
||||
{/* Per-doctor clinic permissions */}
|
||||
{permissionsFor && (
|
||||
<DoctorPermissionsModal
|
||||
clinicUuid={clinicUuid}
|
||||
doctorUuid={permissionsFor.uuid}
|
||||
doctorName={permissionsFor.name}
|
||||
onClose={() => setPermissionsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && clinicUuid && (
|
||||
<InviteDoctorModal
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
import { usePermissions } from "../../hooks/usePermissions";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
@@ -53,28 +54,32 @@ function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
scope: string | null,
|
||||
can: (resource: string, action: string) => boolean,
|
||||
): Section[] {
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبتهای خودش؛
|
||||
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمیشوند.
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
||||
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
],
|
||||
},
|
||||
const items: SectionItem[] = [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
];
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
});
|
||||
}
|
||||
if (can("patients", "view")) {
|
||||
items.push({
|
||||
to: "/admin/patients",
|
||||
icon: FolderOpenIcon,
|
||||
label: "پرونده بیماران",
|
||||
feature: "patient_records",
|
||||
});
|
||||
}
|
||||
|
||||
return [{ label: "عمومی", items }];
|
||||
}
|
||||
|
||||
if (primaryRole === "admin") {
|
||||
@@ -625,7 +630,8 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const { hasFeature } = useSubscription();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null);
|
||||
const { can } = usePermissions();
|
||||
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
|
||||
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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: 'ویرایش' },
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user