Files
clinicpro/assets/admin/pages/RepresentationDetailPage.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

513 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, UserGroupIcon, CalendarDaysIcon, ClockIcon, ReceiptPercentIcon, PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Representation, City } from '../types';
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface RepDoctor {
uuid: string;
id: number;
name: string;
gender: string | null;
medical_code: string | null;
is_active: boolean;
owner_status?: string;
past_count: number;
upcoming_count: number;
created_at: string;
}
interface RepAppointment {
uuid: string;
slot_start: number;
slot_end: number;
status: string;
patient_name: string | null;
doctor_uuid: string;
doctor_name: string;
}
interface UnassignedDoctor {
uuid: string;
name: string;
medical_code: string | null;
mobile: string | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="cp-info-row items-start gap-4">
<span className="text-sm text-[var(--text-2)] w-40 shrink-0">{label}</span>
<span className="cp-info-value">{value ?? '—'}</span>
</div>
);
}
function StatTile({ icon: Icon, label, value, tone }: {
icon: React.ComponentType<{ className?: string }>;
label: string; value: React.ReactNode; tone: 'violet' | 'green' | 'amber' | 'pink';
}) {
return (
<div className="cp-stat">
<div className="cp-stat-icon" style={{ background: `var(--stat-${tone}-bg)`, color: `var(--stat-${tone}-fg)` }}>
<Icon className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="text-xs text-[var(--text-3)] mb-1">{label}</div>
<div className="text-xl font-bold text-[var(--text)] leading-none">{value}</div>
</div>
</div>
);
}
export default function RepresentationDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [formData, setFormData] = useState({ full_name: '', city_id: '', mobile_number: '', commission_percent: '' });
const [attachOpen, setAttachOpen] = useState(false);
const [attachSearch, setAttachSearch] = useState('');
const [apptScope, setApptScope] = useState<'upcoming' | 'past'>('upcoming');
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: City[] = citiesQuery.data?.data ?? [];
const { data, isLoading } = useQuery({
queryKey: ['representation', uuid],
queryFn: () => api.get<ApiResponse<{ data: Representation }>>(`/api/v1/representation/${uuid}`),
enabled: !!uuid,
});
const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data;
const doctorsQuery = useQuery({
queryKey: ['representation-doctors', uuid],
queryFn: () => api.get<PaginatedResponse<RepDoctor>>(`/api/v1/admin/representations/${uuid}/doctors?limit=100`),
enabled: !!uuid,
});
const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? [];
const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length;
const upcomingTotal = repDoctors.reduce((s, d) => s + (d.upcoming_count ?? 0), 0);
const pastTotal = repDoctors.reduce((s, d) => s + (d.past_count ?? 0), 0);
const appointmentsQuery = useQuery({
queryKey: ['representation-appointments', uuid, apptScope],
queryFn: () => api.get<PaginatedResponse<RepAppointment>>(`/api/v1/admin/representations/${uuid}/appointments?scope=${apptScope}&limit=50`),
enabled: !!uuid,
});
const repAppointments: RepAppointment[] = appointmentsQuery.data?.data ?? [];
const repAppointmentsTotal = appointmentsQuery.data?.meta?.totalRecords ?? repAppointments.length;
const unassignedQuery = useQuery({
queryKey: ['unassigned-doctors', attachSearch],
queryFn: () => api.get<PaginatedResponse<UnassignedDoctor>>(`/api/v1/admin/doctors?unassigned=1&limit=20${attachSearch ? `&search=${encodeURIComponent(attachSearch)}` : ''}`),
enabled: attachOpen,
});
const unassignedDoctors: UnassignedDoctor[] = unassignedQuery.data?.data ?? [];
const attachMutation = useMutation({
mutationFn: (doctorUuid: string) =>
api.post<ApiResponse<unknown>>(`/api/v1/admin/representations/${uuid}/doctors`, { doctor_uuid: doctorUuid }),
onSuccess: () => {
toast.success('پزشک به نماینده متصل شد');
setAttachOpen(false);
setAttachSearch('');
qc.invalidateQueries({ queryKey: ['representation-doctors', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: (d: Partial<Representation>) =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, d),
onSuccess: () => {
toast.success('اطلاعات بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['representation', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const toggleActiveMutation = useMutation({
mutationFn: () =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, {
active: !(rep?.active ?? rep?.is_active),
}),
onSuccess: () => {
toast.success('وضعیت تغییر کرد');
qc.invalidateQueries({ queryKey: ['representation', uuid] });
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/representation/${uuid}`),
onSuccess: () => {
toast.success('نماینده حذف شد');
navigate('/admin/representations');
},
onError: (err: Error) => toast.error(err.message),
});
const openEdit = () => {
if (!rep) return;
setFormData({
full_name: rep.full_name ?? rep.domain ?? '',
city_id: rep.city_id ? String(rep.city_id) : '',
mobile_number: rep.mobile_number ?? '',
commission_percent: String(rep.commission_percent),
});
setEditOpen(true);
};
const isActive = rep?.active ?? rep?.is_active ?? false;
if (isLoading) {
return (
<div>
<PageHeader
backTo="/admin/representations"
title="جزئیات نماینده"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: 'جزئیات' },
]}
/>
<div className="bg-[var(--surface)] rounded-2xl shadow-sm border border-[var(--border)] p-6 animate-pulse">
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 bg-[var(--surface-2)] rounded" />
))}
</div>
</div>
</div>
);
}
if (!rep) {
return (
<div>
<PageHeader
backTo="/admin/representations"
title="نماینده یافت نشد"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
]}
/>
<div className="cp-card p-12 text-center text-[var(--text-3)]">
نماینده‌ای با این شناسه یافت نشد.
</div>
</div>
);
}
const name = rep.full_name ?? rep.domain ?? '—';
return (
<div>
<PageHeader
backTo="/admin/representations"
title={name}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: name },
]}
action={
<div className="flex items-center gap-2">
<button
onClick={() => toggleActiveMutation.mutate()}
disabled={toggleActiveMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border transition-colors disabled:opacity-50 ${
isActive
? 'border-[var(--danger)] text-[var(--danger)] hover:bg-[var(--danger-bg)]'
: 'border-[var(--success)] text-[var(--success)] hover:bg-[var(--success-bg)]'
}`}
>
{isActive
? <><XCircleIcon className="w-4 h-4" /> غیرفعال کردن</>
: <><CheckCircleIcon className="w-4 h-4" /> فعال کردن</>
}
</button>
<button
onClick={openEdit}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-[var(--border-2)] text-[var(--text)] hover:bg-[var(--surface-2)] transition-colors"
>
<PencilIcon className="w-4 h-4" />
ویرایش
</button>
<button
onClick={() => setDeleteOpen(true)}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-[var(--danger)] text-[var(--danger)] hover:bg-[var(--danger-bg)] transition-colors"
>
<TrashIcon className="w-4 h-4" />
حذف
</button>
</div>
}
/>
{/* KPI tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatTile icon={UserGroupIcon} tone="violet" label="تعداد پزشکان" value={formatNumber(repDoctorsTotal)} />
<StatTile icon={CalendarDaysIcon} tone="green" label="نوبت آینده" value={formatNumber(upcomingTotal)} />
<StatTile icon={ClockIcon} tone="amber" label="نوبت گذشته" value={formatNumber(pastTotal)} />
<StatTile icon={ReceiptPercentIcon} tone="pink" label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">اطلاعات پایه</h2>
<DetailRow label="نام کامل" value={rep.full_name} />
<DetailRow label="موبایل" value={
rep.mobile_number
? <span dir="ltr">{rep.mobile_number}</span>
: null
} />
<DetailRow label="شهر" value={rep.city ?? (rep.city_id ? `شناسه ${rep.city_id}` : null)} />
<DetailRow label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
<DetailRow label="وضعیت" value={<ActiveBadge active={isActive} />} />
<DetailRow label="تاریخ ثبت" value={formatDate(rep.created_at)} />
</div>
{/* Bank Account */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">اطلاعات بانکی</h2>
{rep.bank_account ? (
<>
<DetailRow label="شماره کارت" value={
rep.bank_account.card
? <span dir="ltr" className="font-mono tracking-widest">{rep.bank_account.card}</span>
: null
} />
<DetailRow label="نام بانک" value={rep.bank_account.bank_name} />
<DetailRow label="شماره شبا" value={
rep.bank_account.iban
? <span dir="ltr" className="font-mono text-xs">{rep.bank_account.iban}</span>
: null
} />
</>
) : (
<p className="text-sm text-[var(--text-3)] text-center py-6">اطلاعات بانکی ثبت نشده</p>
)}
</div>
</div>
{/* Doctors of this representation */}
<div className="cp-card p-6 mt-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<UserGroupIcon className="w-5 h-5 text-[var(--text-3)]" />
<h2 className="text-sm font-semibold text-[var(--text)]">پزشکان این نماینده</h2>
<span className="badge violet">{formatNumber(repDoctorsTotal)}</span>
</div>
<button className="btn primary sm inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
<PlusIcon className="w-4 h-4" /> افزودن پزشک
</button>
</div>
{doctorsQuery.isLoading ? (
<p className="text-sm text-[var(--text-3)] text-center py-8">در حال بارگذاری…</p>
) : repDoctors.length === 0 ? (
<div className="text-center py-10">
<UserGroupIcon className="w-10 h-10 mx-auto text-[var(--text-3)] mb-2" />
<p className="text-sm text-[var(--text-3)]">این نماینده هنوز پزشکی ندارد</p>
<button className="btn primary sm mt-3 inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
<PlusIcon className="w-4 h-4" /> افزودن اولین پزشک
</button>
</div>
) : (
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>نام پزشک</th>
<th>کد نظام</th>
<th>نوبت گذشته</th>
<th>نوبت آینده</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repDoctors.map((d) => (
<tr key={d.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${d.uuid}`)}>
<td><b>{d.name}</b></td>
<td style={{ fontFamily: 'monospace' }} className="text-[var(--text-3)]"><span dir="ltr">{d.medical_code ?? '—'}</span></td>
<td>{formatNumber(d.past_count)}</td>
<td>{d.upcoming_count > 0 ? <span className="badge green">{formatNumber(d.upcoming_count)}</span> : <span className="text-[var(--text-3)]">۰</span>}</td>
<td><ActiveBadge active={d.is_active} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Appointments of this representation */}
<div className="cp-card p-6 mt-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<CalendarDaysIcon className="w-5 h-5 text-[var(--text-3)]" />
<h2 className="text-sm font-semibold text-[var(--text)]">نوبت‌های این نماینده</h2>
{!appointmentsQuery.isLoading && <span className="badge blue">{formatNumber(repAppointmentsTotal)}</span>}
</div>
<div className="seg">
<button className={apptScope === 'upcoming' ? 'on' : ''} onClick={() => setApptScope('upcoming')}>آینده</button>
<button className={apptScope === 'past' ? 'on' : ''} onClick={() => setApptScope('past')}>گذشته</button>
</div>
</div>
{appointmentsQuery.isLoading ? (
<p className="text-sm text-[var(--text-3)] text-center py-8">در حال بارگذاری…</p>
) : repAppointments.length === 0 ? (
<div className="text-center py-10">
<CalendarDaysIcon className="w-10 h-10 mx-auto text-[var(--text-3)] mb-2" />
<p className="text-sm text-[var(--text-3)]">
{apptScope === 'upcoming' ? 'نوبت آینده‌ای ثبت نشده است' : 'نوبت گذشته‌ای وجود ندارد'}
</p>
</div>
) : (
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>پزشک</th>
<th>بیمار</th>
<th>زمان</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repAppointments.map((a) => (
<tr key={a.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${a.doctor_uuid}`)}>
<td><b>{a.doctor_name}</b></td>
<td>{a.patient_name ?? '—'}</td>
<td><span dir="ltr">{formatDateTime(a.slot_start)}</span></td>
<td><span className="badge gray">{a.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Attach Doctor Modal */}
<Modal open={attachOpen} title="افزودن پزشک به نماینده"
onClose={() => { setAttachOpen(false); setAttachSearch(''); }}
footer={<button onClick={() => { setAttachOpen(false); setAttachSearch(''); }} className="btn ghost sm">بستن</button>}
>
<input
type="text"
className="input w-full mb-3"
placeholder="جستجوی پزشک (نام یا موبایل)…"
value={attachSearch}
onChange={(e) => setAttachSearch(e.target.value)}
/>
{unassignedQuery.isLoading ? (
<p className="text-sm text-[var(--text-3)] text-center py-6">در حال بارگذاری…</p>
) : unassignedDoctors.length === 0 ? (
<p className="text-sm text-[var(--text-3)] text-center py-6">پزشکِ بدون نماینده‌ای یافت نشد</p>
) : (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{unassignedDoctors.map((d) => (
<div key={d.uuid} className="flex items-center justify-between py-2 border-b border-[var(--border)]">
<div>
<b className="text-sm">{d.name}</b>
<div className="text-xs text-[var(--text-3)]" dir="ltr">{d.medical_code ?? d.mobile ?? '—'}</div>
</div>
<button className="btn primary sm" disabled={attachMutation.isPending}
onClick={() => attachMutation.mutate(d.uuid)}>افزودن</button>
</div>
))}
</div>
)}
</Modal>
{/* Edit Modal */}
<Modal open={editOpen} title="ویرایش نماینده"
onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)}
className="btn ghost sm">
لغو
</button>
<button
onClick={() => updateMutation.mutate({
full_name: formData.full_name,
city_id: formData.city_id ? parseInt(formData.city_id) : null,
mobile_number: formData.mobile_number || null,
commission_percent: parseFloat(formData.commission_percent) || rep.commission_percent,
} as any)}
disabled={updateMutation.isPending}
className="btn primary sm">
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="">نام کامل</label>
<input value={formData.full_name} onChange={(e) => setFormData((p) => ({ ...p, full_name: e.target.value }))}
className="cp-input h-11" />
</div>
<div>
<label className="">شهر</label>
<SearchableSelect
options={cities.map(c => ({ value: String(c.id), label: c.name }))}
value={formData.city_id || null}
onChange={(v) => setFormData(p => ({ ...p, city_id: v ? String(v) : '' }))}
placeholder="انتخاب شهر"
isClearable
/>
</div>
<div>
<label className="">موبایل</label>
<input value={formData.mobile_number} onChange={(e) => setFormData((p) => ({ ...p, mobile_number: e.target.value }))}
dir="ltr" className="cp-input h-11" />
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))}
type="text" inputMode="numeric" dir="ltr"
className="cp-input h-11" />
</div>
</div>
</Modal>
<ConfirmDialog
open={deleteOpen}
title="حذف نماینده"
message={`آیا از حذف نماینده "${name}" اطمینان دارید؟`}
confirmLabel="حذف"
danger
loading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}