Files
clinicpro/assets/admin/pages/ClinicDetailPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

1108 lines
53 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, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowRightIcon, PencilIcon, TrashIcon,
BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
PlusIcon, CameraIcon, ChevronDownIcon,
} from '@heroicons/react/24/outline';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
const HUES_LIST = [256, 205, 162, 295, 272];
const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
const MAX_GALLERY_IMAGES = 5;
// ── Types ──────────────────────────────────────────────────────────────────
interface ClinicAddress {
id: string; uuid: string;
name: string | null; address: string | null;
telephone: string | null;
map: { latitude: string | null; longitude: string | null };
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
}
interface Opt { id: number; name: string; }
interface OptUuid { id: number; uuid: string; name: string; }
// ── Edit form schema ───────────────────────────────────────────────────────
const urlOrEmpty = z.string().refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'آدرس URL معتبر نیست' });
const editSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
telephone: z.string().optional(),
info: z.string().optional(),
is_247: z.boolean(),
specialties: z.array(z.number()),
insurance: z.array(z.number()),
doctor_services: z.array(z.number()),
sm_instagram: urlOrEmpty,
sm_telegram: urlOrEmpty,
sm_aparat: urlOrEmpty,
sm_youtube: urlOrEmpty,
sm_linkedin: urlOrEmpty,
});
type EditForm = z.infer<typeof editSchema>;
// ── Searchable Select (template CSS) ──────────────────────────────────────
function SearchableSelectField({ options, value, onChange, placeholder, disabled = false }: {
options: Opt[];
value: number | null;
onChange: (v: number | null, label?: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(
() => q ? options.filter(o => o.name.includes(q)) : options,
[options, q],
);
const selected = useMemo(() => options.find(o => o.id === value) ?? null, [options, value]);
const openDD = () => {
if (disabled || !btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
setQ('');
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 }
: {};
return (
<div>
<button ref={btnRef} type="button" disabled={disabled} onClick={openDD}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: disabled ? 'var(--surface-2, var(--bg))' : 'var(--surface)',
color: selected ? 'var(--text)' : 'var(--text-3)', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 14, opacity: disabled ? 0.6 : 1,
}}>
<span>{selected?.name ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon style={{ width: 14, height: 14, flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
</button>
{open && createPortal(
<div ref={dropRef} style={{
...dropStyle,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,.12)', overflow: 'hidden',
}}>
<div style={{ padding: '8px 8px 0' }}>
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="جستجو..."
className="input" style={{ fontSize: 13 }} />
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', padding: '4px 0' }}>
<button type="button"
onClick={() => { onChange(null); setOpen(false); }}
style={{ width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13, color: 'var(--text-3)', background: 'none', border: 'none', cursor: 'pointer' }}>
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.id} type="button"
onClick={() => { onChange(o.id, o.name); setOpen(false); setQ(''); }}
style={{
width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13,
background: value === o.id ? 'var(--primary-light, oklch(0.95 0.04 256))' : 'none',
color: value === o.id ? 'var(--primary)' : 'var(--text)', border: 'none', cursor: 'pointer',
}}>
{o.name}
</button>
))}
{filtered.length === 0 && (
<p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجه‌ای یافت نشد</p>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
// ── Multi-select checkbox list ─────────────────────────────────────────────
function MultiCheckList({ options, selected, onChange, placeholder }: {
options: Opt[]; selected: number[]; onChange: (v: number[]) => void; placeholder?: string;
}) {
const [q, setQ] = useState('');
const filtered = useMemo(() => q ? options.filter(o => o.name.includes(q)) : options, [options, q]);
const toggle = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2, var(--bg))' }}>
<input value={q} onChange={e => setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'}
className="input" style={{ fontSize: 13 }} />
</div>
{selected.length > 0 && (
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--border)', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selected.map(id => {
const opt = options.find(o => o.id === id);
if (!opt) return null;
return (
<span key={id} className="badge violet" style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggle(id)}>
<span className="bdot" />{opt.name} ×
</span>
);
})}
</div>
)}
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{filtered.length === 0
? <p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجه‌ای یافت نشد</p>
: filtered.map(o => (
<label key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px', cursor: 'pointer' }}>
<input type="checkbox" checked={selected.includes(o.id)} onChange={() => toggle(o.id)} />
<span style={{ fontSize: 13, color: 'var(--text)' }}>{o.name}</span>
</label>
))}
</div>
</div>
);
}
// ── Leaflet helpers ────────────────────────────────────────────────────────
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
return null;
}
function MapFlyController({ target }: { target: [number, number] | null }) {
const map = useMap();
useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
return null;
}
function MapPicker({ lat, lng, onChange, flyTarget, initialCenter }: {
lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null;
initialCenter?: [number, number] | null;
}) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = initialCenter ?? pos ?? IRAN_CENTER;
const zoom = initialCenter ?? pos ? 13 : 5;
return (
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer key={`${center[0]},${center[1]}`} center={center} zoom={zoom} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<MapClickHandler onPick={onChange} />
<MapFlyController target={flyTarget ?? null} />
{pos && <Marker position={pos} />}
</MapContainer>
</div>
);
}
// ── Geocode helper ─────────────────────────────────────────────────────────
async function geocodeCity(name: string): Promise<[number, number] | null> {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name + ',ایران')}&format=json&countrycodes=ir&limit=1`;
const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } });
const d = await res.json();
if (d?.[0]) return [parseFloat(d[0].lat), parseFloat(d[0].lon)];
return null;
} catch { return null; }
}
// ── Edit Modal ─────────────────────────────────────────────────────────────
function EditModal({ clinic, onClose, onSaved }: {
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
}) {
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: {
name: clinic.name ?? '',
telephone: clinic.phone ?? clinic.phone_number ?? '',
info: clinic.caption ?? '',
is_247: clinic['24_7'] ?? false,
specialties: (clinic.specialties ?? []).map(s => Number(s.id)),
insurance: (clinic.list_bime ?? []).map(s => Number(s.id)),
doctor_services: (clinic.services ?? []).map(s => Number(s.id)),
sm_instagram: clinic.social_media?.instagram ?? '',
sm_telegram: clinic.social_media?.telegram ?? '',
sm_aparat: clinic.social_media?.aparat ?? '',
sm_youtube: clinic.social_media?.youtube ?? '',
sm_linkedin: clinic.social_media?.linkedin ?? '',
},
});
const watchedSpec = (watch('specialties') ?? []) as number[];
const watchedIns = (watch('insurance') ?? []) as number[];
const watchedSrv = (watch('doctor_services') ?? []) as number[];
const specialtiesQ = useQuery({
queryKey: ['specialties-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'),
});
const insurancesQ = useQuery({
queryKey: ['insurances-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/insurances'),
});
const servicesQ = useQuery({
queryKey: ['doctor-services-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/doctor-services'),
});
const specialties: Opt[] = useMemo(() => {
const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [specialtiesQ.data]);
const insurances: Opt[] = useMemo(() => {
const raw = insurancesQ.data?.data?.data ?? insurancesQ.data?.data ?? [];
return raw.map((i: any) => ({ id: Number(i.id), name: i.name }));
}, [insurancesQ.data]);
const services: Opt[] = useMemo(() => {
const raw = servicesQ.data?.data?.data ?? servicesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [servicesQ.data]);
const saveMut = useMutation({
mutationFn: (values: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/clinic/${clinic.uuid}`, {
name: values.name,
telephone: values.telephone || undefined,
info: values.info || undefined,
'24_7': values.is_247,
specialties: values.specialties,
insurance: values.insurance,
doctor_services: values.doctor_services,
social_media: {
instagram: values.sm_instagram || null,
telegram: values.sm_telegram || null,
aparat: values.sm_aparat || null,
youtube: values.sm_youtube || null,
linkedin: values.sm_linkedin || null,
},
}),
onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
{/* Tab switcher */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
{([['basic', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه'], ['social', 'شبکه‌های اجتماعی']] as const).map(([id, label]) => (
<button key={id} type="button" onClick={() => setActiveTab(id)}
style={{
padding: '10px 14px', fontSize: 13, fontWeight: 600, border: 'none', background: 'none',
cursor: 'pointer', borderBottom: activeTab === id ? '2px solid var(--primary)' : '2px solid transparent',
color: activeTab === id ? 'var(--primary)' : 'var(--text-3)', marginBottom: -1,
}}>
{label}
</button>
))}
</div>
<form onSubmit={handleSubmit(v => saveMut.mutate(v))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* ── Basic tab ── */}
{activeTab === 'basic' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام کلینیک</label>
<input className="input" {...register('name')} />
{errors.name && <div className="err-text">{errors.name.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" placeholder="021-12345678" {...latinDigitsField(register('telephone'))} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
<textarea className="input" rows={3} style={{ resize: 'vertical' }} {...register('info')} />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" {...register('is_247')} />
<span style={{ fontSize: 13, fontWeight: 600 }}>کلینیک ۲۴ ساعته (۷ روز هفته)</span>
</label>
</>
)}
{/* ── Social tab ── */}
{activeTab === 'social' && (
<>
{([
['sm_instagram', 'اینستاگرام', 'https://instagram.com/...'],
['sm_telegram', 'تلگرام', 'https://t.me/...'],
['sm_aparat', 'آپارات', 'https://aparat.com/...'],
['sm_youtube', 'یوتیوب', 'https://youtube.com/...'],
['sm_linkedin', 'لینکدین', 'https://linkedin.com/...'],
] as const).map(([field, label, placeholder]) => (
<div key={field}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>{label}</label>
<input className="input" dir="ltr" placeholder={placeholder} {...register(field)} />
{errors[field] && <div className="err-text">{errors[field]?.message}</div>}
</div>
))}
</>
)}
{/* ── Tags tab ── */}
{activeTab === 'tags' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
تخصص‌ها ({watchedSpec.length} انتخاب‌شده)
</label>
<MultiCheckList options={specialties} selected={watchedSpec}
onChange={v => setValue('specialties', v)} placeholder="جستجوی تخصص..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
بیمه‌ها ({watchedIns.length} انتخاب‌شده)
</label>
<MultiCheckList options={insurances} selected={watchedIns}
onChange={v => setValue('insurance', v)} placeholder="جستجوی بیمه..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
خدمات ({watchedSrv.length} انتخاب‌شده)
</label>
<MultiCheckList options={services} selected={watchedSrv}
onChange={v => setValue('doctor_services', v)} placeholder="جستجوی خدمت..." />
</div>
</>
)}
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={saveMut.isPending}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const authToken = useAuthStore(s => s.token);
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const [logoUploading, setLogoUploading] = useState(false);
const [galleryUploading, setGalleryUploading] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['clinic-detail', uuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDetail }>>(`/api/v1/clinic/${uuid}`),
enabled: !!uuid,
});
const clinicAddressesQ = useQuery({
queryKey: ['clinic-addresses', uuid],
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
enabled: !!uuid,
});
const clinicAddresses: ClinicAddress[] = (clinicAddressesQ.data?.data as any)?.data ?? clinicAddressesQ.data?.data ?? [];
const [addrFormOpen, setAddrFormOpen] = useState(false);
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
const [deleteImageConfirm, setDeleteImageConfirm] = useState<string | null>(null);
const emptyAddrForm = { name: '', address: '', telephone: '', province_id: null as number | null, city_id: null as number | null, latitude: null as number | null, longitude: null as number | null };
const [addrForm, setAddrForm] = useState(emptyAddrForm);
const [addrMapFlyTarget, setAddrMapFlyTarget] = useState<[number, number] | null>(null);
const provincesQ = useQuery({
queryKey: ['provinces'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
});
const citiesQ = useQuery({
queryKey: ['cities', addrForm.province_id], staleTime: 300_000,
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${addrForm.province_id}`),
enabled: !!addrForm.province_id,
});
const provinces: Opt[] = useMemo(() => (provincesQ.data?.data?.data ?? provincesQ.data?.data ?? []).map((p: any) => ({ id: Number(p.id ?? p.nid), name: p.name })), [provincesQ.data]);
const addrCities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]);
const openAddrForm = (addr: ClinicAddress | null) => {
setEditingClinicAddr(addr);
const lat = addr?.map?.latitude ? parseFloat(addr.map.latitude) : null;
const lng = addr?.map?.longitude ? parseFloat(addr.map.longitude) : null;
setAddrForm(addr ? {
name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '',
province_id: addr.province ? Number(addr.province.id) : null,
city_id: addr.city ? Number(addr.city.id) : null,
latitude: lat, longitude: lng,
} : emptyAddrForm);
setAddrMapFlyTarget(lat && lng ? [lat, lng] : null);
setAddrFormOpen(true);
};
const saveAddrMutation = useMutation({
mutationFn: (payload: typeof addrForm) => {
if (editingClinicAddr) {
return api.patch<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address/${editingClinicAddr.uuid}`, payload);
}
return api.post<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address`, payload);
},
onSuccess: () => {
toast.success(editingClinicAddr ? 'آدرس ویرایش شد' : 'آدرس اضافه شد');
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
setAddrFormOpen(false);
setEditingClinicAddr(null);
setAddrForm(emptyAddrForm);
},
onError: () => toast.error('خطا در ذخیره آدرس'),
});
const deleteAddrMutation = useMutation({
mutationFn: (addrUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${uuid}/address/${addrUuid}`),
onSuccess: () => {
toast.success('آدرس حذف شد');
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
setDeleteAddrConfirm(null);
},
onError: (err: any) => toast.error(err?.message ?? 'خطا در حذف آدرس'),
});
const clinic: ClinicDetail | undefined = useMemo(() => {
const raw = data?.data;
return (raw as any)?.data ?? raw;
}, [data]);
const openEdit = () => setEditOpen(true);
const toggleMut = useMutation({
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
onSuccess: () => {
toast.success('وضعیت کلینیک تغییر کرد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteMut = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/${uuid}`),
onSuccess: () => { toast.success('کلینیک حذف شد'); navigate('/admin/clinics'); },
onError: (e: Error) => toast.error(e.message),
});
const handleLogoUpload = async (file: File) => {
setLogoUploading(true);
try {
const res = await fetch('/file/upload/clinic_pro/clinic/field_clinic_logo', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${authToken ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (url) {
await api.patch(`/api/v1/clinic/${uuid}`, { clinic_logo: url });
toast.success('لوگو آپلود شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setLogoUploading(false); }
};
const handleGalleryUpload = async (files: FileList) => {
const existing = (clinic?.images_clinic ?? []).filter(img => img?.url);
const remaining = MAX_GALLERY_IMAGES - existing.length;
if (remaining <= 0) {
toast.error(`گالری حداکثر ${MAX_GALLERY_IMAGES} عکس می‌تواند داشته باشد`);
return;
}
const selected = Array.from(files);
if (selected.length > remaining) {
toast.error(`فقط ${remaining} عکس دیگر می‌توانید اضافه کنید (حداکثر ${MAX_GALLERY_IMAGES})`);
}
const toUpload = selected.slice(0, remaining);
setGalleryUploading(true);
try {
const uploaded: { url: string }[] = [];
for (const file of toUpload) {
const res = await fetch('/file/upload/clinic_pro/clinic/field_image_clinic', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${authToken ?? ''}`,
},
body: file,
});
const json = await res.json();
if (json?.data?.url) uploaded.push({ url: json.data.url });
}
if (uploaded.length > 0 && clinic) {
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, ...uploaded] });
toast.success(`${uploaded.length} تصویر اضافه شد`);
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setGalleryUploading(false); }
};
const handleGalleryRemove = async (url: string) => {
if (!clinic) return;
try {
const remaining = (clinic.images_clinic ?? []).filter(img => img?.url && img.url !== url);
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: remaining });
toast.success('تصویر حذف شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
} catch (e: any) { toast.error(e?.message ?? 'خطا در حذف تصویر'); }
};
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const logo = clinic?.logo ?? clinic?.clinic_logo;
// شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity
const primaryAddr = clinicAddresses[0] ?? null;
const cityName = primaryAddr?.city?.name ?? clinic?.city?.[0]?.name;
const provinceName = primaryAddr?.province?.name ?? clinic?.state?.[0]?.name;
const lat = primaryAddr?.map?.latitude ? parseFloat(primaryAddr.map.latitude)
: clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null;
const lng = primaryAddr?.map?.longitude ? parseFloat(primaryAddr.map.longitude)
: clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null;
if (isLoading) {
return (
<div className="fade-in">
<div className="card card-pad">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton" style={{ height: 20, borderRadius: 6, marginBottom: 12 }} />
))}
</div>
</div>
);
}
if (!clinic) {
return (
<div className="fade-in">
<div className="card card-pad">
<div className="empty">
<BuildingOffice2Icon style={{ width: 36, height: 36 }} />
<p>کلینیک یافت نشد</p>
</div>
</div>
</div>
);
}
return (
<div className="fade-in">
{/* ── Header ── */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')} style={{ padding: '6px 10px' }}>
<ArrowRightIcon style={{ width: 16, height: 16 }} />
</button>
<div>
<h1 className="section-title">{clinic.name}</h1>
<div className="muted">جزئیات کلینیک</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{!isReadOnly && (
<button className="btn ghost sm" onClick={() => openEdit()}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
)}
{primaryRole === 'admin' && (
<>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
</div>
{/* ── Main grid ── */}
<div className="split-2">
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* Identity card */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
{logo ? (
<img src={logo} alt="" className="avatar lg" style={{ objectFit: 'cover' }} />
) : (
<div className="avatar lg"
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`, fontSize: 28, fontWeight: 700 }}>
{clinic.name?.[0] ?? '?'}
</div>
)}
{!isReadOnly && (
<>
<button onClick={() => logoInputRef.current?.click()} disabled={logoUploading}
style={{
position: 'absolute', insetInlineEnd: -6, bottom: -6, width: 24, height: 24,
borderRadius: '50%', background: 'var(--primary)', border: '2px solid var(--surface)',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
<CameraIcon style={{ width: 12, height: 12, color: '#fff' }} />
</button>
<input ref={logoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleLogoUpload(e.target.files[0])} />
</>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{clinic.name}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
</span>
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
</div>
</div>
</div>
{/* Info rows */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<InfoTile icon={<PhoneIcon style={{ width: 15, height: 15 }} />}
label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : '—'} />
<InfoTile icon={<MapPinIcon style={{ width: 15, height: 15 }} />}
label="شهر"
value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
<InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
label="آدرس" value={primaryAddr?.address ?? clinic.location ?? '—'} fullWidth />
</div>
{clinic.caption && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2, var(--bg))', borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div>
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
</div>
)}
</div>
{/* Doctors + Invitations — shared manager */}
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly} />
{/* Gallery */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<b style={{ fontSize: 14 }}>
گالری تصاویر ({formatNumber((clinic.images_clinic ?? []).filter(i => i?.url).length)}/{formatNumber(MAX_GALLERY_IMAGES)})
</b>
{!isReadOnly && (
<>
<button className="btn ghost sm" onClick={() => galleryInputRef.current?.click()}
disabled={galleryUploading || (clinic.images_clinic ?? []).filter(i => i?.url).length >= MAX_GALLERY_IMAGES}>
<PlusIcon style={{ width: 14, height: 14 }} />
{galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'}
</button>
<input ref={galleryInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => e.target.files?.length && handleGalleryUpload(e.target.files)} />
</>
)}
</div>
{(clinic.images_clinic ?? []).length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ تصویری در گالری وجود ندارد</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
{clinic.images_clinic.filter(img => img?.url).map((img, i) => (
<div key={i} style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2, var(--bg))' }}>
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{!isReadOnly && (
<button
type="button"
className="mini-btn danger"
title="حذف تصویر"
onClick={() => setDeleteImageConfirm(img.url)}
style={{ position: 'absolute', top: 6, left: 6 }}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Map (read-only) */}
{lat !== null && lng !== null && (
<div className="card card-pad">
<b style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>موقعیت روی نقشه</b>
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer center={[lat, lng]} zoom={13} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<Marker position={[lat, lng]} />
</MapContainer>
</div>
</div>
)}
</div>
{/* Right sidebar */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* Specialties + Insurances + Services combined */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<b style={{ fontSize: 13 }}>تخصص‌ها، بیمه و خدمات</b>
{!isReadOnly && (
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit()}>
<PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
تخصص‌ها ({formatNumber((clinic.specialties ?? []).length)})
</div>
{(clinic.specialties ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.specialties.map(s => (
<span key={s.id} className="badge violet"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
بیمه‌ها ({formatNumber((clinic.list_bime ?? []).length)})
</div>
{(clinic.list_bime ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.list_bime.map(ins => (
<span key={ins.id} className="badge blue"><span className="bdot" />{ins.name}</span>
))}
</div>
}
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
خدمات ({formatNumber((clinic.services ?? []).length)})
</div>
{(clinic.services ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.services.map(s => (
<span key={s.id} className="badge green"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
</div>
</div>
{primaryRole === 'clinic' && (
<NotificationMobileCard target="clinic" />
)}
{/* Clinic Addresses Section */}
{(isOwner || primaryRole === 'admin' || isReadOnly) && (
<div className="card">
<div className="toolbar" style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
آدرس‌های کلینیک ({formatNumber(clinicAddresses.length)})
</div>
{(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
<button className="btn primary sm" onClick={() => openAddrForm(null)}>
<PlusIcon style={{ width: 14, height: 14 }} />
افزودن آدرس
</button>
)}
</div>
{clinicAddresses.length === 0 ? (
<div className="empty" style={{ padding: '24px 16px' }}>
<MapPinIcon style={{ width: 28, height: 28 }} />
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
هنوز آدرسی ثبت نشده دکتران نمی‌توانند این کلینیک را به عنوان لوکیشن انتخاب کنند
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{clinicAddresses.map((addr, idx) => (
<div key={addr.uuid} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '12px 16px',
borderTop: idx === 0 ? '1px solid var(--border)' : undefined,
borderBottom: '1px solid var(--border)',
}}>
<MapPinIcon style={{ width: 18, height: 18, color: 'var(--primary)', flexShrink: 0, marginTop: 2 }} />
<div style={{ flex: 1, minWidth: 0 }}>
{addr.name && <div style={{ fontWeight: 600, fontSize: 13 }}>{addr.name}</div>}
{addr.address && <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{addr.address}</div>}
{addr.telephone && (
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--text-2)' }}>
<PhoneIcon style={{ width: 12, height: 12, display: 'inline', marginLeft: 4 }} />
{addr.telephone}
</div>
)}
</div>
{(isOwner || primaryRole === 'admin') && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}>
<PencilIcon style={{ width: 14, height: 14 }} />
</button>
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
onClick={() => setDeleteAddrConfirm(addr)}>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* Clinic Address Form Modal */}
{addrFormOpen && createPortal(
<div className="overlay" onClick={() => setAddrFormOpen(false)}>
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b>
<button className="mini-btn" onClick={() => setAddrFormOpen(false)}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label>
<input className="input" placeholder="مثال: شعبه مرکزی"
value={addrForm.name}
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>استان</label>
<SearchableSelectField
options={provinces}
value={addrForm.province_id}
placeholder="انتخاب استان"
onChange={val => setAddrForm(f => ({ ...f, province_id: val, city_id: null }))}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
<SearchableSelectField
options={addrCities}
value={addrForm.city_id}
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان'}
disabled={!addrForm.province_id}
onChange={(val, label) => {
setAddrForm(f => ({ ...f, city_id: val }));
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس کامل</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..."
value={addrForm.address}
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
{addrForm.latitude && addrForm.longitude && (
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
</span>
)}
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت دقیق روی نقشه کلیک کنید</p>
<MapPicker
lat={addrForm.latitude} lng={addrForm.longitude}
flyTarget={addrMapFlyTarget}
initialCenter={addrForm.latitude && addrForm.longitude ? [addrForm.latitude, addrForm.longitude] : null}
onChange={(lt, ln) => setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
/>
{addrForm.latitude && addrForm.longitude && (
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="مثال: 02112345678"
value={addrForm.telephone}
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary sm" disabled={saveAddrMutation.isPending}
onClick={() => saveAddrMutation.mutate(addrForm)}>
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>,
document.body,
)}
{/* Delete Address Confirm */}
<ConfirmDialog
open={deleteAddrConfirm !== null}
title="حذف آدرس"
message={`آیا از حذف آدرس "${deleteAddrConfirm?.name ?? deleteAddrConfirm?.address ?? ''}" اطمینان دارید؟`}
confirmLabel="حذف"
onConfirm={() => deleteAddrConfirm && deleteAddrMutation.mutate(deleteAddrConfirm.uuid)}
onCancel={() => setDeleteAddrConfirm(null)}
/>
{/* Delete Gallery Image Confirm */}
<ConfirmDialog
open={deleteImageConfirm !== null}
title="حذف تصویر"
message="آیا از حذف این تصویر از گالری اطمینان دارید؟"
confirmLabel="حذف"
danger
onConfirm={() => {
if (deleteImageConfirm) handleGalleryRemove(deleteImageConfirm);
setDeleteImageConfirm(null);
}}
onCancel={() => setDeleteImageConfirm(null)}
/>
{/* Edit modal — self-portals to escape transform context */}
{editOpen && (
<EditModal
clinic={clinic}
onClose={() => setEditOpen(false)}
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
/>
)}
{/* Delete confirm */}
<ConfirmDialog
open={deleteOpen}
title="حذف کلینیک"
message={`آیا از حذف کلینیک "${clinic.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}
// ── Info tile ──────────────────────────────────────────────────────────────
function InfoTile({ icon, label, value, fullWidth }: {
icon?: React.ReactNode; label: string; value: React.ReactNode; fullWidth?: boolean;
}) {
return (
<div style={{
gridColumn: fullWidth ? '1 / -1' : undefined,
padding: '10px 12px', borderRadius: 8,
background: 'var(--surface-2, var(--bg))',
border: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}>
{icon}
<span style={{ fontSize: 11 }}>{label}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
</div>
);
}