- 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.
705 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|