429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { PageLoader } from "../../../Components/UI/Spinner";
|
||
import { Alert } from "../../../Components/UI/Alert";
|
||
import { fmtDate } from "../../../utils/helperFun";
|
||
import { getStoredToken } from "../../../lib/auth";
|
||
import { userService } from "../../../services/user.service";
|
||
import type { UserDetail } from "../../../types/user";
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
function safeFmtDate(iso: string | null | undefined): string {
|
||
if (!iso) return "—";
|
||
try {
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return "—";
|
||
return fmtDate(iso);
|
||
} catch {
|
||
return "—";
|
||
}
|
||
}
|
||
|
||
// ── sub-components ────────────────────────────────────────────────────────────
|
||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div style={{
|
||
background: "var(--color-surface)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "var(--radius-xl)",
|
||
boxShadow: "var(--shadow-card)",
|
||
overflow: "hidden",
|
||
}}>
|
||
<div style={{
|
||
padding: "0.875rem 1.5rem",
|
||
borderBottom: "1px solid var(--color-border)",
|
||
background: "var(--color-surface-muted)",
|
||
}}>
|
||
<p style={{
|
||
margin: 0,
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.2em",
|
||
color: "var(--color-text-muted)",
|
||
}}>
|
||
{title}
|
||
</p>
|
||
</div>
|
||
<div style={{ padding: "1.25rem 1.5rem" }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<span style={{
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: "var(--color-text-muted)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.15em",
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 13,
|
||
color: "var(--color-text-primary)",
|
||
fontFamily: "var(--font-sans)",
|
||
}}>
|
||
{children}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActiveBadge({ active }: { active: boolean }) {
|
||
return (
|
||
<span style={{
|
||
display: "inline-flex", alignItems: "center", gap: 5,
|
||
borderRadius: "var(--radius-full)",
|
||
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
||
background: active ? "#DCFCE7" : "#FEF2F2",
|
||
padding: "0.2rem 0.75rem",
|
||
fontSize: 12, fontWeight: 600,
|
||
color: active ? "#166534" : "#991B1B",
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||
{active ? "نشط" : "معطل"}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DeletedBadge({ deleted }: { deleted: boolean }) {
|
||
if (!deleted) return (
|
||
<span style={{
|
||
display: "inline-flex", alignItems: "center", gap: 5,
|
||
borderRadius: "var(--radius-full)",
|
||
border: "1px solid #BBF7D0",
|
||
background: "#DCFCE7",
|
||
padding: "0.2rem 0.75rem",
|
||
fontSize: 12, fontWeight: 600,
|
||
color: "#166534",
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#16A34A" }} />
|
||
موجود
|
||
</span>
|
||
);
|
||
return (
|
||
<span style={{
|
||
display: "inline-flex", alignItems: "center", gap: 5,
|
||
borderRadius: "var(--radius-full)",
|
||
border: "1px solid #FECACA",
|
||
background: "#FEF2F2",
|
||
padding: "0.2rem 0.75rem",
|
||
fontSize: 12, fontWeight: 600,
|
||
color: "#991B1B",
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#DC2626" }} />
|
||
محذوف
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// eye icon
|
||
function EyeIcon({ open }: { open: boolean }) {
|
||
if (open) return (
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||
<line x1="1" y1="1" x2="23" y2="23" />
|
||
</svg>
|
||
);
|
||
return (
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||
<circle cx="12" cy="12" r="3" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// silhouette fallback avatar
|
||
function AvatarFallback() {
|
||
return (
|
||
<div style={{
|
||
width: 80, height: 80, borderRadius: "50%",
|
||
background: "var(--color-brand-100)",
|
||
border: "2px solid var(--color-brand-200)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
flexShrink: 0,
|
||
}}>
|
||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.5">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── main page ─────────────────────────────────────────────────────────────────
|
||
export default function UserDetailPage() {
|
||
const params = useParams();
|
||
const router = useRouter();
|
||
const userId = typeof params?.userId === "string" ? params.userId : null;
|
||
|
||
const [user, setUser] = useState<UserDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showToken, setShowToken] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!userId) {
|
||
router.replace("/users");
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
async function fetchUser() {
|
||
try {
|
||
const token = getStoredToken();
|
||
const res = await userService.getById(userId as string, token);
|
||
if (!cancelled) {
|
||
setUser((res as { data: UserDetail }).data ?? null);
|
||
}
|
||
} catch {
|
||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
}
|
||
fetchUser();
|
||
return () => { cancelled = true; };
|
||
}, [userId, router]);
|
||
|
||
// reset token visibility on unmount
|
||
useEffect(() => () => { setShowToken(false); }, []);
|
||
|
||
if (loading) return <PageLoader message="جارٍ تحميل بيانات المستخدم…" />;
|
||
|
||
if (error || !user) {
|
||
return (
|
||
<div dir="rtl" style={{ padding: "2rem", maxWidth: 560 }}>
|
||
<Alert
|
||
type="error"
|
||
title="خطأ في التحميل"
|
||
message={error ?? "المستخدم غير موجود."}
|
||
/>
|
||
<a
|
||
href="/users"
|
||
style={{
|
||
display: "inline-block", marginTop: "1rem",
|
||
fontSize: 13, fontWeight: 600,
|
||
color: "var(--color-brand-600)",
|
||
textDecoration: "underline",
|
||
}}
|
||
>
|
||
← العودة إلى قائمة المستخدمين
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||
|
||
{/* soft-deleted warning banner */}
|
||
{user.isDeleted && (
|
||
<div style={{
|
||
display: "flex", alignItems: "center", gap: 10,
|
||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||
borderRadius: "var(--radius-lg)", padding: "0.875rem 1.25rem",
|
||
fontSize: 13, fontWeight: 600, color: "#991B1B",
|
||
}}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
هذا المستخدم محذوف — تم حذفه بتاريخ {safeFmtDate(user.deletedAt)}.
|
||
</div>
|
||
)}
|
||
|
||
{/* page header */}
|
||
<header style={{
|
||
background: "var(--color-surface)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "var(--radius-xl)",
|
||
boxShadow: "var(--shadow-card)",
|
||
padding: "1.5rem 2rem",
|
||
}}>
|
||
<a
|
||
href="/users"
|
||
style={{
|
||
display: "inline-flex", alignItems: "center", gap: 6,
|
||
fontSize: 12, fontWeight: 600,
|
||
color: "var(--color-brand-600)",
|
||
textDecoration: "none", marginBottom: "1rem",
|
||
}}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<polyline points="15 18 9 12 15 6" />
|
||
</svg>
|
||
العودة إلى المستخدمين
|
||
</a>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem", flexWrap: "wrap" }}>
|
||
{/* avatar */}
|
||
{user.photo
|
||
? <img src={user.photo} alt={user.name} style={{ width: 80, height: 80, borderRadius: "50%", objectFit: "cover", border: "2px solid var(--color-brand-200)", flexShrink: 0 }} />
|
||
: <AvatarFallback />
|
||
}
|
||
<div>
|
||
<p style={{ margin: 0, fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||
ملف المستخدم
|
||
</p>
|
||
<h1 style={{ margin: "4px 0 6px", fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text-primary)" }}>
|
||
{user.name}
|
||
</h1>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||
{user.userName && (
|
||
<span style={{
|
||
fontFamily: "var(--font-mono)", fontSize: 12,
|
||
color: "#2563EB", fontWeight: 600,
|
||
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||
borderRadius: "var(--radius-sm)", padding: "2px 8px",
|
||
}}>
|
||
@{user.userName}
|
||
</span>
|
||
)}
|
||
<ActiveBadge active={user.isActive} />
|
||
<DeletedBadge deleted={user.isDeleted} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* grid layout */}
|
||
<div style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||
gap: "1.25rem",
|
||
}}>
|
||
|
||
{/* Basic Info */}
|
||
<SectionCard title="المعلومات الأساسية">
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||
<Field label="الاسم الكامل">{user.name}</Field>
|
||
<Field label="البريد الإلكتروني">
|
||
{user.email
|
||
? <span style={{ direction: "ltr", display: "inline-block" }}>{user.email}</span>
|
||
: "—"
|
||
}
|
||
</Field>
|
||
<Field label="رقم الهاتف">
|
||
<span style={{ direction: "ltr", display: "inline-block", fontFamily: "var(--font-mono)" }}>
|
||
{user.phone || "—"}
|
||
</span>
|
||
</Field>
|
||
<Field label="المعرف">
|
||
<span style={{
|
||
fontFamily: "var(--font-mono)", fontSize: 11,
|
||
color: "var(--color-text-muted)", wordBreak: "break-all",
|
||
}}>
|
||
{user.id}
|
||
</span>
|
||
</Field>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
{/* Role & Branch */}
|
||
<SectionCard title="الدور والفرع">
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||
<Field label="الدور">
|
||
<span style={{
|
||
display: "inline-flex", alignItems: "center",
|
||
borderRadius: "var(--radius-full)",
|
||
border: "1px solid #BFDBFE",
|
||
background: "#EFF6FF",
|
||
padding: "0.2rem 0.75rem",
|
||
fontSize: 12, fontWeight: 600,
|
||
color: "#1D4ED8",
|
||
}}>
|
||
{user.role?.name ?? "—"}
|
||
</span>
|
||
</Field>
|
||
{user.role?.description && (
|
||
<Field label="وصف الدور">
|
||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||
{user.role.description}
|
||
</span>
|
||
</Field>
|
||
)}
|
||
<Field label="الفرع">{user.branch?.name ?? "—"}</Field>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
{/* Account Status */}
|
||
<SectionCard title="حالة الحساب">
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||
<Field label="الحالة"><ActiveBadge active={user.isActive} /></Field>
|
||
<Field label="حالة الحذف"><DeletedBadge deleted={user.isDeleted} /></Field>
|
||
<Field label="تاريخ الإنشاء">{safeFmtDate(user.createdAt)}</Field>
|
||
<Field label="آخر تحديث">{safeFmtDate(user.updatedAt)}</Field>
|
||
<Field label="تاريخ الحذف">{safeFmtDate(user.deletedAt)}</Field>
|
||
<Field label="آخر تغيير لكلمة المرور">{safeFmtDate(user.passwordChangedAt)}</Field>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
{/* Refresh Token */}
|
||
<SectionCard title="رمز التحديث">
|
||
{user.refreshToken ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||
<div style={{
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
background: "var(--color-surface-muted)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "var(--radius-md)",
|
||
padding: "0.625rem 0.875rem",
|
||
gap: "0.75rem",
|
||
}}>
|
||
<span style={{
|
||
fontFamily: "var(--font-mono)", fontSize: 12,
|
||
color: "var(--color-text-secondary)",
|
||
wordBreak: "break-all", flex: 1,
|
||
letterSpacing: showToken ? "normal" : "0.15em",
|
||
}}>
|
||
{showToken ? user.refreshToken : "••••••••••••••••••••••••"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowToken(p => !p)}
|
||
title={showToken ? "إخفاء الرمز" : "إظهار الرمز"}
|
||
aria-label={showToken ? "إخفاء رمز التحديث" : "إظهار رمز التحديث"}
|
||
style={{
|
||
flexShrink: 0,
|
||
width: 30, height: 30,
|
||
borderRadius: "var(--radius-sm)",
|
||
border: "1px solid var(--color-border)",
|
||
background: "var(--color-surface)",
|
||
color: "var(--color-text-muted)",
|
||
cursor: "pointer",
|
||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||
transition: "opacity 150ms",
|
||
}}
|
||
>
|
||
<EyeIcon open={showToken} />
|
||
</button>
|
||
</div>
|
||
<p style={{ margin: 0, fontSize: 11, color: "var(--color-text-hint)" }}>
|
||
هذا الرمز حساس — لا تشاركه مع أي أحد.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<p style={{
|
||
margin: 0, fontSize: 13,
|
||
color: "var(--color-text-muted)",
|
||
fontStyle: "italic",
|
||
}}>
|
||
غير متاح
|
||
</p>
|
||
)}
|
||
</SectionCard>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
} |