nxtgauge-admin-solid/src/routes/admin/users/index.tsx
Tracewebstudio Dev 164bbaf99b
Some checks failed
build-and-release / build (push) Failing after 30s
feat: role registration wizard + shared identity doc pre-fill
- Add RoleWizard inline to SwitchServicesPage after successful role
  registration — same wizard flow as ProfilePage, no separate step
- Pre-fill identity documents (Aadhaar, PAN, selfie, etc.) from the
  user's active role profile so they don't re-upload on second role
- Add identity_shared flag to RuntimeOnboardingField for admin-
  configurable per-field opt-in; CONVENTION_IDENTITY_FIELD_IDS covers
  common IDs without schema changes
- Extract roleKeyToPrefix() into src/lib/role-utils.ts (shared util)
- Fix solid/reactivity: snapshot portfolioForm()/form()/docUrls()
  synchronously before the first await in handleSubmit
- Reuse badge (♻ purple) distinguishes pre-filled docs from new uploads;
  Change link clears the pre-fill and lets user re-upload
- Pending roles now show 'Under Review' status badge instead of Switch
- ArrowLeft back-button in header exits wizard without full page reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 14:55:25 +02:00

1073 lines
50 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 { For, Show, createMemo, createSignal, onMount } from "solid-js";
type RegisteredRole = {
key: string;
name: string;
status: "ACTIVE" | "PENDING" | "INACTIVE";
registeredOn?: string;
};
type ExternalUserRecord = {
id: string;
userCode: string;
name: string;
email: string;
phone?: string;
location: string;
joinedOn: string;
lastActive?: string;
userType: "CUSTOMER" | "PROFESSIONAL" | "COMPANY" | "JOBSEEKER" | "NONE";
accountStatus: "ACTIVE" | "INACTIVE" | "SUSPENDED" | "BLOCKED";
verificationStatus:
| "UNVERIFIED"
| "PENDING"
| "IN_REVIEW"
| "VERIFIED"
| "REJECTED"
| "RE_UPLOAD_REQUESTED";
onboardingStatus: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED" | "COMPLETED";
registeredRoles: RegisteredRole[];
portfolioCount: number;
notes?: string;
updatedAt: string;
};
function StatusBadge(props: { status: string }) {
const active = () =>
props.status === "ACTIVE" || props.status === "VERIFIED" || props.status === "COMPLETED";
const pending = () =>
props.status === "PENDING" ||
props.status === "IN_REVIEW" ||
props.status === "SUBMITTED" ||
props.status === "IN_PROGRESS";
const suspended = () =>
props.status === "SUSPENDED" || props.status === "REJECTED" || props.status === "BLOCKED";
return (
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? "#B7E4C7" : pending() ? "#FDE68A" : suspended() ? "#FECACA" : "#D1D5DB"};background:${active() ? "#DEF7E8" : pending() ? "#FFFBEB" : suspended() ? "#FEF2F2" : "#F3F4F6"};color:${active() ? "#166534" : pending() ? "#92400E" : suspended() ? "#B91C1C" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:600`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? "#16A34A" : pending() ? "#D97706" : suspended() ? "#DC2626" : "#9CA3AF"};margin-right:5px;flex-shrink:0`}
/>
{props.status
.split("_")
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
.join(" ")}
</span>
);
}
function RoleChip(props: { role: RegisteredRole }) {
return (
<span
style={`display:inline-flex;align-items:center;height:24px;padding:0 10px;border-radius:8px;font-size:11px;font-weight:600;border:1px solid #E5E7EB;background:${props.role.status === "ACTIVE" ? "#EEF2FF" : props.role.status === "PENDING" ? "#FFF7ED" : "#F3F4F6"};color:${props.role.status === "ACTIVE" ? "#3730A3" : props.role.status === "PENDING" ? "#C2410C" : "#6B7280"}`}
>
{props.role.name}
</span>
);
}
export default function UsersManagementPage() {
const [rows, setRows] = createSignal<ExternalUserRecord[]>([]);
const [listTab, setListTab] = createSignal<
"all" | "no_role" | "registered" | "pending" | "approved" | "suspended" | "view"
>("all");
const [detailTab, setDetailTab] = createSignal<
"overview" | "personal" | "roles" | "portfolio" | "verification" | "activity" | "notes"
>("overview");
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "name_asc" | "name_desc">(
"newest"
);
const [statusFilter, setStatusFilter] = createSignal<
"all" | "active" | "pending" | "suspended" | "blocked"
>("all");
const [roleFilter, setRoleFilter] = createSignal<
"all" | "no_role" | "professional" | "company" | "jobseeker" | "customer"
>("all");
const [sortOpen, setSortOpen] = createSignal(false);
const [filterOpen, setFilterOpen] = createSignal(false);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [selectedUser, setSelectedUser] = createSignal<ExternalUserRecord | null>(null);
const [loadError, setLoadError] = createSignal("");
const [actionError, setActionError] = createSignal("");
const currentUser = createMemo<ExternalUserRecord | null>(() => selectedUser());
const authHeaders = (): Record<string, string> => {
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
};
const load = async () => {
try {
setLoadError("");
const r = await fetch("/api/admin/users", {
headers: {
Accept: "application/json",
...authHeaders(),
},
credentials: "include",
});
if (!r.ok) throw new Error("Failed to fetch users");
const data = await r.json();
const mapped: ExternalUserRecord[] = (Array.isArray(data) ? data : []).map((u: any) => ({
id: u.id,
userCode: u.reference_number || String(u.id).slice(0, 8).toUpperCase(),
name:
u.first_name || u.last_name
? `${u.first_name || ""} ${u.last_name || ""}`.trim()
: u.email.split("@")[0],
email: u.email,
location: "Not Specified",
joinedOn: u.created_at ? u.created_at.slice(0, 10) : "—",
userType:
(u.roles || []).length === 0
? "NONE"
: u.roles.includes("COMPANY")
? "COMPANY"
: u.roles.includes("JOBSEEKER")
? "JOBSEEKER"
: u.roles.includes("CUSTOMER")
? "CUSTOMER"
: "PROFESSIONAL",
accountStatus: (u.status || "ACTIVE").toUpperCase(),
verificationStatus: "VERIFIED",
onboardingStatus: "COMPLETED",
registeredRoles: (u.roles || []).map((r: string) => ({
key: r.toLowerCase(),
name: r
.split("_")
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
.join(" "),
status: "ACTIVE",
})),
portfolioCount: 0,
updatedAt: u.updated_at || u.created_at || "—",
}));
setRows(mapped);
} catch (e) {
console.error(e);
setRows([]);
setLoadError("Failed to load users. Please refresh or try again.");
}
};
onMount(() => void load());
const metrics = createMemo(() => {
const all = rows();
const noRoleUsers = all.filter((u) => u.registeredRoles.length === 0);
const activeRoleUsers = all.filter((u) => u.registeredRoles.some((r) => r.status === "ACTIVE"));
const pendingRoles = all.filter((u) => u.registeredRoles.some((r) => r.status === "PENDING"));
const suspended = all.filter((u) => u.accountStatus === "SUSPENDED");
const newThisMonth = all.filter((u) => String(u.joinedOn || "").startsWith("2026-04")).length;
return {
totalUsers: all.length,
noRoleUsers: noRoleUsers.length,
activeRoleUsers: activeRoleUsers.length,
pendingRoles: pendingRoles.length,
suspended: suspended.length,
newThisMonth,
};
});
const INTERNAL_ROLES = ["SUPER_ADMIN", "ADMIN", "SUPPORT"];
const scopedRows = createMemo(() => {
let list = rows().filter((u) => {
const hasExternalRole = u.registeredRoles.some(
(r) => !INTERNAL_ROLES.includes(r.key.toUpperCase())
);
const hasNoRole = u.registeredRoles.length === 0;
return hasExternalRole || hasNoRole;
});
if (listTab() === "no_role") list = list.filter((u) => u.registeredRoles.length === 0);
if (listTab() === "registered") list = list.filter((u) => u.registeredRoles.length > 0);
if (listTab() === "pending")
list = list.filter(
(u) =>
u.registeredRoles.some((r) => r.status === "PENDING") ||
u.verificationStatus === "PENDING" ||
u.verificationStatus === "IN_REVIEW"
);
if (listTab() === "approved") list = list.filter((u) => u.verificationStatus === "VERIFIED");
if (listTab() === "suspended") list = list.filter((u) => u.accountStatus === "SUSPENDED");
if (statusFilter() === "active") list = list.filter((u) => u.accountStatus === "ACTIVE");
if (statusFilter() === "pending")
list = list.filter(
(u) => u.verificationStatus === "PENDING" || u.verificationStatus === "IN_REVIEW"
);
if (statusFilter() === "suspended") list = list.filter((u) => u.accountStatus === "SUSPENDED");
if (statusFilter() === "blocked") list = list.filter((u) => u.accountStatus === "BLOCKED");
if (roleFilter() === "no_role") list = list.filter((u) => u.registeredRoles.length === 0);
if (roleFilter() === "professional") list = list.filter((u) => u.userType === "PROFESSIONAL");
if (roleFilter() === "company") list = list.filter((u) => u.userType === "COMPANY");
if (roleFilter() === "jobseeker") list = list.filter((u) => u.userType === "JOBSEEKER");
if (roleFilter() === "customer") list = list.filter((u) => u.userType === "CUSTOMER");
const q = search().trim().toLowerCase();
if (q) {
list = list.filter((u) =>
[u.userCode, u.name, u.email, u.location].some((v) =>
String(v || "")
.toLowerCase()
.includes(q)
)
);
}
const sorted = [...list];
sorted.sort((a, b) => {
if (sortBy() === "name_asc") return a.name.localeCompare(b.name);
if (sortBy() === "name_desc") return b.name.localeCompare(a.name);
if (sortBy() === "oldest") return String(a.joinedOn).localeCompare(String(b.joinedOn));
return String(b.joinedOn).localeCompare(String(a.joinedOn));
});
return sorted;
});
const exportCsv = () => {
const headers = [
"User ID",
"Name",
"Email",
"Type",
"Registered Roles",
"Role Count",
"Status",
"Joined On",
];
const lines = scopedRows().map((u) => [
u.userCode,
u.name,
u.email,
u.userType,
u.registeredRoles.map((r) => r.name).join(" | ") || "No Role Assigned",
String(u.registeredRoles.length),
u.accountStatus,
u.joinedOn,
]);
const csv = [headers, ...lines]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `users-management-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const openView = (user: ExternalUserRecord) => {
setSelectedUser(user);
setDetailTab("overview");
setListTab("view");
setOpenMenuId(null);
};
const applyAccountStatus = async (user: ExternalUserRecord, nextStatus: string) => {
const previousStatus = user.accountStatus;
setActionError("");
setOpenMenuId(null);
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus as any } : r))
);
if (selectedUser()?.id === user.id)
setSelectedUser({ ...user, accountStatus: nextStatus as any });
try {
const r = await fetch(`/api/admin/users/${user.id}/status`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...authHeaders(),
},
credentials: "include",
body: JSON.stringify({ status: nextStatus }),
});
if (!r.ok) throw new Error("Failed to update user status");
} catch (e) {
console.error(e);
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: previousStatus } : r))
);
if (selectedUser()?.id === user.id)
setSelectedUser({ ...user, accountStatus: previousStatus });
setActionError(`Failed to update status for ${user.name}. Please try again.`);
}
};
const toggleSuspend = (user: ExternalUserRecord) => {
void applyAccountStatus(user, user.accountStatus === "SUSPENDED" ? "ACTIVE" : "SUSPENDED");
};
const toggleBlock = (user: ExternalUserRecord) => {
void applyAccountStatus(user, user.accountStatus === "BLOCKED" ? "ACTIVE" : "BLOCKED");
};
return (
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Users Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Manage all external user accounts, monitor registered roles, and review complete user
activity.
</p>
</div>
<Show when={loadError()}>
<div style="margin-bottom:16px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;padding:12px 16px;font-size:13px;font-weight:500">
{loadError()}
</div>
</Show>
<Show when={actionError()}>
<div style="margin-bottom:16px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;padding:12px 16px;font-size:13px;font-weight:500">
{actionError()}
</div>
</Show>
<Show when={listTab() !== "view"}>
<div style="display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px">
<For each={[
{ label: "Total Users", value: metrics().totalUsers, accent: "#111827" },
{ label: "No Role Users", value: metrics().noRoleUsers, accent: "#111827" },
{ label: "Active Role Users", value: metrics().activeRoleUsers, accent: "#111827" },
{ label: "Pending Roles", value: metrics().pendingRoles, accent: "#111827" },
{ label: "Suspended", value: metrics().suspended, accent: "#111827" },
{ label: "New This Month", value: metrics().newThisMonth, accent: "#111827" },
]}>{(card) => (
<div style="border:1px solid #E5E7EB;border-radius:14px;background:white;padding:16px 18px;min-height:100px">
<p style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#667085">
{card.label}
</p>
<p
style={`margin-top:8px;font-size:40px;line-height:1;font-weight:700;color:${card.accent}`}
>
{card.value}
</p>
</div>
)}</For>
</div>
</Show>
<div style="display:flex;align-items:center;gap:24px;min-height:44px;border-bottom:1px solid #E5E7EB;overflow:auto">
<For each={[
{ key: "all", label: `All Users (${rows().length})` },
{ key: "no_role", label: `No Role Users (${metrics().noRoleUsers})` },
{ key: "registered", label: `Registered Role Users (${metrics().activeRoleUsers})` },
{ key: "pending", label: `Pending Users (${metrics().pendingRoles})` },
{ key: "approved", label: "Approved Users" },
{ key: "suspended", label: `Suspended (${metrics().suspended})` },
{ key: "view", label: "View User" },
] as const}>{(tab) => (
<button
type="button"
onClick={() => setListTab(tab.key)}
style={`height:44px;padding:0 2px;white-space:nowrap;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? "color:#FF5E13;box-shadow:inset 0 -2px 0 #FF5E13" : "color:#6B7280"}`}
>
{tab.label}
</button>
)}</For>
</div>
<Show when={listTab() === "view"}>
<Show when={!selectedUser()}>
<div style="margin-top:18px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No user selected</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">
Open actions menu in table and click <strong>View User</strong>.
</p>
</div>
</Show>
<Show when={selectedUser()}>
<div style="border:1px solid #E5E7EB;border-radius:16px;background:white;overflow:hidden">
<div style="padding:18px 22px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;gap:16px">
<div>
<h2 style="font-size:22px;font-weight:700;color:#111827">
User Profile: {currentUser()?.name}
</h2>
<p style="font-size:14px;color:#6B7280;margin-top:4px">
View and manage account information, registered roles, and activity history.
</p>
</div>
<div style="display:flex;gap:10px">
<button
type="button"
onClick={() => toggleSuspend(currentUser()!)}
style="height:40px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer"
>
{currentUser()?.accountStatus === "SUSPENDED" ? "Activate" : "Suspend"}
</button>
<button
type="button"
onClick={() => toggleBlock(currentUser()!)}
style="height:40px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer"
>
{currentUser()?.accountStatus === "BLOCKED" ? "Unblock" : "Block"}
</button>
<button
type="button"
style="height:40px;border-radius:10px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer"
>
Edit Profile
</button>
</div>
</div>
<div style="padding:18px 22px;display:grid;grid-template-columns:2fr 1fr;gap:14px;border-bottom:1px solid #E5E7EB">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:14px;display:grid;grid-template-columns:120px 1fr 1fr 1fr;gap:14px;align-items:center">
<div style="width:110px;height:110px;border-radius:12px;border:1px solid #E5E7EB;background:#F9FAFB;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#94A3B8">
{currentUser()?.name.charAt(0)}
</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
User ID
</p>
<p style="font-size:20px;font-weight:700;color:#111827">
{currentUser()?.userCode}
</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
Account Status
</p>
<StatusBadge status={currentUser()?.accountStatus || "ACTIVE"} />
</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
Email Address
</p>
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser()?.email}</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
Joined Date
</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.joinedOn}
</p>
</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
Phone
</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.phone || "—"}
</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">
Location
</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.location}
</p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Total Roles</p>
<p style="font-size:30px;font-weight:700;color:#111827">
{currentUser()?.registeredRoles.length || 0}
</p>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Active Roles</p>
<p style="font-size:30px;font-weight:700;color:#FF5E13">
{currentUser()?.registeredRoles.filter((r) => r.status === "ACTIVE").length ||
0}
</p>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Pending</p>
<p style="font-size:30px;font-weight:700;color:#111827">
{currentUser()?.registeredRoles.filter((r) => r.status === "PENDING").length ||
0}
</p>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Portfolio</p>
<p style="font-size:30px;font-weight:700;color:#111827">
{currentUser()?.portfolioCount || 0}
</p>
</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:22px;min-height:44px;border-bottom:1px solid #E5E7EB;padding:0 22px;overflow:auto">
<For each={[
{ key: "overview", label: "Overview" },
{ key: "personal", label: "Personal Details" },
{ key: "roles", label: "Registered Roles" },
{ key: "portfolio", label: "Portfolio" },
{ key: "verification", label: "Verification & Approval" },
{ key: "activity", label: "Activity" },
{ key: "notes", label: "Notes" },
] as const}>{(tab) => (
<button
type="button"
onClick={() => setDetailTab(tab.key)}
style={`height:44px;padding:0 2px;white-space:nowrap;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${detailTab() === tab.key ? "color:#FF5E13;box-shadow:inset 0 -2px 0 #FF5E13" : "color:#6B7280"}`}
>
{tab.label}
</button>
)}</For>
</div>
<div style="padding:22px">
<Show when={detailTab() === "overview"}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:18px;font-weight:700;color:#111827">Personal Summary</p>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">Full Name</span>
<span style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.name}
</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">User Type</span>
<span style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.userType}
</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">Last Active</span>
<span style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.lastActive || "—"}
</span>
</div>
</div>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:18px;font-weight:700;color:#111827">Registered Roles</p>
<Show
when={(currentUser()?.registeredRoles.length || 0) > 0}
fallback={
<p style="margin-top:14px;font-size:14px;color:#6B7280">
No role registered yet.
</p>
}
>
<div style="margin-top:14px;display:flex;flex-wrap:wrap;gap:8px">
<For each={currentUser()?.registeredRoles}>
{(role) => <RoleChip role={role} />}
</For>
</div>
</Show>
</div>
</div>
</Show>
<Show when={detailTab() === "personal"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<p style="font-size:12px;color:#9CA3AF">Name</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.name}
</p>
</div>
<div>
<p style="font-size:12px;color:#9CA3AF">Email</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.email}
</p>
</div>
<div>
<p style="font-size:12px;color:#9CA3AF">Phone</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.phone || "—"}
</p>
</div>
<div>
<p style="font-size:12px;color:#9CA3AF">Location</p>
<p style="font-size:14px;font-weight:600;color:#111827">
{currentUser()?.location}
</p>
</div>
</div>
</Show>
<Show when={detailTab() === "roles"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<Show
when={(currentUser()?.registeredRoles.length || 0) > 0}
fallback={
<p style="font-size:14px;color:#6B7280">No role registrations yet.</p>
}
>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
<For each={currentUser()?.registeredRoles}>
{(role) => (
<div style="border:1px solid #E5E7EB;border-radius:10px;padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center">
<p style="font-size:15px;font-weight:700;color:#111827">
{role.name}
</p>
<StatusBadge status={role.status} />
</div>
<p style="margin-top:6px;font-size:12px;color:#6B7280">
Registered on {role.registeredOn || "—"}
</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={detailTab() === "portfolio"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827;font-weight:600">
Portfolio assets submitted: {currentUser()?.portfolioCount || 0}
</p>
</div>
</Show>
<Show when={detailTab() === "verification"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;display:flex;gap:12px;align-items:center">
<StatusBadge status={currentUser()?.verificationStatus || "VERIFIED"} />
<span style="font-size:14px;color:#374151">
Onboarding:{" "}
{currentUser()?.onboardingStatus.split("_").join(" ") || "COMPLETED"}
</span>
</div>
</Show>
<Show when={detailTab() === "activity"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827;font-weight:600">
Last Active: {currentUser()?.lastActive || "—"}
</p>
<p style="font-size:13px;color:#6B7280;margin-top:8px">
Joined on {currentUser()?.joinedOn}
</p>
</div>
</Show>
<Show when={detailTab() === "notes"}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827">
{currentUser()?.notes || "No notes yet."}
</p>
</div>
</Show>
</div>
<div style="display:flex;gap:10px;padding:14px 22px;border-top:1px solid #E5E7EB">
<button
type="button"
onClick={() => setListTab("all")}
style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer"
>
Back to List
</button>
</div>
</div>
</Show>
</Show>
<Show when={listTab() !== "view"}>
<div style="position:relative;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
placeholder="Search by ID, name or email..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<div style="position:relative">
<button
type="button"
onClick={() => {
setSortOpen((v) => !v);
setFilterOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M7 4v13" />
<path d="m3 13 4 4 4-4" />
<path d="M17 20V7" />
<path d="m21 11-4-4-4 4" />
</svg>
Sort
</button>
<Show when={sortOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
<For each={[
{ key: "newest", label: "Newest First" },
{ key: "oldest", label: "Oldest First" },
{ key: "name_asc", label: "Name (A-Z)" },
{ key: "name_desc", label: "Name (Z-A)" },
] as const}>{(item) => (
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}</For>
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => {
setFilterOpen((v) => !v);
setSortOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
Filters
</button>
<Show when={filterOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:220px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.05em;padding:4px 8px">
Status
</p>
<For each={[
{ key: "all", label: "All Status" },
{ key: "active", label: "Active" },
{ key: "pending", label: "Pending" },
{ key: "suspended", label: "Suspended" },
{ key: "blocked", label: "Blocked" },
] as const}>{(item) => (
<button
type="button"
onClick={() => {
setStatusFilter(item.key);
setFilterOpen(false);
}}
style={`display:block;width:100%;border:none;background:${statusFilter() === item.key ? "#FFF1EB" : "transparent"};color:${statusFilter() === item.key ? "#FF5E13" : "#374151"};padding:8px 10px;border-radius:8px;text-align:left;font-size:13px;cursor:pointer`}
>
{item.label}
</button>
)}</For>
<div style="height:1px;background:#F3F4F6;margin:6px 0" />
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.05em;padding:4px 8px">
Role Group
</p>
<For each={[
{ key: "all", label: "All Roles" },
{ key: "no_role", label: "No Role Users" },
{ key: "professional", label: "Professionals" },
{ key: "company", label: "Companies" },
{ key: "jobseeker", label: "Job Seekers" },
{ key: "customer", label: "Service Seekers" },
] as const}>{(item) => (
<button
type="button"
onClick={() => {
setRoleFilter(item.key);
setFilterOpen(false);
}}
style={`display:block;width:100%;border:none;background:${roleFilter() === item.key ? "#FFF1EB" : "transparent"};color:${roleFilter() === item.key ? "#FF5E13" : "#374151"};padding:8px 10px;border-radius:8px;text-align:left;font-size:13px;cursor:pointer`}
>
{item.label}
</button>
)}</For>
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
</div>
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
User ID
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
First Name
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Last Name
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Email
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
User Type
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Roles
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Status
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Verification
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">
Joined
</th>
<th style="padding:10px 16px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap;text-align:center">
Actions
</th>
</tr>
</thead>
<tbody>
<Show
when={scopedRows().length > 0}
fallback={
<tr>
<td colSpan={10} style="padding:32px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">
{loadError() ? "Unable to load users" : "No users found"}
</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">
{loadError()
? "There was a problem contacting the server. Try refreshing the page."
: "Try changing filters or search."}
</p>
</td>
</tr>
}
>
<For each={scopedRows()}>
{(user) => {
const nameParts = user.name.split(" ");
const firstName = nameParts[0] || "";
const lastName = nameParts.slice(1).join(" ") || "";
return (
<tr
style="border-bottom:1px solid #F3F4F6"
class="hover:bg-[#FAFAFA] transition-colors"
>
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#334155;white-space:nowrap">
{user.userCode}
</td>
<td style="padding:12px 16px;font-size:13px;color:#111827">
{firstName}
</td>
<td style="padding:12px 16px;font-size:13px;color:#111827">{lastName}</td>
<td style="padding:12px 16px;font-size:13px;color:#374151;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{user.email}
</td>
<td style="padding:12px 16px">
<span
style={`font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px;${user.userType === "NONE" ? "background:#FEF3C7;color:#92400E" : user.userType === "COMPANY" ? "background:#DBEAFE;color:#1E40AF" : user.userType === "JOBSEEKER" ? "background:#D1FAE5;color:#065F46" : user.userType === "CUSTOMER" ? "background:#EDE9FE;color:#5B21B6" : "background:#F3F4F6;color:#374151"}`}
>
{user.userType}
</span>
</td>
<td style="padding:12px 16px;min-width:160px">
<Show
when={user.registeredRoles.length > 0}
fallback={
<span style="font-size:11px;color:#94A3B8;font-style:italic">
No Role
</span>
}
>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<For each={user.registeredRoles.slice(0, 2)}>
{(role) => <RoleChip role={role} />}
</For>
{user.registeredRoles.length > 2 && (
<span style="font-size:10px;color:#6B7280">
+{user.registeredRoles.length - 2}
</span>
)}
</div>
</Show>
</td>
<td style="padding:12px 16px">
<StatusBadge status={user.accountStatus} />
</td>
<td style="padding:12px 16px">
<StatusBadge status={user.verificationStatus} />
</td>
<td style="padding:12px 16px;font-size:12px;color:#64748B;white-space:nowrap">
{user.joinedOn}
</td>
<td style="padding:12px 16px;white-space:nowrap">
<div style="display:flex;gap:6px;justify-content:center">
<button
type="button"
onClick={() => openView(user)}
title="View User"
style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#FFFFFF;background:#0D0D2A;border:none;cursor:pointer"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="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>
</button>
<button
type="button"
title={
user.accountStatus === "SUSPENDED"
? "Activate User"
: "Suspend User"
}
onClick={() => toggleSuspend(user)}
style={`display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;border:none;cursor:pointer;${user.accountStatus === "SUSPENDED" ? "background:#D1FAE5;color:#065F46" : "background:#FEF3C7;color:#92400E"}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
{user.accountStatus === "SUSPENDED" ? (
<path d="M8 12l3 3 5-5" />
) : (
<path d="M4.9 4.9 19.1 19.1" />
)}
</svg>
</button>
<button
type="button"
title={
user.accountStatus === "BLOCKED" ? "Unblock User" : "Block User"
}
onClick={() => toggleBlock(user)}
style={`display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;border:none;cursor:pointer;${user.accountStatus === "BLOCKED" ? "background:#D1FAE5;color:#065F46" : "background:#FEE2E2;color:#B91C1C"}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
{user.accountStatus === "BLOCKED" ? (
<path d="M8 12l3 3 5-5" />
) : (
<>
<path d="M8 8l8 8" />
<path d="M16 8l-8 8" />
</>
)}
</svg>
</button>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
<Show when={scopedRows().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing{" "}
<strong style="font-weight:600;color:#111827">1{scopedRows().length}</strong> of{" "}
<strong style="font-weight:600;color:#111827">{rows().length}</strong> users
</p>
<div style="display:flex;align-items:center;gap:4px">
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
>
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer"
>
1
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
>
2
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
>
3
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
>
</button>
</div>
</div>
</Show>
</div>
</Show>
</div>
);
}