601 lines
27 KiB
TypeScript
601 lines
27 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, useSearchParams } 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, 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 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;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
|
|
admin: { label: 'ادمین', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', dot: '#8b5cf6' },
|
|
doctor: { label: 'پزشک', cls: 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', dot: '#3b82f6' },
|
|
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' },
|
|
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' },
|
|
patient: { label: 'بیمار', cls: 'bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400', dot: '#94a3b8' },
|
|
};
|
|
|
|
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';
|
|
}
|
|
|
|
const AVATAR_COLORS = [
|
|
'from-violet-500 to-purple-600',
|
|
'from-blue-500 to-cyan-600',
|
|
'from-emerald-500 to-teal-600',
|
|
'from-pink-500 to-rose-600',
|
|
'from-amber-500 to-orange-600',
|
|
];
|
|
|
|
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 ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-white 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-slate-50 dark:bg-gray-800/60 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors group">
|
|
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
|
|
<Icon className="w-4.5 h-4.5 text-slate-500 dark:text-slate-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[11px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">{label}</p>
|
|
<p className={`text-sm font-medium text-slate-800 dark:text-slate-200 truncate ${mono ? 'font-mono' : ''}`}
|
|
dir={mono ? 'ltr' : undefined}>
|
|
{value ?? <span className="text-slate-400 dark:text-slate-500 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-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-white dark:hover:bg-gray-700 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-primary-500 bg-primary-50 dark:bg-primary-500/10'
|
|
: 'border-transparent hover:bg-slate-50 dark:hover:bg-gray-800 border border-slate-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
<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-slate-800 dark:text-slate-200 flex-1">{meta.label}</span>
|
|
{selected === key && <CheckCircleIcon className="w-4 h-4 text-primary-600 dark:text-primary-400" />}
|
|
</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;
|
|
|
|
// ── 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-red-100 dark:bg-red-400/10 flex items-center justify-center">
|
|
<XCircleIcon className="w-8 h-8 text-red-500" />
|
|
</div>
|
|
<p className="font-semibold text-slate-800 dark:text-slate-200">کاربر یافت نشد</p>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">این کاربر حذف شده یا 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">
|
|
|
|
{/* ── Breadcrumb / Back ─────────────────────────────────── */}
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
|
<button
|
|
onClick={() => navigate('/admin/users')}
|
|
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"
|
|
>
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
کاربران
|
|
</button>
|
|
<span>/</span>
|
|
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل کاربر</span>
|
|
</div>
|
|
|
|
{/* ── Profile Card ──────────────────────────────────────── */}
|
|
<div className="cp-card overflow-hidden">
|
|
|
|
{/* Cover strip */}
|
|
<div className="h-24 bg-gradient-to-l from-primary-600/20 via-primary-500/10 to-transparent dark:from-primary-500/20" />
|
|
|
|
{/* 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-slate-900 dark:text-slate-50 leading-tight">
|
|
{user.name || <span className="text-slate-400 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-green-100 dark:bg-green-400/10 text-green-700 dark:text-green-300">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />فعال
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-400/10 text-slate-500 dark:text-slate-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />غیرفعال
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-slate-400 dark:text-slate-500">#{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-slate-200 dark:border-gray-700 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-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
|
>
|
|
{user.is_active
|
|
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
|
|
: <CheckCircleIcon className="w-4 h-4 text-green-500 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-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<ShieldCheckIcon className="w-4 h-4 text-violet-400 shrink-0" />
|
|
تغییر نقش کاربر
|
|
</button>
|
|
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
|
|
<button
|
|
onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/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-slate-700 dark:text-slate-300">اطلاعات تماس و حساب</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-slate-700 dark:text-slate-300">نقشهای سیستمی</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-slate-100 dark:bg-gray-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-700">
|
|
{r}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => setRoleOpen(true)}
|
|
className="w-full mt-1 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right"
|
|
>
|
|
تغییر نقش ←
|
|
</button>
|
|
</div>
|
|
|
|
{/* UUID card */}
|
|
<div className="cp-card p-5 space-y-2">
|
|
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">شناسه یکتا (UUID)</h2>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-[11px] font-mono text-slate-500 dark:text-slate-400 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-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-700 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-slate-700 dark:text-slate-300">وضعیت حساب</h2>
|
|
{user.is_active
|
|
? <span className="text-xs font-medium text-green-600 dark:text-green-400">فعال</span>
|
|
: <span className="text-xs font-medium text-slate-500">غیرفعال</span>
|
|
}
|
|
</div>
|
|
<div className={`w-full h-2 rounded-full ${user.is_active ? 'bg-green-200 dark:bg-green-400/20' : 'bg-slate-200 dark:bg-gray-700'}`}>
|
|
<div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-green-500' : '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-orange-50 dark:bg-orange-400/10 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-400/20'
|
|
: 'bg-green-50 dark:bg-green-400/10 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-400/20'
|
|
}`}
|
|
>
|
|
{toggleStatusMut.isPending
|
|
? 'در حال تغییر...'
|
|
: user.is_active ? 'غیرفعال کردن حساب' : 'فعالسازی حساب'
|
|
}
|
|
</button>
|
|
</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-slate-100 dark:border-gray-700">
|
|
<BigAvatar name={user.name} id={user.id} />
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
|
{user.name || 'نام ثبت نشده'}
|
|
</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono" dir="ltr">{user.mobile_number}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
|
نام و نام خانوادگی
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="مثال: علی احمدی"
|
|
className={`cp-input ${errors.name ? 'border-red-400 dark:border-red-500' : ''}`}
|
|
{...register('name')}
|
|
/>
|
|
{errors.name && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.name.message}</p>}
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
|
آدرس ایمیل
|
|
</label>
|
|
<input
|
|
type="email"
|
|
placeholder="example@email.com"
|
|
dir="ltr"
|
|
className={`cp-input text-left ${errors.email ? 'border-red-400 dark:border-red-500' : ''}`}
|
|
{...register('email')}
|
|
/>
|
|
{errors.email && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.email.message}</p>}
|
|
</div>
|
|
|
|
{/* Password */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
|
رمز عبور جدید
|
|
<span className="text-xs font-normal text-slate-400 dark:text-slate-500 mr-1">(خالی = بدون تغییر)</span>
|
|
</label>
|
|
<div className="relative">
|
|
<KeyIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
|
<input
|
|
type="password"
|
|
placeholder="حداقل ۶ کاراکتر"
|
|
dir="ltr"
|
|
className={`cp-input pr-9 text-left ${errors.password ? 'border-red-400 dark:border-red-500' : ''}`}
|
|
{...register('password')}
|
|
/>
|
|
</div>
|
|
{errors.password && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.password.message}</p>}
|
|
</div>
|
|
|
|
{/* Mobile (read-only) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
|
شماره موبایل
|
|
<span className="text-xs font-normal text-slate-400 dark:text-slate-500 mr-1">(قابل تغییر نیست)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={user.mobile_number}
|
|
readOnly
|
|
dir="ltr"
|
|
className="cp-input text-left bg-slate-50 dark:bg-gray-800/60 text-slate-400 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>
|
|
);
|
|
}
|