- 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.
309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
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, ShieldCheckIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
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];
|
|
|
|
export interface ClinicDoctorItem {
|
|
id: string; uuid: string; name: string;
|
|
gender: string | null; degree: string | null;
|
|
img: { url: string }[];
|
|
specialties: { id: string; name: string }[];
|
|
active: boolean;
|
|
}
|
|
|
|
export interface ClinicInvitation {
|
|
uuid: string;
|
|
mobile: string;
|
|
invited_name: string | null;
|
|
invited_specialty: string | null;
|
|
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
|
|
token_used: boolean;
|
|
invited_at: number;
|
|
expires_at: number;
|
|
responded_at: number | null;
|
|
doctor: { uuid: string; name: string } | null;
|
|
}
|
|
|
|
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
|
pending: { label: 'در انتظار', cls: 'amber' },
|
|
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
|
rejected: { label: 'رد شده', cls: 'gray' },
|
|
suspended: { label: 'تعلیق', cls: 'violet' },
|
|
removed: { label: 'حذفشده', cls: 'gray' },
|
|
};
|
|
|
|
/**
|
|
* ClinicDoctorsManager — self-contained management of a clinic's doctors and
|
|
* pending invitations (list, invite, resend, suspend, delete invitation, detach
|
|
* doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
|
|
* settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control.
|
|
*/
|
|
export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
|
clinicUuid: string;
|
|
readOnly?: boolean;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
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],
|
|
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
|
enabled: !!clinicUuid,
|
|
});
|
|
|
|
const invitationsQ = useQuery({
|
|
queryKey: ['clinic-invitations', clinicUuid],
|
|
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invitations?limit=50`),
|
|
enabled: !!clinicUuid,
|
|
});
|
|
|
|
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
|
const raw = doctorsQ.data?.data;
|
|
return (raw as any)?.data ?? raw ?? [];
|
|
}, [doctorsQ.data]);
|
|
|
|
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
|
|
|
const resendInvMut = useMutation({
|
|
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
|
|
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const changeInvStatusMut = useMutation({
|
|
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
|
|
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
|
|
onSuccess: (_d, v) => {
|
|
toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد');
|
|
qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const deleteInvMut = useMutation({
|
|
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
|
|
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const detachDoctorMut = useMutation({
|
|
mutationFn: (doctorUuid: string) =>
|
|
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
|
|
onSuccess: () => {
|
|
toast.success('پزشک از کلینیک جدا شد');
|
|
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
|
|
qc.invalidateQueries({ queryKey: ['clinic-detail', clinicUuid] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="card card-pad">
|
|
{/* Card header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<div className="seg">
|
|
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
|
پزشکان ({formatNumber(doctorList.length)})
|
|
</button>
|
|
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
|
دعوتنامهها ({formatNumber(invitationList.length)})
|
|
</button>
|
|
</div>
|
|
{!readOnly && (
|
|
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
|
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Doctors tab */}
|
|
{doctorsTab === 'doctors' && (
|
|
doctorList.length === 0 ? (
|
|
<div className="empty" style={{ padding: '20px 0' }}>
|
|
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{doctorList.map(doc => {
|
|
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
|
const img = doc.img?.[0]?.url;
|
|
return (
|
|
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
|
{img
|
|
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
|
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
|
}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
|
|
{doc.specialties?.length > 0 && (
|
|
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
|
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
<button
|
|
className="mini-btn"
|
|
title="مشاهده پروفایل"
|
|
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
|
>
|
|
<EyeIcon style={{ width: 14, height: 14 }} />
|
|
</button>
|
|
{!readOnly && (
|
|
<>
|
|
<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>
|
|
);
|
|
})}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Invitations tab */}
|
|
{doctorsTab === 'invitations' && (
|
|
invitationList.length === 0 ? (
|
|
<div className="empty" style={{ padding: '20px 0' }}>
|
|
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
|
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{invitationList.map(inv => {
|
|
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
|
|
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
|
|
return (
|
|
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
|
|
{inv.invited_specialty && (
|
|
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
|
|
)}
|
|
{inv.doctor && (
|
|
<button
|
|
className="badge blue"
|
|
style={{ fontSize: 11, cursor: 'pointer', border: 'none', background: 'none', padding: 0 }}
|
|
onClick={() => navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
|
|
>
|
|
<span className="bdot" />{inv.doctor.name}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
|
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
|
|
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
|
|
</span>
|
|
{!readOnly && (
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{inv.status === 'pending' && (
|
|
<button
|
|
className="mini-btn"
|
|
title="ارسال مجدد"
|
|
disabled={resendInvMut.isPending}
|
|
onClick={() => resendInvMut.mutate(inv.uuid)}
|
|
>
|
|
<ArrowPathIcon style={{ width: 13, height: 13 }} />
|
|
</button>
|
|
)}
|
|
{inv.status !== 'removed' && inv.status !== 'accepted' && (
|
|
<button
|
|
className="mini-btn"
|
|
title={inv.status === 'suspended' ? 'فعالسازی و ارسال مجدد پیامک' : 'تعلیق'}
|
|
disabled={changeInvStatusMut.isPending}
|
|
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
|
|
>
|
|
<NoSymbolIcon style={{ width: 13, height: 13 }} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="mini-btn danger"
|
|
title="حذف"
|
|
disabled={deleteInvMut.isPending}
|
|
onClick={() => deleteInvMut.mutate(inv.uuid)}
|
|
>
|
|
<TrashIcon style={{ width: 13, height: 13 }} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Detach Doctor From Clinic Confirm */}
|
|
<ConfirmDialog
|
|
open={detachDoctorConfirm !== null}
|
|
title="جداسازی پزشک از کلینیک"
|
|
message={`آیا پزشک "${detachDoctorConfirm?.name ?? ''}" از این کلینیک جدا شود؟ این کار فقط ارتباط پزشک با این کلینیک را حذف میکند.`}
|
|
confirmLabel="جداسازی"
|
|
danger
|
|
loading={detachDoctorMut.isPending}
|
|
onConfirm={() => {
|
|
if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid);
|
|
setDetachDoctorConfirm(null);
|
|
}}
|
|
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
|
|
clinicUuid={clinicUuid}
|
|
onClose={() => setInviteOpen(false)}
|
|
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|