- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL. - Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages. - Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values. - Add SlotPicker component for selecting appointment slots based on availability. - Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL. - Update API documentation to reflect changes in appointment creation and slot selection processes.
548 lines
24 KiB
TypeScript
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-dom';
|
|
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>
|
|
);
|
|
}
|