feat: implement OTP login flow and enhance role-based access control
- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment. - Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions. - Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience. - Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking. - Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements. - Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -11,8 +11,11 @@ import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
||||
import DataTable, { type Column } from './ui/DataTable';
|
||||
import Pagination from './ui/Pagination';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export interface ClinicDoctorItem {
|
||||
id: string; uuid: string; name: string;
|
||||
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
|
||||
doctor: { uuid: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** یک ردیف از `GET /api/v1/admin/clinic/{uuid}/doctor-permissions`. */
|
||||
interface DoctorPermissionRow {
|
||||
doctor_uuid: string;
|
||||
active: boolean;
|
||||
permissions: { resources: Record<string, Record<string, boolean>> };
|
||||
}
|
||||
|
||||
/** چند اکشن از کل اکشنهای تعریفشده به این پزشک داده شده. */
|
||||
function grantedCount(row?: DoctorPermissionRow): { granted: number; total: number } {
|
||||
const resources = row?.permissions?.resources ?? {};
|
||||
let granted = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const actions of Object.values(resources)) {
|
||||
for (const allowed of Object.values(actions)) {
|
||||
total += 1;
|
||||
if (allowed) granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, total };
|
||||
}
|
||||
|
||||
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'در انتظار', cls: 'amber' },
|
||||
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
||||
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// فیلدِ جستجو محلی میماند و فقط مقدارِ آرامشده به کوئری میرود؛ وگرنه هر حرف یک
|
||||
// درخواست به سرور میزند.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
/**
|
||||
* صفحه و جستجو به سرور میروند.
|
||||
*
|
||||
* پیش از این هیچکدام فرستاده نمیشد و backend سقف پیشفرضِ ۱۰ را اعمال میکرد
|
||||
* (`DoctorRepository::findByClinicWithFilters`)، پس کلینیکِ یازدهپزشکه بیهیچ نشانهای
|
||||
* یک پزشک را گم میکرد.
|
||||
*/
|
||||
const doctorsQ = useQuery({
|
||||
queryKey: ['clinic-doctors', clinicUuid],
|
||||
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
||||
queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch],
|
||||
queryFn: () => api.get<PaginatedResponse<ClinicDoctorItem>>(
|
||||
`/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}`
|
||||
+ (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''),
|
||||
),
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
|
||||
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
|
||||
/**
|
||||
* مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست.
|
||||
*
|
||||
* اندپوینت تکپزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل
|
||||
* وجود داشت و فقط مصرف نمیشد.
|
||||
*/
|
||||
const permissionsQ = useQuery({
|
||||
queryKey: ['clinic-doctor-permissions', clinicUuid],
|
||||
queryFn: () => api.get<ApiResponse<DoctorPermissionRow[]>>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`),
|
||||
enabled: !!clinicUuid && canUpdate,
|
||||
});
|
||||
|
||||
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
||||
const raw = doctorsQ.data?.data;
|
||||
return (raw as any)?.data ?? raw ?? [];
|
||||
}, [doctorsQ.data]);
|
||||
|
||||
const doctorTotal = doctorsQ.data?.meta?.totalRecords ?? doctorList.length;
|
||||
|
||||
const permissionByDoctor = useMemo(() => {
|
||||
const rows = permissionsQ.data?.data ?? [];
|
||||
return new Map((Array.isArray(rows) ? rows : []).map(r => [r.doctor_uuid, r]));
|
||||
}, [permissionsQ.data]);
|
||||
|
||||
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||
|
||||
const resendInvMut = useMutation({
|
||||
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
/**
|
||||
* ستونها دو «فعال بودن» را از هم جدا میکنند، چون دو چیز متفاوتاند و پیش از این
|
||||
* یکی جای هر دو مینشست:
|
||||
*
|
||||
* • «دسترسی در کلینیک» → `ClinicDoctorPermission::active` — کلیدِ خودِ مالک کلینیک،
|
||||
* و خاموشبودنش یعنی `can()` همهچیز را رد میکند.
|
||||
* • «نوبتدهی آنلاین» → `Doctor::$activeDoctorAppointment && has_schedule` — حالِ
|
||||
* پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد.
|
||||
*
|
||||
* بجِ قبلی دومی را نشان میداد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ
|
||||
* هفتگی «غیرفعال» خوانده میشد و مالک ممکن بود بیدلیل جدایش کند.
|
||||
*/
|
||||
const doctorColumns: Column<ClinicDoctorItem>[] = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'پزشک',
|
||||
render: (doc) => {
|
||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
const img = doc.img?.[0]?.url;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
{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={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||
{doc.specialties?.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// بج و شمارِ مجوز یک ستوناند نه دو: هر دو یک سؤال را جواب میدهند («این پزشک
|
||||
// چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا میشود.
|
||||
key: 'clinic_access',
|
||||
header: 'دسترسی در کلینیک',
|
||||
render: (doc) => {
|
||||
// ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیشفرضها برقرارند.
|
||||
const row = permissionByDoctor.get(doc.uuid);
|
||||
const on = row?.active ?? true;
|
||||
const { granted, total } = grantedCount(row);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
|
||||
<span className={`badge ${on ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{on ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: 11 }}>
|
||||
{row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیشفرض'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'online_booking',
|
||||
header: 'نوبتدهی آنلاین',
|
||||
render: (doc) => (
|
||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [permissionByDoctor]);
|
||||
|
||||
const detachDoctorMut = useMutation({
|
||||
mutationFn: (doctorUuid: string) =>
|
||||
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
|
||||
@@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({
|
||||
{/* Card header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div className="seg">
|
||||
{/* شمارنده از meta میآید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
|
||||
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
||||
پزشکان ({formatNumber(doctorList.length)})
|
||||
پزشکان ({formatNumber(doctorTotal)})
|
||||
</button>
|
||||
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
||||
دعوتنامهها ({formatNumber(invitationList.length)})
|
||||
@@ -147,67 +282,76 @@ export default function ClinicDoctorsManager({
|
||||
|
||||
{/* Doctors tab */}
|
||||
{doctorsTab === 'doctors' && (
|
||||
doctorList.length === 0 ? (
|
||||
doctorsQ.isError ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
||||
<p className="muted">فهرست پزشکان بارگذاری نشد</p>
|
||||
<button className="btn secondary sm" onClick={() => doctorsQ.refetch()}>تلاش دوباره</button>
|
||||
</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>
|
||||
<>
|
||||
<DataTable<ClinicDoctorItem>
|
||||
columns={doctorColumns}
|
||||
data={doctorList}
|
||||
loading={doctorsQ.isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="جستجوی نام پزشک"
|
||||
emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'}
|
||||
actions={doc => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مشاهده پروفایل"
|
||||
aria-label={`مشاهده پروفایل ${doc.name}`}
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
||||
>
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && canUpdate && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مشاهده پروفایل"
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
||||
title="مدیریت دسترسیها"
|
||||
aria-label={`مدیریت دسترسیهای ${doc.name}`}
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && canUpdate && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مدیریت دسترسیها"
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && canDelete && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && canDelete && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
aria-label={`جداسازی ${doc.name} از کلینیک`}
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={doctorTotal}
|
||||
limit={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Invitations tab */}
|
||||
{doctorsTab === 'invitations' && (
|
||||
invitationList.length === 0 ? (
|
||||
invitationsQ.isLoading ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">در حال بارگذاری…</p>
|
||||
</div>
|
||||
) : invitationsQ.isError ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">فهرست دعوتنامهها بارگذاری نشد</p>
|
||||
<button className="btn secondary sm" onClick={() => invitationsQ.refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : invitationList.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
||||
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
||||
@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({
|
||||
clinicUuid={clinicUuid}
|
||||
doctorUuid={permissionsFor.uuid}
|
||||
doctorName={permissionsFor.name}
|
||||
onClose={() => setPermissionsFor(null)}
|
||||
onClose={() => {
|
||||
setPermissionsFor(null);
|
||||
// ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند.
|
||||
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user