- 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.
513 lines
21 KiB
TypeScript
513 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|