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:
@@ -31,7 +31,7 @@ import CategoriesPage from './pages/CategoriesPage';
|
||||
import BlogsPage from './pages/BlogsPage';
|
||||
import BlogFormPage from './pages/BlogFormPage';
|
||||
import SecretariesPage from './pages/SecretariesPage';
|
||||
import MyClinicPage from './pages/MyClinicPage';
|
||||
import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import FinancialReportPage from './pages/FinancialReportPage';
|
||||
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
|
||||
@@ -187,8 +187,10 @@ export default function App() {
|
||||
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
|
||||
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
|
||||
|
||||
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
|
||||
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
|
||||
{/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */}
|
||||
<Route path="settings/clinic-doctors" element={<RoleRoute roles={['clinic']} blockClinicScope><ClinicDoctorsPage /></RoleRoute>} />
|
||||
{/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */}
|
||||
<Route path="my-clinic" element={<Navigate to="/admin/settings/clinic-doctors" replace />} />
|
||||
|
||||
{/* ادمین + کلینیک */}
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'representation']}><ClinicDetailPage /></RoleRoute>} />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import ClinicDoctorsManager from './ClinicDoctorsManager';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
|
||||
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
|
||||
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
|
||||
] });
|
||||
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
|
||||
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
|
||||
responded_at: null, doctor: null },
|
||||
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClinicDoctorsManager', () => {
|
||||
it('lists clinic doctors and shows management controls by default', async () => {
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
|
||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
||||
expect(screen.getByText('قلب')).toBeInTheDocument();
|
||||
// manager controls
|
||||
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides every mutating control when readOnly', async () => {
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
|
||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
||||
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
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,
|
||||
} 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';
|
||||
|
||||
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 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: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); 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 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="تعلیق"
|
||||
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)}
|
||||
/>
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && clinicUuid && (
|
||||
<InviteDoctorModal
|
||||
clinicUuid={clinicUuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SearchHeaderP } from '../../pages/subscriptionIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
/**
|
||||
* Settings sub-navigation for the subscription page — item list and order copied
|
||||
@@ -12,28 +13,32 @@ import { SearchHeaderP } from '../../pages/subscriptionIcons';
|
||||
* This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the
|
||||
* other settings pages are not affected.
|
||||
*/
|
||||
type NavItem = { key: string; label: string; to?: string };
|
||||
/** `roles`: when set, the item is only shown to those roles (omit = every role). */
|
||||
type NavItem = { key: string; label: string; to?: string; roles?: string[] };
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings' },
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', to: '/admin/discounts' },
|
||||
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' },
|
||||
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet' },
|
||||
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', to: '/admin/staff' },
|
||||
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings' },
|
||||
{ key: 'security', label: 'تنظیمات' },
|
||||
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', to: '/admin/discounts', roles: ['doctor', 'clinic'] },
|
||||
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' },
|
||||
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet' },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', to: '/admin/staff' },
|
||||
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings' },
|
||||
{ key: 'security', label: 'تنظیمات' },
|
||||
];
|
||||
|
||||
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
const items = useMemo(
|
||||
() => NAV_ITEMS.filter((i) => i.label.includes(query.trim())),
|
||||
[query],
|
||||
() => NAV_ITEMS
|
||||
.filter((i) => !i.roles || (primaryRole != null && i.roles.includes(primaryRole)))
|
||||
.filter((i) => i.label.includes(query.trim())),
|
||||
[query, primaryRole],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,10 +34,17 @@ describe('SettingsLayout', () => {
|
||||
expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the same menu to every role (ungated, mirroring tauri)', () => {
|
||||
// the desktop sidebar is not role-gated: مدیریت مطب shows even for a doctor
|
||||
it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => {
|
||||
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
|
||||
expect(screen.getByText('مدیریت مطب')).toBeInTheDocument();
|
||||
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
|
||||
// the removed 'مدیریت مطب' tab must be gone for everyone
|
||||
expect(screen.queryByText('مدیریت مطب')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the clinic-doctors tab to a clinic owner', () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' });
|
||||
renderWithProviders(<SettingsLayout active="clinic-doctors"><div /></SettingsLayout>);
|
||||
expect(screen.getByText('پزشکان کلینیک').closest('a')).toHaveAttribute('href', '/admin/settings/clinic-doctors');
|
||||
});
|
||||
|
||||
it('filters the menu by the search query', () => {
|
||||
@@ -49,8 +56,10 @@ describe('SettingsLayout', () => {
|
||||
|
||||
it('menuForRole still role-gates the mobile settings list', () => {
|
||||
// SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU
|
||||
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic');
|
||||
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors');
|
||||
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor');
|
||||
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment');
|
||||
// a plain doctor must not get the clinic-doctors management tab
|
||||
expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
|
||||
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
|
||||
{ key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff' },
|
||||
|
||||
@@ -203,7 +203,7 @@ function buildSections(
|
||||
if (primaryRole === "clinic") {
|
||||
const clinicTo = dbUuid
|
||||
? `/admin/clinics/${dbUuid}`
|
||||
: "/admin/my-clinic";
|
||||
: "/admin/settings/clinic-doctors";
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user