Files
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

705 lines
33 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { useParams, useNavigate, useSearchParams } from 'react-router';
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, CheckCircleIcon,
XCircleIcon, ShieldCheckIcon, UserIcon, PhoneIcon,
EnvelopeIcon, CalendarIcon, KeyIcon, EllipsisVerticalIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDate, formatDateTime } from '../lib/utils';
import Modal from '../components/ui/Modal';
import BackButton from '../components/ui/BackButton';
import ConfirmDialog from '../components/ui/ConfirmDialog';
// ── Types ─────────────────────────────────────────────────────────────────
interface AdminUserDetail {
uuid: string;
id: number;
mobile_number: string;
name: string | null;
email: string | null;
roles: string[];
is_active: boolean;
status: number;
created_at: string;
updated_at: string;
}
interface UserProfileData {
uuid: string;
national_code: string | null;
gender: string | null;
birthday: number | null;
blood_type: string | null;
marital_status: string | null;
education: string | null;
job: string | null;
fathers_name: string | null;
address: string | null;
home_phone: string | null;
work_phone: string | null;
basic_insurance_id: number | null;
supplementary_insurance_id: number | null;
other: Record<string, unknown[]> | null;
}
const GENDER_LABELS: Record<string, string> = { male: 'مرد', female: 'زن' };
const MARITAL_LABELS: Record<string, string> = { married: 'متاهل', single: 'مجرد' };
// Each medical section stores objects with its own shape; format an item to a label.
const MEDICAL_SECTIONS: {
key: string;
label: string;
format: (item: any) => string;
filter?: (item: any) => boolean;
}[] = [
{ key: 'allergies', label: 'آلرژی‌ها', format: (i) => [i.substance, i.reaction, i.severity].filter(Boolean).join(' — ') },
{ key: 'medications', label: 'داروهای مصرفی', format: (i) => [i.name, i.dose, i.frequency].filter(Boolean).join(' — ') },
{ key: 'surgeries', label: 'جراحی‌ها', format: (i) => [i.type, i.year && `سال ${i.year}`, i.hospital].filter(Boolean).join(' — ') },
{ key: 'family_history', label: 'سابقه خانوادگی بیماری', format: (i) => [i.relation, i.disease].filter(Boolean).join(': ') },
// disease is a fixed checklist; only show the ones marked active
{ key: 'disease', label: 'بیماری‌ها', format: (i) => i.name, filter: (i) => i.status === 'true' || i.status === true },
];
// ── Helpers ───────────────────────────────────────────────────────────────
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
admin: { label: 'ادمین', cls: 'bg-[var(--violet-bg)] text-[var(--violet)]', dot: 'var(--violet)' },
doctor: { label: 'پزشک', cls: 'bg-[var(--info-bg)] text-[var(--info)]', dot: 'var(--info)' },
secretary: { label: 'منشی', cls: 'bg-[var(--accent-bg)] text-[var(--accent)]', dot: 'var(--accent)' },
clinic: { label: 'کلینیک', cls: 'bg-[var(--success-bg)] text-[var(--success)]', dot: 'var(--success)' },
patient: { label: 'بیمار', cls: 'bg-[var(--surface-2)] text-[var(--text-2)]', dot: 'var(--text-3)' },
};
const ROLE_MAP: Record<string, string[]> = {
admin: ['ROLE_USER', 'ROLE_ADMIN'],
doctor: ['ROLE_USER', 'ROLE_DOCTOR'],
secretary: ['ROLE_USER', 'ROLE_SECRETARY'],
clinic: ['ROLE_USER', 'ROLE_CLINIC'],
patient: ['ROLE_USER'],
};
function getPrimaryRole(roles: string[]): string {
if (roles.includes('ROLE_ADMIN')) return 'admin';
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
if (roles.includes('ROLE_CLINIC')) return 'clinic';
return 'patient';
}
function BigAvatar({ name, id }: { name: string | null; id: number }) {
const initials = name
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
: '؟';
return (
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br ${avatarGradient(id)} flex items-center justify-center text-[var(--on-primary)] text-2xl font-bold shadow-lg shrink-0`}>
{initials}
</div>
);
}
function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }: {
icon: React.ElementType; label: string; value: React.ReactNode;
mono?: boolean; copyable?: boolean;
}) {
const handleCopy = () => {
if (typeof value === 'string') {
navigator.clipboard.writeText(value);
toast.success('کپی شد');
}
};
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] hover:bg-[var(--surface-2)] transition-colors group">
<div className="w-9 h-9 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4.5 h-4.5 text-[var(--text-2)]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-[var(--text)] truncate ${mono ? 'font-mono' : ''}`}
dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p>
</div>
{copyable && typeof value === 'string' && (
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)] transition-all shrink-0"
>
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
// ── Edit schema ───────────────────────────────────────────────────────────
const editSchema = z.object({
name: z.string().max(100).optional().or(z.literal('')),
email: z.string().email('ایمیل نامعتبر').optional().or(z.literal('')),
password: z.string().min(6, 'رمز عبور حداقل ۶ کاراکتر').optional().or(z.literal('')),
});
type EditForm = z.infer<typeof editSchema>;
// ── Role Change Modal ─────────────────────────────────────────────────────
function ChangeRoleModal({ current, loading, onSave, onClose }: {
current: string; loading: boolean;
onSave: (role: string) => void; onClose: () => void;
}) {
const [selected, setSelected] = useState(current);
return (
<Modal open title="تغییر نقش کاربر" onClose={onClose}
footer={
<>
<button onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button onClick={() => onSave(selected)} disabled={loading} className="btn primary sm">
{loading ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<div className="space-y-2">
{Object.entries(ROLE_META).map(([key, meta]) => (
<label
key={key}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
selected === key
? 'border-[var(--primary)] bg-[var(--primary-soft)] dark:bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]'
: 'border-transparent hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] border border-[var(--border)]'
}`}
>
<input type="radio" name="role" value={key} checked={selected === key}
onChange={() => setSelected(key)} className="sr-only" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
<span className="text-sm font-medium text-[var(--text)] flex-1">{meta.label}</span>
{selected === key && <CheckCircleIcon className="w-4 h-4 text-[var(--primary)] dark:text-[var(--primary)]" />}
</label>
))}
</div>
</Modal>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────
export default function UserDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const qc = useQueryClient();
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
const [roleOpen, setRoleOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!menuOpen) return;
const close = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-upmenu]') === null) setMenuOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [menuOpen]);
// ── Query ──
const { data, isLoading, isError } = useQuery({
queryKey: ['admin-user', uuid],
queryFn: () => api.get<ApiResponse<AdminUserDetail>>(`/api/v1/admin/users/${uuid}`),
enabled: !!uuid,
});
const user: AdminUserDetail | undefined = (data?.data as any)?.data ?? data?.data;
const profileQ = useQuery({
queryKey: ['admin-user-profile', uuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/user-profile/${uuid}`),
enabled: !!uuid,
retry: false,
});
const profile: UserProfileData | undefined =
(profileQ.data as any)?.data?.data ?? (profileQ.data as any)?.data;
// ── Edit form ──
const { register, handleSubmit, reset, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
});
useEffect(() => {
if (user) reset({ name: user.name ?? '', email: user.email ?? '', password: '' });
}, [user, reset]);
// ── Mutations ──
const updateMut = useMutation({
mutationFn: (body: EditForm) => api.put<ApiResponse<AdminUserDetail>>(`/api/v1/admin/users/${uuid}`, body),
onSuccess: () => {
toast.success('اطلاعات بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
qc.invalidateQueries({ queryKey: ['users'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleStatusMut = useMutation({
mutationFn: () => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
onSuccess: () => {
toast.success('وضعیت کاربر تغییر کرد');
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
qc.invalidateQueries({ queryKey: ['users'] });
},
onError: (e: Error) => toast.error(e.message),
});
const updateRoleMut = useMutation({
mutationFn: (role: string) => api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role }),
onSuccess: () => {
toast.success('نقش کاربر بروزرسانی شد');
setRoleOpen(false);
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
qc.invalidateQueries({ queryKey: ['users'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteMut = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
onSuccess: () => {
toast.success('کاربر حذف شد');
navigate('/admin/users');
},
onError: (e: Error) => toast.error(e.message),
});
// ── Loading skeleton ──
if (isLoading) {
return (
<div className="animate-slide-up space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg skeleton" />
<div className="h-5 w-32 rounded skeleton" />
</div>
<div className="cp-card p-6">
<div className="flex items-start gap-5 mb-8">
<div className="w-20 h-20 rounded-2xl skeleton" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-6 w-48 rounded skeleton" />
<div className="h-4 w-24 rounded skeleton" />
<div className="h-5 w-16 rounded-full skeleton mt-3" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 rounded-xl skeleton" />)}
</div>
</div>
</div>
);
}
if (isError || !user) {
return (
<div className="animate-slide-up">
<div className="cp-card p-16 flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div>
<p className="font-semibold text-[var(--text)]">کاربر یافت نشد</p>
<p className="text-sm text-[var(--text-2)]">این کاربر حذف شده یا UUID اشتباه است.</p>
<button onClick={() => navigate('/admin/users')} className="cp-btn-secondary mt-2">
بازگشت به لیست کاربران
</button>
</div>
</div>
);
}
const primaryRole = getPrimaryRole(user.roles);
const roleMeta = ROLE_META[primaryRole];
return (
<div className="animate-slide-up space-y-5">
<BackButton fallback="/admin/users" />
{/* ── Breadcrumb / Back ─────────────────────────────────── */}
<div className="flex items-center gap-2 text-sm text-[var(--text-2)]">
<button
onClick={() => navigate('/admin/users')}
className="flex items-center gap-1.5 hover:text-[var(--text)] dark:hover:text-[var(--text)] transition-colors"
>
<ArrowRightIcon className="w-4 h-4" />
کاربران
</button>
<span>/</span>
<span className="text-[var(--text)] font-medium">پروفایل کاربر</span>
</div>
{/* ── Profile Card ──────────────────────────────────────── */}
<div className="cp-card overflow-hidden">
{/* Cover strip */}
<div className="h-24 bg-gradient-to-l from-[color-mix(in_srgb,var(--primary)_20%,transparent)] via-[color-mix(in_srgb,var(--primary)_10%,transparent)] to-transparent dark:from-[color-mix(in_srgb,var(--primary)_20%,transparent)]" />
{/* Profile header */}
<div className="px-6 pb-6 -mt-10 flex flex-col sm:flex-row sm:items-end gap-4">
<BigAvatar name={user.name} id={user.id} />
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)] leading-tight">
{user.name || <span className="text-[var(--text-3)] font-normal">نام ثبت نشده</span>}
</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${roleMeta.cls}`}>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: roleMeta.dot }} />
{roleMeta.label}
</span>
{user.is_active ? (
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--success)] animate-pulse" />فعال
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-2)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--text-3)]" />غیرفعال
</span>
)}
<span className="text-xs text-[var(--text-3)]">#{user.id}</span>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 sm:mb-1">
<button
onClick={() => setEditOpen(true)}
className="cp-btn-primary text-sm"
>
<PencilIcon className="w-4 h-4" />
ویرایش اطلاعات
</button>
{/* More menu */}
<div className="relative" data-upmenu>
<button
data-upmenu
onClick={() => setMenuOpen(!menuOpen)}
className="cp-btn-secondary h-10 w-10 flex items-center justify-center px-0"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-[var(--border)] py-1.5 animate-scale-in" data-upmenu>
<button
onClick={() => { toggleStatusMut.mutate(); setMenuOpen(false); }}
disabled={toggleStatusMut.isPending}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors disabled:opacity-50"
>
{user.is_active
? <XCircleIcon className="w-4 h-4 text-[var(--accent)] shrink-0" />
: <CheckCircleIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
}
{user.is_active ? 'غیرفعال کردن حساب' : 'فعال‌سازی حساب'}
</button>
<button
onClick={() => { setRoleOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors"
>
<ShieldCheckIcon className="w-4 h-4 text-[var(--violet)] shrink-0" />
تغییر نقش کاربر
</button>
<div className="border-t border-[var(--border)] my-1" />
<button
onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors"
>
<TrashIcon className="w-4 h-4 shrink-0" />
حذف این کاربر
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* ── Info Grid ─────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left: Contact info */}
<div className="lg:col-span-2 cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-[var(--text)]">اطلاعات تماس و حساب</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoCard icon={UserIcon} label="نام کامل" value={user.name} />
<InfoCard icon={PhoneIcon} label="شماره موبایل" value={user.mobile_number} mono copyable />
<InfoCard icon={EnvelopeIcon} label="آدرس ایمیل" value={user.email} copyable />
<InfoCard icon={ShieldCheckIcon} label="نقش در سیستم" value={roleMeta.label} />
<InfoCard icon={CalendarIcon} label="تاریخ عضویت" value={formatDate(user.created_at)} />
<InfoCard icon={CalendarIcon} label="آخرین بروزرسانی" value={formatDateTime(user.updated_at)} />
</div>
</div>
{/* Right: Roles + UUID */}
<div className="space-y-5">
{/* Roles card */}
<div className="cp-card p-5 space-y-3">
<h2 className="text-sm font-semibold text-[var(--text)]">نقش‌های سیستمی</h2>
<div className="flex flex-wrap gap-2">
{user.roles.map((r) => (
<span key={r} className="text-xs font-mono px-2.5 py-1 rounded-lg bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)]">
{r}
</span>
))}
</div>
<button
onClick={() => setRoleOpen(true)}
className="w-full mt-1 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right"
>
تغییر نقش
</button>
</div>
{/* UUID card */}
<div className="cp-card p-5 space-y-2">
<h2 className="text-sm font-semibold text-[var(--text)]">شناسه یکتا (UUID)</h2>
<div className="flex items-center gap-2">
<p className="text-[11px] font-mono text-[var(--text-2)] break-all flex-1" dir="ltr">
{user.uuid}
</p>
<button
onClick={() => { navigator.clipboard.writeText(user.uuid); toast.success('UUID کپی شد'); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface-2)] transition-colors shrink-0"
>
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Status card */}
<div className="cp-card p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[var(--text)]">وضعیت حساب</h2>
{user.is_active
? <span className="text-xs font-medium text-[var(--success)]">فعال</span>
: <span className="text-xs font-medium text-[var(--text-2)]">غیرفعال</span>
}
</div>
<div className={`w-full h-2 rounded-full ${user.is_active ? 'bg-[var(--success-bg)]' : 'bg-[var(--surface-3)]'}`}>
<div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-[var(--success)]' : 'w-0'}`} />
</div>
<button
onClick={() => toggleStatusMut.mutate()}
disabled={toggleStatusMut.isPending}
className={`w-full mt-4 text-sm font-medium py-2 rounded-xl transition-colors disabled:opacity-50 ${
user.is_active
? 'bg-[var(--accent-bg)] text-[var(--accent)] hover:bg-[var(--accent-bg)]'
: 'bg-[var(--success-bg)] text-[var(--success)] hover:bg-[var(--success-bg)]'
}`}
>
{toggleStatusMut.isPending
? 'در حال تغییر...'
: user.is_active ? 'غیرفعال کردن حساب' : 'فعال‌سازی حساب'
}
</button>
</div>
</div>
</div>
{/* ── User Profile (read-only) ──────────────────────────── */}
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-[var(--text)]">پروفایل کاربر</h2>
{profileQ.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 rounded-xl skeleton" />)}
</div>
) : !profile ? (
<p className="text-sm text-[var(--text-2)] py-4">
این کاربر هنوز پروفایلی تکمیل نکرده است.
</p>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<InfoCard icon={UserIcon} label="نام پدر" value={profile.fathers_name} />
<InfoCard icon={ClipboardDocumentIcon} label="کد ملی" value={profile.national_code} mono copyable />
<InfoCard icon={UserIcon} label="جنسیت" value={profile.gender ? GENDER_LABELS[profile.gender] ?? profile.gender : null} />
<InfoCard icon={CalendarIcon} label="تاریخ تولد" value={profile.birthday ? formatDate(profile.birthday) : null} />
<InfoCard icon={UserIcon} label="گروه خونی" value={profile.blood_type} />
<InfoCard icon={UserIcon} label="وضعیت تأهل" value={profile.marital_status ? MARITAL_LABELS[profile.marital_status] ?? profile.marital_status : null} />
<InfoCard icon={UserIcon} label="تحصیلات" value={profile.education} />
<InfoCard icon={UserIcon} label="شغل" value={profile.job} />
<InfoCard icon={PhoneIcon} label="تلفن منزل" value={profile.home_phone} mono />
<InfoCard icon={PhoneIcon} label="تلفن محل کار" value={profile.work_phone} mono />
<InfoCard icon={ShieldCheckIcon} label="بیمه پایه" value={profile.basic_insurance_id != null ? String(profile.basic_insurance_id) : null} />
<InfoCard icon={ShieldCheckIcon} label="بیمه تکمیلی" value={profile.supplementary_insurance_id != null ? String(profile.supplementary_insurance_id) : null} />
<InfoCard icon={UserIcon} label="آدرس" value={profile.address} />
</div>
{/* Medical history */}
<div className="pt-2 border-t border-[var(--border)] space-y-3">
<h3 className="text-sm font-semibold text-[var(--text)]">سوابق پزشکی</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{MEDICAL_SECTIONS.map(({ key, label, format, filter }) => {
const raw = Array.isArray(profile.other?.[key]) ? (profile.other![key] as any[]) : [];
const items = (filter ? raw.filter(filter) : raw)
.map(format)
.filter((s) => s && s.trim());
return (
<div key={key} className="p-4 rounded-xl bg-[var(--surface-2)]">
<p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-1.5">{label}</p>
{items.length === 0 ? (
<p className="text-sm text-[var(--text-3)]">موردی ثبت نشده</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{items.map((text, i) => (
<li key={i} className="text-xs px-2 py-0.5 rounded-lg bg-[var(--surface)] text-[var(--text-2)] border border-[var(--border)]">
{text}
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
</>
)}
</div>
{/* ── Edit Modal ────────────────────────────────────────── */}
<Modal
open={editOpen}
title="ویرایش اطلاعات کاربر"
size="md"
onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary px-5">لغو</button>
<button
form="edit-user-form"
type="submit"
disabled={updateMut.isPending}
className="btn primary sm"
>
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form id="edit-user-form" onSubmit={handleSubmit((v) => updateMut.mutate(v))} className="space-y-4">
{/* Avatar preview */}
<div className="flex items-center gap-4 pb-4 border-b border-[var(--border)]">
<BigAvatar name={user.name} id={user.id} />
<div>
<p className="text-sm font-semibold text-[var(--text)]">
{user.name || 'نام ثبت نشده'}
</p>
<p className="text-xs text-[var(--text-2)] font-mono" dir="ltr">{user.mobile_number}</p>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
نام و نام خانوادگی
</label>
<input
type="text"
placeholder="مثال: علی احمدی"
className={`cp-input ${errors.name ? 'border-[var(--danger)]' : ''}`}
{...register('name')}
/>
{errors.name && <p className="text-xs text-[var(--danger)] mt-1">{errors.name.message}</p>}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
آدرس ایمیل
</label>
<input
type="email"
placeholder="example@email.com"
dir="ltr"
className={`cp-input text-left ${errors.email ? 'border-[var(--danger)]' : ''}`}
{...register('email')}
/>
{errors.email && <p className="text-xs text-[var(--danger)] mt-1">{errors.email.message}</p>}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
رمز عبور جدید
<span className="text-xs font-normal text-[var(--text-3)] mr-1">(خالی = بدون تغییر)</span>
</label>
<div className="relative">
<KeyIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-3)] pointer-events-none" />
<input
type="password"
placeholder="حداقل ۶ کاراکتر"
dir="ltr"
className={`cp-input pr-9 text-left ${errors.password ? 'border-[var(--danger)]' : ''}`}
{...register('password')}
/>
</div>
{errors.password && <p className="text-xs text-[var(--danger)] mt-1">{errors.password.message}</p>}
</div>
{/* Mobile (read-only) */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
شماره موبایل
<span className="text-xs font-normal text-[var(--text-3)] mr-1">(قابل تغییر نیست)</span>
</label>
<input
type="text"
value={user.mobile_number}
readOnly
dir="ltr"
className="cp-input text-left bg-[var(--surface-2)] text-[var(--text-3)] cursor-not-allowed"
/>
</div>
</form>
</Modal>
{/* ── Role change modal ─────────────────────────────────── */}
{roleOpen && (
<ChangeRoleModal
current={primaryRole}
loading={updateRoleMut.isPending}
onSave={(r) => updateRoleMut.mutate(r)}
onClose={() => setRoleOpen(false)}
/>
)}
{/* ── Delete confirm ────────────────────────────────────── */}
<ConfirmDialog
open={deleteOpen}
title="حذف کاربر"
message={`آیا از حذف کاربر "${user.name ?? user.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام داده‌های مرتبط پاک می‌شود.`}
confirmLabel="بله، حذف کن"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}