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:
@@ -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