Files
clinicpro/assets/admin/pages/DoctorsPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

548 lines
24 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import {
MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon,
CheckCircleIcon, XCircleIcon, TableCellsIcon, Squares2X2Icon, DevicePhoneMobileIcon,
} from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { useUrlState, pageOf } from '../hooks/useUrlState';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
// ── Types ─────────────────────────────────────────────────────────────────
interface AdminDoctor {
uuid: string;
id: number;
name: string;
gender: string | null;
degree: string | null;
medical_code: string | null;
mobile: string | null;
email: string | null;
is_active: boolean;
owner_status?: string;
source?: string;
rate: number;
representation_id?: number | null;
representation_uuid?: string | null;
representation_name?: string | null;
specialties: { id: number; name: string }[];
profile_image: string | null;
created_at: string;
}
interface DoctorStats {
total: number;
active: number;
inactive: number;
male: number;
female: number;
}
interface SpecialtyOption {
id: number;
uuid: string;
name: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────
const HUES_LIST = [256, 205, 162, 295, 272];
const DEGREE_BADGE: Record<string, string> = {
general: 'gray',
specialist: 'blue',
expert: 'violet',
subspecialistplus: 'amber',
};
const DEGREE_LABEL: Record<string, string> = {
general: 'عمومی',
specialist: 'متخصص',
expert: 'فوق تخصص',
subspecialistplus: 'فلوشیپ',
};
function DoctorAvatar({ name, id, image, size = 'sm' }: {
name: string; id: number; image: string | null; size?: 'sm' | 'lg';
}) {
const initials = name.split(' ').filter(Boolean).map((w) => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr';
const hue = HUES_LIST[id % HUES_LIST.length];
if (image) {
return (
<div className={`avatar ${size}`} style={{ padding: 0, overflow: 'hidden' }}>
<img src={image} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
return (
<div
className={`avatar ${size}`}
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))` }}
>
{initials}
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────
export default function DoctorsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const isRepresentation = primaryRole === 'representation';
// وضعیت لیست در URL می‌ماند تا «بازگشت» از پروفایل پزشک، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({
page: '1', search: '', status: '', owner: '', specialty: '', province: '', city: '', view: 'table',
});
const page = pageOf(urlState.page);
const search = urlState.search;
const status = urlState.status;
const ownerStatus = urlState.owner;
const specialtyId = urlState.specialty;
const provinceId = urlState.province;
const cityId = urlState.city;
const view: 'table' | 'grid' = urlState.view === 'grid' ? 'grid' : 'table';
const setPage = (p: number) => setUrlState({ page: String(p) });
const setStatus = (v: string) => setUrlState({ status: v, page: '1' });
const setOwnerStatus = (v: string) => setUrlState({ owner: v, page: '1' });
const setSpecialty = (v: string) => setUrlState({ specialty: v, page: '1' });
const setProvinceId = (v: string) => setUrlState({ province: v, city: '', page: '1' });
const setCityId = (v: string) => setUrlState({ city: v, page: '1' });
const setView = (v: 'table' | 'grid') => setUrlState({ view: v });
const [limit] = useState(25);
// فیلد جستجو local می‌ماند (تایپ روان)؛ مقدارِ debounce‌شده به URL می‌رود.
const [searchInput, setSearchInput] = useState(urlState.search);
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
useEffect(() => {
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
return () => clearTimeout(t);
}, [searchInput]);
// ── Queries ──
const statsQ = useQuery({
queryKey: ['doctors-stats', isRepresentation],
queryFn: () => {
const url = isRepresentation
? '/api/v1/representation/doctors/stats'
: '/api/v1/admin/doctors/stats';
return api.get<ApiResponse<DoctorStats>>(url);
},
staleTime: 30_000,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-list'],
queryFn: () => api.get<ApiResponse<SpecialtyOption[]>>('/api/v1/specialties'),
staleTime: 300_000,
});
const provincesQ = useQuery({
queryKey: ['provinces-list'],
queryFn: () => api.get<ApiResponse<{ id: number; name: string }[]>>('/api/v1/provinces'),
staleTime: 600_000,
enabled: !isRepresentation,
});
const citiesQ = useQuery({
queryKey: ['cities-list', provinceId],
queryFn: () => api.get<ApiResponse<{ id: number; name: string }[]>>(`/api/v1/cities${provinceId ? `?province_id=${provinceId}` : ''}`),
staleTime: 600_000,
enabled: !isRepresentation && !!provinceId,
});
const doctorsQ = useQuery({
queryKey: ['admin-doctors', page, limit, search, status, ownerStatus, specialtyId, provinceId, cityId],
queryFn: () => {
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) p.set('search', search);
if (status) p.set('status', status);
if (ownerStatus && !isRepresentation) p.set('owner_status', ownerStatus);
if (specialtyId) p.set('specialty_id', specialtyId);
if (provinceId && !isRepresentation) p.set('state_id', provinceId);
if (cityId && !isRepresentation) p.set('city_id', cityId);
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
return api.get<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
},
});
const stats: DoctorStats | undefined = useMemo(
() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]
);
// هم endpoint ادمین و هم endpoint نماینده شکل AdminDoctor را برمی‌گردانند.
const items: AdminDoctor[] = doctorsQ.data?.data ?? [];
const total = doctorsQ.data?.meta?.totalRecords ?? 0;
const specialties: SpecialtyOption[] = useMemo(
() => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [],
[specialtiesQ.data]
);
const provinceOptions = useMemo(
() => ((provincesQ.data?.data as any)?.data ?? provincesQ.data?.data ?? []).map((p: { id: number; name: string }) => ({ value: String(p.id), label: p.name })),
[provincesQ.data]
);
const cityOptions = useMemo(
() => ((citiesQ.data?.data as any)?.data ?? citiesQ.data?.data ?? []).map((c: { id: number; name: string }) => ({ value: String(c.id), label: c.name })),
[citiesQ.data]
);
// ── Mutations ──
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/doctor/${uuid}`),
onSuccess: () => {
toast.success('پزشک حذف شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
qc.invalidateQueries({ queryKey: ['doctors-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: (uuid: string) => {
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
return api.post<ApiResponse<{ is_active: boolean }>>(`${base}/${uuid}/status`, {});
},
onSuccess: () => {
toast.success('وضعیت پزشک تغییر کرد');
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
qc.invalidateQueries({ queryKey: ['doctors-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const kpiCards = [
{ label: 'کل پزشکان', value: stats?.total, bg: 'var(--surface-3)', color: 'var(--text-2)' },
{ label: 'فعال', value: stats?.active, bg: 'var(--success-bg)', color: 'var(--success)' },
{ label: 'غیرفعال', value: stats?.inactive, bg: 'var(--surface-3)', color: 'var(--text-3)' },
{ label: 'مرد', value: stats?.male, bg: 'var(--info-bg)', color: 'var(--info)' },
{ label: 'زن', value: stats?.female, bg: 'var(--violet-bg)', color: 'var(--violet)' },
];
return (
<div className="fade-in">
{/* Header */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">پزشکان</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصص‌ها و وضعیت همکاری</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn ghost sm">
<ArrowPathIcon style={{ width: 15, height: 15 }} />
خروجی
</button>
<button className="btn primary sm" onClick={() => navigate('/admin/doctors/new')}>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن پزشک
</button>
</div>
</div>
{/* KPI cards */}
<div className="stat-grid">
{kpiCards.map((c) => (
<div key={c.label} className="stat">
<div className="ico" style={{ background: c.bg, color: c.color }}>
<svg style={{ width: 20, height: 20 }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div className="lbl">{c.label}</div>
<div className="val">
{c.value === undefined
? <span className="skeleton" style={{ display: 'inline-block', width: 48, height: 26, borderRadius: 4 }} />
: formatNumber(c.value)}
</div>
</div>
))}
</div>
{/* Main card */}
<div className="card">
{/* Toolbar */}
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="نام پزشک یا موبایل..."
/>
</div>
{specialties.length > 0 && (
<div style={{ minWidth: 180 }}>
<SearchableSelect
options={specialties.map(s => ({ value: String(s.id), label: s.name }))}
value={specialtyId || null}
onChange={(v) => setSpecialty(v ? String(v) : '')}
placeholder="همه تخصص‌ها"
isClearable
height={36}
/>
</div>
)}
{!isRepresentation && (
<div style={{ minWidth: 170 }}>
<SearchableSelect
options={provinceOptions}
value={provinceId || null}
onChange={(v) => { setProvinceId(v ? String(v) : ''); setCityId(''); }}
placeholder="همه استان‌ها"
isClearable
height={36}
/>
</div>
)}
{!isRepresentation && provinceId && (
<div style={{ minWidth: 170 }}>
<SearchableSelect
options={cityOptions}
value={cityId || null}
onChange={(v) => setCityId(v ? String(v) : '')}
placeholder="همه شهرها"
isClearable
height={36}
/>
</div>
)}
<div className="seg">
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
<button className={status === 'active' ? 'on' : ''} onClick={() => setStatus('active')}>فعال</button>
<button className={status === 'inactive' ? 'on' : ''} onClick={() => setStatus('inactive')}>غیرفعال</button>
</div>
{!isRepresentation && (
<div className="seg" title="مالکیت پروفایل (ایمپورت نظام پزشکی)">
<button className={!ownerStatus ? 'on' : ''} onClick={() => setOwnerStatus('')}>همه</button>
<button className={ownerStatus === 'unclaimed' ? 'on' : ''} onClick={() => setOwnerStatus('unclaimed')}>بدون‌مالک</button>
<button className={ownerStatus === 'claimed' ? 'on' : ''} onClick={() => setOwnerStatus('claimed')}>تصاحب‌شده</button>
</div>
)}
<div className="spacer" />
<div className="seg">
<button className={view === 'table' ? 'on' : ''} onClick={() => setView('table')} title="جدول">
<TableCellsIcon style={{ width: 16, height: 16 }} />
</button>
<button className={view === 'grid' ? 'on' : ''} onClick={() => setView('grid')} title="کارت">
<Squares2X2Icon style={{ width: 16, height: 16 }} />
</button>
</div>
<button className="btn ghost sm" onClick={() => doctorsQ.refetch()} disabled={doctorsQ.isFetching}>
<ArrowPathIcon style={{ width: 15, height: 15, animation: doctorsQ.isFetching ? 'spin 1s linear infinite' : undefined }} />
</button>
</div>
</div>
{/* Table view */}
{view === 'table' && (
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>پزشک</th>
<th>تخصص</th>
<th>درجه</th>
<th>امتیاز</th>
<th>موبایل</th>
{!isRepresentation && <th>نماینده</th>}
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
{doctorsQ.isLoading && Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<td key={j}>
<div className="skeleton" style={{ height: 14, borderRadius: 6, width: j === 1 ? '70%' : '55%' }} />
</td>
))}
</tr>
))}
{!doctorsQ.isLoading && items.length === 0 && (
<tr>
<td colSpan={7}>
<div className="empty">هیچ پزشکی یافت نشد</div>
</td>
</tr>
)}
{!doctorsQ.isLoading && items.map((doc) => (
<tr key={doc.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
<td>
<div className="cell-user">
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
<div>
<b>{displayDoctorName(doc.name)}</b>
<br /><small>{doc.gender ?? '—'}</small>
</div>
</div>
</td>
<td>
{doc.specialties.slice(0, 2).map((s) => (
<span key={s.id} className="badge gray" style={{ marginInlineEnd: 4, fontSize: 11 }}>{s.name}</span>
))}
{doc.specialties.length > 2 && (
<span className="muted" style={{ fontSize: 11 }}>+{doc.specialties.length - 2}</span>
)}
</td>
<td>
{doc.degree && (
<span className={`badge ${DEGREE_BADGE[doc.degree] ?? 'gray'}`}>
<span className="bdot" />
{DEGREE_LABEL[doc.degree] ?? doc.degree}
</span>
)}
</td>
<td><b style={{ fontSize: 12.5 }}>{(doc.rate ?? 0).toFixed(1)}</b></td>
<td style={{ fontFamily: 'monospace', direction: 'ltr', textAlign: 'right' }} className="muted">
{doc.mobile ?? '—'}
</td>
{!isRepresentation && (
<td>
{doc.representation_id ? (
<span
className="badge blue"
style={{ cursor: doc.representation_uuid ? 'pointer' : 'default', fontSize: 11 }}
onClick={(e) => { e.stopPropagation(); if (doc.representation_uuid) navigate(`/admin/representations/${doc.representation_uuid}`); }}
>
{doc.representation_name ?? 'نماینده'}
</span>
) : (
<span className="muted" style={{ fontSize: 11 }}>بدون نماینده</span>
)}
</td>
)}
<td>
{doc.owner_status === 'unclaimed' && <span className="badge amber" style={{ marginLeft: 6 }}>بدون‌مالک</span>}
{doc.owner_status === 'pending_transfer' && <span className="badge violet" style={{ marginLeft: 6 }}>در انتظار انتقال</span>}
<span className={`badge ${doc.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />
{doc.is_active ? 'فعال' : 'غیرفعال'}
</span>
</td>
<td onClick={(e) => e.stopPropagation()}>
<div className="row-actions">
<button className="mini-btn" title="مشاهده"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
<EyeIcon style={{ width: 16, height: 16 }} />
</button>
<button className="mini-btn" title="تغییر شماره ورود"
onClick={() => setMobileTarget({ uuid: doc.uuid, name: doc.name, mobile_number: doc.mobile })}>
<DevicePhoneMobileIcon style={{ width: 16, height: 16 }} />
</button>
<button className="mini-btn" title={doc.is_active ? 'غیرفعال کردن' : 'فعال‌سازی'}
onClick={() => toggleMut.mutate(doc.uuid)} disabled={toggleMut.isPending}>
{doc.is_active
? <XCircleIcon style={{ width: 16, height: 16 }} />
: <CheckCircleIcon style={{ width: 16, height: 16 }} />}
</button>
<button className="mini-btn danger" title="حذف" onClick={() => setDeleteTarget(doc)}>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Grid view */}
{view === 'grid' && (
<div className="card-pad" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 'var(--gap)',
}}>
{doctorsQ.isLoading && Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="card card-pad" style={{ boxShadow: 'none' }}>
<div className="flex" style={{ gap: 12, marginBottom: 12 }}>
<div className="skeleton" style={{ width: 38, height: 38, borderRadius: '50%', flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<div className="skeleton" style={{ height: 14, borderRadius: 4, width: '70%', marginBottom: 8 }} />
<div className="skeleton" style={{ height: 12, borderRadius: 4, width: '50%' }} />
</div>
</div>
</div>
))}
{!doctorsQ.isLoading && items.map((doc) => (
<div
key={doc.uuid}
className="card card-pad"
style={{ cursor: 'pointer', boxShadow: 'none' }}
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<div className="flex" style={{ gap: 12, marginBottom: 12 }}>
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{displayDoctorName(doc.name)}
</b>
<span className="muted" style={{ fontSize: 12.5 }}>
{doc.specialties[0]?.name ?? '—'}
</span>
</div>
<span className={`badge ${doc.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />
{doc.is_active ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div className="flex" style={{ justifyContent: 'space-between', fontSize: 12.5 }}>
<b>{(doc.rate ?? 0).toFixed(1)}</b>
<span className="muted">{formatDate(doc.created_at)}</span>
</div>
</div>
))}
{!doctorsQ.isLoading && items.length === 0 && (
<div className="empty" style={{ gridColumn: '1/-1' }}>هیچ پزشکی یافت نشد</div>
)}
</div>
)}
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
{/* Dialogs */}
<ConfirmDialog
open={!!deleteTarget}
title="حذف پزشک"
message={`آیا از حذف دکتر "${deleteTarget?.name}" اطمینان دارید؟`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
onCancel={() => setDeleteTarget(null)}
/>
<ChangeLoginMobileModal
target={mobileTarget}
resource="doctors"
queryKey={['doctors']}
onClose={() => setMobileTarget(null)}
/>
</div>
);
}