feat(admin-ui): align detail views with next layout patterns

This commit is contained in:
Ashwin Kumar 2026-03-19 15:14:15 +01:00
parent 97f9317797
commit 19bacc5ab3
5 changed files with 747 additions and 100 deletions

View file

@ -912,7 +912,9 @@ body {
}
.grid,
.list-grid {
.list-grid,
.field-grid-2,
.detail-layout {
grid-template-columns: 1fr;
}
@ -1105,6 +1107,113 @@ body {
gap: 16px;
}
.kv-item {
margin: 0;
}
.kv-label {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.kv-value {
margin: 6px 0 0;
font-size: 14px;
color: #0f172a;
font-weight: 600;
}
.status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid #cbd5e1;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-approved {
border-color: #86efac;
background: #f0fdf4;
color: #166534;
}
.status-pending {
border-color: #fcd34d;
background: #fffbeb;
color: #92400e;
}
.status-rejected {
border-color: #fca5a5;
background: #fef2f2;
color: #991b1b;
}
.status-info {
border-color: #93c5fd;
background: #eff6ff;
color: #1d4ed8;
}
.identity-avatar {
width: 62px;
height: 62px;
border-radius: 16px;
background: linear-gradient(135deg, #fd6216 0%, #050026 100%);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 800;
}
.mini-stat {
min-width: 90px;
text-align: center;
}
.mini-stat-value {
margin: 0;
font-size: 26px;
font-weight: 800;
color: #0f172a;
}
.mini-stat-label {
margin: 4px 0 0;
font-size: 11px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.meta-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-weight: 600;
padding: 6px 10px;
}
.detail-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.sub-card {
border: 1px solid #e2e8f0;
border-radius: 12px;
@ -1113,6 +1222,16 @@ body {
margin-top: 12px;
}
.sub-card-success {
border-color: #86efac;
background: #f0fdf4;
}
.sub-card-warning {
border-color: #fcd34d;
background: #fffbeb;
}
.sub-card-header {
display: flex;
align-items: center;

View file

@ -4,7 +4,7 @@ import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type CompanyDetail = {
type CompanyCore = {
id: string;
company_name?: string;
companyName?: string;
@ -18,21 +18,64 @@ type CompanyDetail = {
city?: string;
state?: string;
address?: string;
description?: string;
status?: string;
is_verified?: boolean;
isVerified?: boolean;
description?: string;
pan?: string;
gst?: string;
tan?: string;
strength?: number;
gstVerified?: boolean;
mcaVerified?: boolean;
createdAt?: string;
updatedAt?: string;
updatedBy?: string;
};
async function fetchCompany(id: string): Promise<CompanyDetail | null> {
type CompanyContact = {
city?: string;
state?: string;
pinCode?: string;
linkedin?: string;
instagram?: string;
twitter?: string;
};
type CompanyBanking = {
bankName?: string;
accountNumber?: string;
ifscCode?: string;
branchName?: string;
accountHolderName?: string;
isVerified?: boolean;
};
type CompanyBundle = {
company: CompanyCore;
contact: CompanyContact | null;
banking: CompanyBanking | null;
};
async function fetchCompanyBundle(id: string): Promise<CompanyBundle | null> {
try {
const res = await fetch(`${API}/api/admin/companies/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.company || data;
if (data.company) {
return {
company: data.company,
contact: data.contact || null,
banking: data.banking || null,
};
}
return {
company: data,
contact: null,
banking: null,
};
} catch {
return null;
}
@ -40,10 +83,14 @@ async function fetchCompany(id: string): Promise<CompanyDetail | null> {
export default function CompanyDetailPage() {
const params = useParams();
const [company] = createResource(() => params.id, fetchCompany);
const [bundle] = createResource(() => params.id, fetchCompanyBundle);
const company = createMemo(() => bundle()?.company || null);
const contact = createMemo(() => bundle()?.contact || null);
const banking = createMemo(() => bundle()?.banking || null);
const name = createMemo(() => company()?.company_name || company()?.companyName || 'Company');
const cid = createMemo(() => company()?.company_id || company()?.companyId || company()?.id || '—');
const companyId = createMemo(() => company()?.company_id || company()?.companyId || company()?.id || '—');
const website = createMemo(() => company()?.website_url || company()?.websiteUrl || '');
const isVerified = createMemo(() => Boolean(company()?.is_verified ?? company()?.isVerified));
@ -51,50 +98,201 @@ export default function CompanyDetailPage() {
<AdminShell>
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Company Detail</h1>
<p class="page-subtitle">Review company profile, contact details, and verification readiness.</p>
<h1 class="page-title">{name()}</h1>
<p class="page-subtitle">{companyId()}</p>
</div>
<div class="page-actions-right">
<span class={`status-pill ${isVerified() ? 'status-approved' : 'status-pending'}`}>
{isVerified() ? 'Verified - Can Post Jobs' : 'Not Verified'}
</span>
<A class="btn" href="/admin/company">Back to Companies</A>
<A class="btn navy" href="/admin/approval">Open Approval Management</A>
</div>
</div>
<Show when={company.loading}>
<Show when={bundle.loading}>
<div class="card"><p class="notice">Loading company...</p></div>
</Show>
<Show when={!company.loading && !company()}>
<Show when={!bundle.loading && !company()}>
<div class="card"><p class="notice">Company not found.</p></div>
</Show>
<Show when={company()}>
<div class="grid" style="margin-top:0">
<>
<div class="field-grid-2" style="margin-bottom:16px">
<div class="card">
<p class="kv-label">Email</p>
<p class="kv-value">{company()!.email || '—'}</p>
</div>
<div class="card">
<p class="kv-label">Phone</p>
<p class="kv-value">{company()!.phone || '—'}</p>
</div>
<div class="card" style="grid-column:1 / -1">
<p class="kv-label">Website</p>
<p class="kv-value">
<Show when={website()} fallback={'—'}>
<a href={website()} target="_blank" rel="noreferrer">{website()}</a>
</Show>
</p>
</div>
</div>
<div class="detail-layout">
<div style="display:flex;flex-direction:column;gap:16px">
<section class="card">
<h2 style="margin-bottom:8px">Company</h2>
<p class="notice" style="margin:0"><strong>Name:</strong> {name()}</p>
<p class="notice" style="margin:8px 0 0"><strong>Company ID:</strong> {cid()}</p>
<p class="notice" style="margin:8px 0 0"><strong>Industry:</strong> {company()!.industry || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {company()!.status || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Verification:</strong> {isVerified() ? 'Verified' : 'Not Verified'}</p>
<h2 style="margin-bottom:8px">Company Information</h2>
<div class="field-grid-2">
<div class="kv-item">
<p class="kv-label">Company Name</p>
<p class="kv-value">{name()}</p>
</div>
<div class="kv-item">
<p class="kv-label">Company ID</p>
<p class="kv-value">{companyId()}</p>
</div>
<div class="kv-item">
<p class="kv-label">Industry</p>
<p class="kv-value">{company()!.industry || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Company Size</p>
<p class="kv-value">{company()!.strength ? `${company()!.strength} employees` : '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">PAN</p>
<p class="kv-value">{company()!.pan || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">GST</p>
<p class="kv-value">{company()!.gst || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">TAN</p>
<p class="kv-value">{company()!.tan || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Status</p>
<p class="kv-value">{company()!.status || 'UNKNOWN'}</p>
</div>
<div class="kv-item" style="grid-column:1 / -1">
<p class="kv-label">Address</p>
<p class="kv-value">{company()!.address || '—'}</p>
</div>
<div class="kv-item" style="grid-column:1 / -1">
<p class="kv-label">Description</p>
<p class="kv-value">{company()!.description || '—'}</p>
</div>
</div>
</section>
<section class="card">
<h2 style="margin-bottom:8px">Contact</h2>
<p class="notice" style="margin:0"><strong>Email:</strong> {company()!.email || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Phone:</strong> {company()!.phone || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>City/State:</strong> {[company()!.city, company()!.state].filter(Boolean).join(', ') || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Address:</strong> {company()!.address || '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Website:</strong> {website() ? <a href={website()} target="_blank" rel="noreferrer">{website()}</a> : '—'}</p>
<h2 style="margin-bottom:8px">Contact Details</h2>
<div class="field-grid-2">
<div class="kv-item">
<p class="kv-label">City</p>
<p class="kv-value">{contact()?.city || company()!.city || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">State</p>
<p class="kv-value">{contact()?.state || company()!.state || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">PIN Code</p>
<p class="kv-value">{contact()?.pinCode || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">LinkedIn</p>
<p class="kv-value">{contact()?.linkedin || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Instagram</p>
<p class="kv-value">{contact()?.instagram || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Twitter</p>
<p class="kv-value">{contact()?.twitter || '—'}</p>
</div>
</div>
</section>
<section class="card">
<h2 style="margin-bottom:8px">Banking Details</h2>
<div class="field-grid-2">
<div class="kv-item">
<p class="kv-label">Account Holder</p>
<p class="kv-value">{banking()?.accountHolderName || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Account Number</p>
<p class="kv-value">{banking()?.accountNumber || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Bank Name</p>
<p class="kv-value">{banking()?.bankName || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Branch</p>
<p class="kv-value">{banking()?.branchName || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">IFSC Code</p>
<p class="kv-value">{banking()?.ifscCode || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Banking Verification</p>
<p class="kv-value">{banking()?.isVerified ? 'Verified' : 'Not Verified'}</p>
</div>
</div>
</section>
</div>
<section class="card" style="margin-top:16px">
<h2 style="margin-bottom:8px">Compliance</h2>
<div class="field-grid-2">
<p class="notice" style="margin:0"><strong>PAN:</strong> {company()!.pan || '—'}</p>
<p class="notice" style="margin:0"><strong>GST:</strong> {company()!.gst || '—'}</p>
<p class="notice" style="margin:0"><strong>TAN:</strong> {company()!.tan || '—'}</p>
<div style="display:flex;flex-direction:column;gap:16px">
<section class="card">
<h2 style="margin-bottom:8px">Verification Status</h2>
<div class={`sub-card ${isVerified() ? 'sub-card-success' : 'sub-card-warning'}`} style="margin-top:0">
<p class="kv-label">Overall Status</p>
<p class="kv-value" style="font-size:18px">{isVerified() ? 'Company Verified' : 'Pending Verification'}</p>
<p class="notice" style="margin:8px 0 0">
{isVerified() ? 'This company can post job listings.' : 'Company cannot post jobs until verified.'}
</p>
</div>
<div class="sub-card">
<div class="kv-item">
<p class="kv-label">GST Verification</p>
<p class="kv-value">{company()!.gstVerified ? 'Verified' : 'Not Verified'}</p>
</div>
<div class="kv-item">
<p class="kv-label">MCA Verification</p>
<p class="kv-value">{company()!.mcaVerified ? 'Verified' : 'Not Verified'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Banking Verification</p>
<p class="kv-value">{banking()?.isVerified ? 'Verified' : 'Not Verified'}</p>
</div>
</div>
</section>
<section class="card">
<h2 style="margin-bottom:8px">Metadata</h2>
<div class="kv-item">
<p class="kv-label">Created At</p>
<p class="kv-value">{company()!.createdAt ? new Date(company()!.createdAt!).toLocaleString() : '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Last Updated</p>
<p class="kv-value">{company()!.updatedAt ? new Date(company()!.updatedAt!).toLocaleString() : '—'}</p>
</div>
<Show when={company()!.updatedBy}>
<div class="kv-item">
<p class="kv-label">Updated By</p>
<p class="kv-value">{company()!.updatedBy}</p>
</div>
</Show>
</section>
</div>
</div>
</>
</Show>
</AdminShell>
);

View file

@ -20,12 +20,40 @@ type Lead = {
updated_at?: string;
};
async function fetchLead(id: string): Promise<Lead | null> {
type Requirement = {
id: string;
title?: string;
description?: string;
customerName?: string;
budgetMin?: number;
budgetMax?: number;
location?: string;
status?: string;
};
type LeadBundle = {
lead: Lead;
requirement: Requirement | null;
};
async function fetchLeadBundle(id: string): Promise<LeadBundle | null> {
try {
const res = await fetch(`${API}/api/leads/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.lead || data;
const lead: Lead = data.lead || data;
const requirementId = lead.requirementId || lead.requirement_id || '';
if (!requirementId) return { lead, requirement: null };
try {
const reqRes = await fetch(`${API}/api/requirements/${requirementId}`);
if (!reqRes.ok) return { lead, requirement: null };
const reqData = await reqRes.json();
return { lead, requirement: reqData.requirement || reqData || null };
} catch {
return { lead, requirement: null };
}
} catch {
return null;
}
@ -33,8 +61,10 @@ async function fetchLead(id: string): Promise<Lead | null> {
export default function LeadDetailPage() {
const params = useParams();
const [lead] = createResource(() => params.id, fetchLead);
const [bundle] = createResource(() => params.id, fetchLeadBundle);
const lead = createMemo(() => bundle()?.lead || null);
const requirement = createMemo(() => bundle()?.requirement || null);
const requirementId = createMemo(() => lead()?.requirementId || lead()?.requirement_id || '');
const customerId = createMemo(() => lead()?.customerId || lead()?.customer_id || '');
const professionalId = createMemo(() => lead()?.professionalId || lead()?.professional_id || '');
@ -51,30 +81,84 @@ export default function LeadDetailPage() {
<A class="btn" href="/admin/leads">Back to Leads</A>
</div>
<Show when={lead.loading}>
<Show when={bundle.loading}>
<div class="card"><p class="notice">Loading lead...</p></div>
</Show>
<Show when={!lead.loading && !lead()}>
<Show when={!bundle.loading && !lead()}>
<div class="card"><p class="notice">Lead not found.</p></div>
</Show>
<Show when={lead()}>
<section class="card">
<section class="card" style="padding:20px">
<div class="field-grid-2">
<p class="notice" style="margin:0"><strong>Lead ID:</strong> {lead()!.id}</p>
<p class="notice" style="margin:0"><strong>Status:</strong> {lead()!.status || '—'}</p>
<p class="notice" style="margin:0"><strong>Profession:</strong> {lead()!.profession || '—'}</p>
<p class="notice" style="margin:0"><strong>Requirement ID:</strong> {requirementId() || '—'}</p>
<p class="notice" style="margin:0"><strong>Customer ID:</strong> {customerId() || '—'}</p>
<p class="notice" style="margin:0"><strong>Professional ID:</strong> {professionalId() || 'Unassigned'}</p>
<p class="notice" style="margin:0"><strong>Created:</strong> {createdAt() ? new Date(createdAt()).toLocaleString() : '—'}</p>
<p class="notice" style="margin:0"><strong>Updated:</strong> {updatedAt() ? new Date(updatedAt()).toLocaleString() : '—'}</p>
<div class="kv-item">
<p class="kv-label">Lead ID</p>
<p class="kv-value">{lead()!.id}</p>
</div>
<div class="kv-item">
<p class="kv-label">Status</p>
<p class="kv-value">{lead()!.status || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Profession</p>
<p class="kv-value">{lead()!.profession || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Professional ID</p>
<p class="kv-value">{professionalId() || 'Unassigned'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Customer ID</p>
<p class="kv-value">{customerId() || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Created</p>
<p class="kv-value">{createdAt() ? new Date(createdAt()).toLocaleString() : '—'}</p>
</div>
<Show when={updatedAt()}>
<div class="kv-item">
<p class="kv-label">Updated</p>
<p class="kv-value">{new Date(updatedAt()!).toLocaleString()}</p>
</div>
</Show>
<div class="kv-item">
<p class="kv-label">Requirement ID</p>
<p class="kv-value">{requirementId() || '—'}</p>
</div>
</div>
<div class="actions">
<Show when={requirementId()}>
<A class="btn" href={`/admin/jobs/${requirementId()}`}>Open Linked Requirement/Job</A>
</Show>
</div>
<Show when={requirement()}>
<div class="sub-card">
<div class="sub-card-header">
<h4>Linked Requirement</h4>
</div>
<p style="margin:0;font-size:17px;font-weight:700;color:#111827">{requirement()!.title || 'Untitled Requirement'}</p>
<p class="notice" style="margin-top:8px">{requirement()!.description || 'No description available.'}</p>
<div class="field-grid-2" style="margin-top:12px">
<div class="kv-item">
<p class="kv-label">Customer</p>
<p class="kv-value">{requirement()!.customerName || 'Customer'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Budget</p>
<p class="kv-value">{requirement()!.budgetMin || 0} - {requirement()!.budgetMax || 0}</p>
</div>
<div class="kv-item">
<p class="kv-label">Location</p>
<p class="kv-value">{requirement()!.location || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Requirement Status</p>
<p class="kv-value">{requirement()!.status || '—'}</p>
</div>
</div>
</div>
</Show>
</section>
</Show>
</AdminShell>

View file

@ -1,5 +1,5 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { For, createMemo, createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
@ -17,6 +17,14 @@ type Photographer = {
state?: string;
experience?: string;
portfolio_url?: string;
phoneNumber?: string;
location?: string;
gender?: string;
availability?: string;
permanentAddress?: string;
hometown?: string;
pincode?: string;
languages?: Array<{ language?: string; proficiency?: string }>;
};
async function fetchPhotographer(id: string): Promise<Photographer | null> {
@ -24,8 +32,10 @@ async function fetchPhotographer(id: string): Promise<Photographer | null> {
const res = await fetch(`${API}/api/admin/users/${id}`);
if (res.ok) return res.json();
const fallback = await fetch(`${API}/api/users/${id}`);
if (!fallback.ok) return null;
return fallback.json();
if (fallback.ok) return fallback.json();
const photographerRes = await fetch(`${API}/api/photographers/${id}`);
if (!photographerRes.ok) return null;
return photographerRes.json();
} catch {
return null;
}
@ -61,14 +71,68 @@ export default function PhotographerDetailPage() {
<Show when={profile()}>
<section class="card">
<div class="field-grid-2">
<p class="notice" style="margin:0"><strong>Name:</strong> {name()}</p>
<p class="notice" style="margin:0"><strong>Email:</strong> {profile()!.email || '—'}</p>
<p class="notice" style="margin:0"><strong>Phone:</strong> {profile()!.phone || '—'}</p>
<p class="notice" style="margin:0"><strong>Status:</strong> {profile()!.status || '—'}</p>
<p class="notice" style="margin:0"><strong>City/State:</strong> {[profile()!.city, profile()!.state].filter(Boolean).join(', ') || '—'}</p>
<p class="notice" style="margin:0"><strong>Experience:</strong> {profile()!.experience || '—'}</p>
<p class="notice" style="margin:0"><strong>Created:</strong> {created() ? new Date(created()).toLocaleString() : '—'}</p>
<p class="notice" style="margin:0"><strong>Portfolio:</strong> {profile()!.portfolio_url || '—'}</p>
<div class="kv-item">
<p class="kv-label">Name</p>
<p class="kv-value">{name()}</p>
</div>
<div class="kv-item">
<p class="kv-label">Email</p>
<p class="kv-value">{profile()!.email || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Phone</p>
<p class="kv-value">{profile()!.phone || profile()!.phoneNumber || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Location</p>
<p class="kv-value">{profile()!.location || [profile()!.city, profile()!.state].filter(Boolean).join(', ') || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Gender</p>
<p class="kv-value">{profile()!.gender || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Experience</p>
<p class="kv-value">{profile()!.experience || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Availability</p>
<p class="kv-value">{profile()!.availability || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Permanent Address</p>
<p class="kv-value">{profile()!.permanentAddress || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Hometown</p>
<p class="kv-value">{profile()!.hometown || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Pincode</p>
<p class="kv-value">{profile()!.pincode || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Status</p>
<p class="kv-value">{profile()!.status || '—'}</p>
</div>
<div class="kv-item">
<p class="kv-label">Created</p>
<p class="kv-value">{created() ? new Date(created()).toLocaleString() : '—'}</p>
</div>
<div class="kv-item" style="grid-column:1 / -1">
<p class="kv-label">Portfolio</p>
<p class="kv-value">{profile()!.portfolio_url || '—'}</p>
</div>
<Show when={profile()!.languages && profile()!.languages!.length > 0}>
<div class="kv-item" style="grid-column:1 / -1">
<p class="kv-label">Languages</p>
<ul class="notice" style="margin:8px 0 0;padding-left:18px">
<For each={profile()!.languages!}>
{(item) => <li>{item.language || 'Unknown'} - {item.proficiency || 'N/A'}</li>}
</For>
</ul>
</div>
</Show>
</div>
</section>
</Show>

View file

@ -1,25 +1,49 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { A, useParams, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type User = {
type UserDetail = {
id: string;
publicId?: string;
name?: string;
full_name?: string;
email: string;
role?: string;
role_name?: string;
roleId?: string;
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
email?: string;
status?: string;
profilePicture?: string;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
lastLogin?: string;
accountType?: string;
};
async function fetchUser(id: string): Promise<User | null> {
type RoleProfileSummary = {
id: string;
publicId?: string;
roleType?: string;
verificationStatus?: string;
submittedAt?: string | null;
updatedAt?: string | null;
isApproval?: boolean;
};
type UserBundle = {
user: UserDetail;
roleProfiles: RoleProfileSummary[];
};
const ROLE_LABELS: Record<string, string> = {
customer: 'Customer',
job_seeker: 'Job Seeker',
jobseeker: 'Job Seeker',
photographer: 'Photographer',
tutor: 'Tutor',
developer: 'Developer',
makeup_artist: 'Makeup Artist',
};
async function fetchUser(id: string): Promise<UserDetail | null> {
try {
const adminRes = await fetch(`${API}/api/admin/users/${id}`);
if (adminRes.ok) return adminRes.json();
@ -32,21 +56,115 @@ async function fetchUser(id: string): Promise<User | null> {
}
}
async function fetchRoleProfiles(userId: string): Promise<RoleProfileSummary[]> {
const urls = [
`${API}/api/role-profiles?userId=${encodeURIComponent(userId)}`,
`${API}/api/admin/role-profiles?userId=${encodeURIComponent(userId)}`,
];
for (const url of urls) {
try {
const res = await fetch(url);
if (!res.ok) continue;
const data = await res.json();
if (Array.isArray(data?.profiles)) return data.profiles;
if (Array.isArray(data)) return data;
} catch {
continue;
}
}
return [];
}
async function fetchApprovals(userId: string): Promise<RoleProfileSummary[]> {
try {
const res = await fetch(`${API}/api/approvals?requesterId=${encodeURIComponent(userId)}`);
if (!res.ok) return [];
const data = await res.json();
const list = Array.isArray(data) ? data : data?.approvals || [];
if (!Array.isArray(list)) return [];
return list.map((item: any) => ({
id: item.id,
publicId: item.id,
roleType: item.requestType || item.entityType || 'Role Request',
verificationStatus: item.status || item.currentStatus || 'PENDING',
submittedAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
isApproval: true,
}));
} catch {
return [];
}
}
function normalizeRole(value: string | undefined): string {
return (value || '').toLowerCase().replace(/ /g, '_');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function statusClass(status: string | undefined): string {
const normalized = (status || '').toUpperCase();
if (normalized === 'APPROVED') return 'status-approved';
if (normalized === 'REJECTED') return 'status-rejected';
if (normalized === 'NEEDS_INFO') return 'status-info';
return 'status-pending';
}
async function fetchUserBundle(id: string): Promise<UserBundle | null> {
const user = await fetchUser(id);
if (!user) return null;
const userId = user.id || id;
const [profiles, approvals] = await Promise.all([
fetchRoleProfiles(userId),
fetchApprovals(userId),
]);
const roleProfiles = [...profiles, ...approvals].sort((a, b) => {
const ta = a.submittedAt ? new Date(a.submittedAt).getTime() : 0;
const tb = b.submittedAt ? new Date(b.submittedAt).getTime() : 0;
return tb - ta;
});
return { user, roleProfiles };
}
export default function UserDetailPage() {
const params = useParams();
const [user] = createResource(() => params.id, fetchUser);
const [searchParams] = useSearchParams();
const [bundle] = createResource(() => params.id, fetchUserBundle);
const user = createMemo(() => bundle()?.user || null);
const roleProfiles = createMemo(() => bundle()?.roleProfiles || []);
const roleFilter = createMemo(() => searchParams.roleFilter || '');
const displayName = createMemo(() => user()?.name || user()?.full_name || 'Unknown User');
const roleName = createMemo(() => user()?.role_name || user()?.role || 'UNKNOWN');
const createdAt = createMemo(() => user()?.createdAt || user()?.created_at || '');
const updatedAt = createMemo(() => user()?.updatedAt || user()?.updated_at || '');
const userCreatedAt = createMemo(() => user()?.createdAt || user()?.created_at || '');
const displayedProfiles = createMemo(() => {
const filter = roleFilter();
if (!filter) return roleProfiles();
const normalizedFilter = normalizeRole(filter);
return roleProfiles().filter((item) => normalizeRole(item.roleType) === normalizedFilter);
});
const verifiedCount = createMemo(() => displayedProfiles().filter((rp) => (rp.verificationStatus || '').toUpperCase() === 'APPROVED').length);
const roleTitleLabel = createMemo(() => {
const filter = roleFilter();
if (!filter) return '';
return ROLE_LABELS[filter] || filter.replace(/_/g, ' ');
});
return (
<AdminShell>
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">User Detail</h1>
<p class="page-subtitle">Review account profile, role assignment, and account status.</p>
<h1 class="page-title">{roleTitleLabel() ? `${roleTitleLabel()} Profile` : 'User Details'}</h1>
<p class="page-subtitle">Review account profile and role registration data with approval status.</p>
</div>
<div class="page-actions-right">
<A class="btn" href="/admin/users">Back to Users</A>
@ -54,35 +172,99 @@ export default function UserDetailPage() {
</div>
</div>
<Show when={user.loading}>
<Show when={bundle.loading}>
<div class="card"><p class="notice">Loading user...</p></div>
</Show>
<Show when={!user.loading && !user()}>
<Show when={!bundle.loading && !user()}>
<div class="card"><p class="notice">User not found.</p></div>
</Show>
<Show when={user()}>
<div class="grid" style="margin-top:0">
<section class="card">
<h2 style="margin-bottom:8px">Profile</h2>
<p class="notice" style="margin:0"><strong>ID:</strong> {user()!.id}</p>
<p class="notice" style="margin:8px 0 0"><strong>Name:</strong> {displayName()}</p>
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {user()!.email}</p>
</section>
<section class="card">
<h2 style="margin-bottom:8px">Account</h2>
<p class="notice" style="margin:0"><strong>Role:</strong> {roleName()}</p>
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {user()!.status || 'PENDING'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Created:</strong> {createdAt() ? new Date(createdAt()).toLocaleString() : '—'}</p>
<p class="notice" style="margin:8px 0 0"><strong>Updated:</strong> {updatedAt() ? new Date(updatedAt()).toLocaleString() : '—'}</p>
</section>
<>
<section class="card" style="padding:22px">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="display:flex;gap:14px;align-items:center;min-width:260px">
<div class="identity-avatar">
{displayName().charAt(0).toUpperCase() || '?'}
</div>
<div>
<h2 style="margin:0;font-size:24px">{displayName()}</h2>
<p class="notice" style="margin:4px 0 0">{user()!.email || '—'}</p>
<p class="notice" style="margin:3px 0 0">{user()!.publicId || user()!.id}</p>
</div>
</div>
<div style="display:flex;gap:18px">
<div class="mini-stat">
<p class="mini-stat-value">{roleFilter() ? displayedProfiles().length : roleProfiles().length}</p>
<p class="mini-stat-label">{roleFilter() ? 'Profiles' : 'Total Roles'}</p>
</div>
<div class="mini-stat">
<p class="mini-stat-value">{verifiedCount()}</p>
<p class="mini-stat-label">{roleFilter() ? 'Approved' : 'Verified'}</p>
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
<span class={`status-pill ${user()!.status === 'active' ? 'status-approved' : 'status-rejected'}`}>{user()!.status || 'unknown'}</span>
<span class="meta-chip">Joined {formatDate(userCreatedAt())}</span>
<Show when={user()!.lastLogin}>
<span class="meta-chip">Last seen {formatDate(user()!.lastLogin)}</span>
</Show>
</div>
<section class="card" style="margin-top:16px">
<h2 style="margin-bottom:10px">Raw Data</h2>
<pre class="json">{JSON.stringify(user(), null, 2)}</pre>
</section>
<section class="card" style="margin-top:16px;padding:0;overflow:hidden">
<div style="padding:16px 18px;border-bottom:1px solid #e2e8f0;background:#f8fafc">
<h2 style="margin:0;font-size:18px">{roleTitleLabel() ? `${roleTitleLabel()} Registration Data` : 'Registered Roles & Profiles'}</h2>
</div>
<Show when={displayedProfiles().length > 0} fallback={
<div style="padding:24px">
<p class="notice">This user has no registered role profiles yet.</p>
</div>
}>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<Show when={!roleFilter()}>
<th>Role</th>
</Show>
<th>Profile ID</th>
<th>Verification</th>
<th>Submitted</th>
<th>Last Updated</th>
<th class="align-right">Action</th>
</tr>
</thead>
<tbody>
<For each={displayedProfiles()}>
{(item) => (
<tr>
<Show when={!roleFilter()}>
<td>{ROLE_LABELS[normalizeRole(item.roleType)] || item.roleType || 'Role'}</td>
</Show>
<td>{item.publicId || item.id}</td>
<td>
<span class={`status-pill ${statusClass(item.verificationStatus)}`}>
{(item.verificationStatus || 'UNKNOWN').replace(/_/g, ' ')}
</span>
</td>
<td>{formatDate(item.submittedAt)}</td>
<td>{formatDate(item.updatedAt)}</td>
<td class="align-right">
<A class={`btn ${item.isApproval ? '' : 'orange'}`} href={item.isApproval ? `/admin/approval/${item.id}` : `/admin/profile/${item.publicId || item.id}`}>
{item.isApproval ? 'View Request' : 'Open Profile'}
</A>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</section>
</>
</Show>
</AdminShell>
);