feat: refactor clinic management into a dedicated settings tab

- Removed MyClinicPage and redirected its functionality to a new ClinicDoctorsPage.
- Created ClinicDoctorsManager component for managing doctors and invitations within the settings layout.
- Updated backend permissions to allow clinic owners to detach doctors, alongside admins.
- Adjusted API documentation to reflect new permission structure.
- Updated tests to cover new functionality and permissions.
- Modified sidebar and settings menu to reflect the new structure and role-based visibility.
This commit is contained in:
hamed
2026-07-17 21:56:27 +03:30
parent 385b81fae0
commit 6ab7ed38b8
15 changed files with 731 additions and 318 deletions
+4 -255
View File
@@ -9,19 +9,18 @@ import {
ArrowRightIcon, PencilIcon, TrashIcon,
BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
PlusIcon, CameraIcon, ChevronDownIcon,
EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
} from '@heroicons/react/24/outline';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
// Fix leaflet icons
@@ -38,27 +37,6 @@ const MAX_GALLERY_IMAGES = 5;
// ── Types ──────────────────────────────────────────────────────────────────
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;
}
interface ClinicDoctorItem {
id: string; uuid: string; name: string;
gender: string | null; degree: string | null;
img: { url: string }[];
specialties: { id: string; name: string }[];
active: boolean;
}
interface ClinicAddress {
id: string; uuid: string;
name: string | null; address: string | null;
@@ -460,17 +438,6 @@ function EditModal({ clinic, onClose, onSaved }: {
);
}
// ── Invitation status badge ────────────────────────────────────────────────
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' },
};
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() {
@@ -486,8 +453,6 @@ export default function ClinicDetailPage() {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
const logoInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const [logoUploading, setLogoUploading] = useState(false);
@@ -499,18 +464,6 @@ export default function ClinicDetailPage() {
enabled: !!uuid,
});
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', uuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${uuid}`),
enabled: !!uuid,
});
const invitationsQ = useQuery({
queryKey: ['clinic-invitations', uuid],
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${uuid}/invitations?limit=50`),
enabled: !!uuid,
});
const clinicAddressesQ = useQuery({
queryKey: ['clinic-addresses', uuid],
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
@@ -522,7 +475,6 @@ export default function ClinicDetailPage() {
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
const [deleteImageConfirm, setDeleteImageConfirm] = useState<string | null>(null);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const emptyAddrForm = { name: '', address: '', telephone: '', province_id: null as number | null, city_id: null as number | null, latitude: null as number | null, longitude: null as number | null };
const [addrForm, setAddrForm] = useState(emptyAddrForm);
@@ -586,13 +538,6 @@ export default function ClinicDetailPage() {
return (raw as any)?.data ?? raw;
}, [data]);
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const openEdit = () => setEditOpen(true);
const toggleMut = useMutation({
@@ -611,36 +556,6 @@ export default function ClinicDetailPage() {
onError: (e: Error) => toast.error(e.message),
});
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', uuid] }); },
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: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
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', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${uuid}/doctor/${doctorUuid}`),
onSuccess: () => {
toast.success('پزشک از کلینیک جدا شد');
qc.invalidateQueries({ queryKey: ['clinic-doctors', uuid] });
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
const handleLogoUpload = async (file: File) => {
setLogoUploading(true);
try {
@@ -844,150 +759,8 @@ export default function ClinicDetailPage() {
)}
</div>
{/* Doctors + Invitations card */}
<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>
{!isReadOnly && (
<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>
<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>
{!isReadOnly && (
<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="تعلیق"
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>
{/* Doctors + Invitations — shared manager */}
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly} />
{/* Gallery */}
<div className="card card-pad">
@@ -1287,21 +1060,6 @@ export default function ClinicDetailPage() {
onCancel={() => setDeleteImageConfirm(null)}
/>
{/* 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)}
/>
{/* Edit modal — self-portals to escape transform context */}
{editOpen && (
<EditModal
@@ -1311,15 +1069,6 @@ export default function ClinicDetailPage() {
/>
)}
{/* Invite doctor modal */}
{inviteOpen && uuid && (
<InviteDoctorModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
/>
)}
{/* Delete confirm */}
<ConfirmDialog
open={deleteOpen}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
/**
* ClinicDoctorsPage — the clinic-owner settings tab for managing the clinic's
* doctors (list, invite, detach) and pending invitations. Renders inside the
* settings shell instead of jumping to the standalone admin ClinicDetailPage.
* Clinic-info editing (name, gallery, addresses, specialties) stays on the full
* detail page, reachable via the "ویرایش اطلاعات کلینیک" link.
*/
function ClinicDoctorsContent() {
const { dbUuid, fetchMe } = useAuthStore();
useEffect(() => {
if (!dbUuid) fetchMe();
}, [dbUuid, fetchMe]);
if (!dbUuid) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>
);
}
return (
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
<div className="card-title-row">
<div>
<h1 className="section-title">پزشکان کلینیک</h1>
<div className="muted">مدیریت پزشکان و دعوتنامههای کلینیک</div>
</div>
<Link className="btn ghost sm" to={`/admin/clinics/${dbUuid}`}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
</Link>
</div>
<ClinicDoctorsManager clinicUuid={dbUuid} />
</div>
);
}
export default function ClinicDoctorsPage() {
return (
<SettingsLayout active="clinic-doctors">
<ClinicDoctorsContent />
</SettingsLayout>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
function MyClinicPageContent() {
const { dbUuid, fetchMe } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (dbUuid) {
navigate(`/admin/clinics/${dbUuid}`, { replace: true });
} else {
fetchMe().then(() => {
const uuid = useAuthStore.getState().dbUuid;
if (uuid) navigate(`/admin/clinics/${uuid}`, { replace: true });
});
}
}, [dbUuid, fetchMe, navigate]);
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>
);
}
export default function MyClinicPage() {
return (
<SettingsLayout active="clinic">
<MyClinicPageContent />
</SettingsLayout>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe('SettingsMenuPage', () => {
expect(screen.getByText(item.label)).toBeInTheDocument();
}
// clinic-only section is not shown to a doctor
expect(screen.queryByText('مدیریت مطب')).not.toBeInTheDocument();
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
});
it('links every section to its route', () => {