nxtgauge-admin-solid/src/routes/admin/company/index.tsx

463 lines
25 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 CompanyRecord = {
id: string;
companyCode: string;
name: string;
registrationNumber: string;
industry: string;
location: string;
joinedOn: string;
joinedAt: string;
accountStatus: 'ACTIVE' | 'PENDING' | 'SUSPENDED' | 'INACTIVE';
verificationStatus: string;
subscriptionType: string;
jobPostingsCount: number;
totalHires: number;
updatedAt: string;
};
function StatusBadge(props: { status: CompanyRecord['accountStatus'] }) {
const active = () => props.status === 'ACTIVE';
const pending = () => props.status === 'PENDING';
const suspended = () => props.status === 'SUSPENDED';
return (
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${
active() ? '#FFD8C2' : pending() ? '#F6D78F' : suspended() ? '#FECACA' : '#D1D5DB'
};background:${
active() ? '#FFF1EB' : pending() ? '#FFF3D6' : suspended() ? '#FEF2F2' : '#F3F4F6'
};color:${active() ? '#FF5E13' : pending() ? '#B7791F' : suspended() ? '#B91C1C' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${
active() ? '#FF5E13' : pending() ? '#B7791F' : suspended() ? '#B91C1C' : '#9CA3AF'
};margin-right:5px;flex-shrink:0`}
/>
{props.status.charAt(0) + props.status.slice(1).toLowerCase()}
</span>
);
}
function normalizeStatus(value: unknown): CompanyRecord['accountStatus'] {
const key = String(value || '').toUpperCase();
if (key === 'APPROVED' || key === 'ACTIVE') return 'ACTIVE';
if (key === 'PENDING' || key === 'PENDING_REVIEW' || key === 'UNDER_REVIEW') return 'PENDING';
if (key === 'SUSPENDED') return 'SUSPENDED';
return 'INACTIVE';
}
export default function CompanyManagementPage() {
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending' | 'view'>('all');
const [detailTab, setDetailTab] = createSignal<'overview' | 'verification' | 'metrics'>('overview');
const [rows, setRows] = createSignal<CompanyRecord[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [loadError, setLoadError] = createSignal('');
const [selectedCompany, setSelectedCompany] = createSignal<CompanyRecord | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | CompanyRecord['accountStatus']>('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'joined_desc' | 'joined_asc'>('name_asc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const load = async () => {
setIsLoading(true);
setLoadError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const r = await fetch('/api/admin/companies', {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!r.ok) throw new Error('Failed to fetch companies');
const data = await r.json().catch(() => []);
const mapped: CompanyRecord[] = (Array.isArray(data) ? data : []).map((c: any) => {
const joinedAt = String(c.created_at || '');
const status = normalizeStatus(c.status);
return {
id: String(c.id || ''),
companyCode: String(c.id || '').slice(0, 8).toUpperCase() || '—',
name: String(c.company_name || c.name || 'Unnamed Company'),
registrationNumber: String(c.registration_number || c.gst_number || 'Pending Registration'),
industry: String(c.industry || 'Not Specified'),
location: String(c.location || c.city || 'Not Specified'),
joinedOn: joinedAt ? new Date(joinedAt).toLocaleDateString() : '—',
joinedAt,
accountStatus: status,
verificationStatus: status === 'ACTIVE' ? 'VERIFIED' : 'PENDING',
subscriptionType: String(c.subscription_type || 'STANDARD'),
jobPostingsCount: Number(c.job_postings_count || 0),
totalHires: Number(c.total_hires || 0),
updatedAt: String(c.updated_at || c.created_at || ''),
};
});
setRows(mapped);
} catch (e) {
console.error(e);
setLoadError('Failed to load companies.');
setRows([]);
} finally {
setIsLoading(false);
}
};
onMount(() => void load());
const filteredRows = createMemo(() => {
let list = rows();
if (statusFilter() !== 'all') list = list.filter((row) => row.accountStatus === statusFilter());
const query = search().trim().toLowerCase();
if (query) {
list = list.filter((row) =>
row.name.toLowerCase().includes(query)
|| row.companyCode.toLowerCase().includes(query)
|| row.registrationNumber.toLowerCase().includes(query)
|| row.industry.toLowerCase().includes(query),
);
}
const sorted = [...list];
const mode = sortBy();
sorted.sort((a, b) => {
if (mode === 'name_desc') return b.name.localeCompare(a.name);
if (mode === 'joined_desc') return (Date.parse(b.joinedAt) || 0) - (Date.parse(a.joinedAt) || 0);
if (mode === 'joined_asc') return (Date.parse(a.joinedAt) || 0) - (Date.parse(b.joinedAt) || 0);
return a.name.localeCompare(b.name);
});
return sorted;
});
const exportCsv = () => {
const headers = ['Company', 'Code', 'Industry', 'Location', 'Registration', 'Status', 'Joined'];
const lines = filteredRows().map((c) => [
c.name,
c.companyCode,
c.industry,
c.location,
c.registrationNumber,
c.accountStatus,
c.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 = `companies-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const openView = (company: CompanyRecord) => {
setSelectedCompany(company);
setListTab('view');
setOpenMenuId(null);
};
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]">Company Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage and monitor all registered companies and their account status.</p>
</div>
<div>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Companies', action: () => { setListTab('all'); setStatusFilter('all'); } },
{ key: 'active', label: 'Active', action: () => { setListTab('active'); setStatusFilter('ACTIVE'); } },
{ key: 'pending', label: 'Pending', action: () => { setListTab('pending'); setStatusFilter('PENDING'); } },
{ key: 'view', label: 'View Company', action: () => setListTab('view') },
] as const).map((tab) => (
<button
type="button"
onClick={tab.action}
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{tab.label}
</button>
))}
</div>
<Show when={listTab() === 'view'}>
<Show when={!selectedCompany()}>
<div style="margin-top:24px;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 company selected</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong></strong> menu on any company row and choose <strong>View Company</strong>.</p>
</div>
</Show>
<Show when={selectedCompany()}>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
<div>
<h2 style="font-size:18px;font-weight:700;color:#111827">{selectedCompany()!.name}</h2>
<p style="margin-top:2px;font-size:13px;color:#6B7280">{selectedCompany()!.companyCode} Joined {selectedCompany()!.joinedOn}</p>
</div>
<StatusBadge status={selectedCompany()!.accountStatus} />
</div>
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
{(['overview', 'verification', 'metrics'] as const).map((tab, i) => {
const labels = ['Overview', 'Verification', 'Metrics'];
const active = () => detailTab() === tab;
return (
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={detailTab() === 'overview'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Company Profile</h3>
<div style="display:flex;flex-direction:column;gap:12px">
{[
{ l: 'Industry', v: selectedCompany()!.industry },
{ l: 'Location', v: selectedCompany()!.location },
{ l: 'Registration', v: selectedCompany()!.registrationNumber },
{ l: 'Subscription', v: selectedCompany()!.subscriptionType },
].map((item) => (
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">{item.l}</span>
<span style="font-size:13px;font-weight:600;color:#111827">{item.v || '—'}</span>
</div>
))}
</div>
</div>
</div>
</Show>
<Show when={detailTab() === 'verification'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Verification Summary</h3>
<div style="display:flex;flex-direction:column;gap:12px">
{[
{ l: 'Verification Status', v: selectedCompany()!.verificationStatus },
{ l: 'Account Status', v: selectedCompany()!.accountStatus },
{ l: 'Last Updated', v: selectedCompany()!.updatedAt ? new Date(selectedCompany()!.updatedAt).toLocaleString() : '—' },
].map((item) => (
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">{item.l}</span>
<span style="font-size:13px;font-weight:600;color:#111827">{item.v || '—'}</span>
</div>
))}
</div>
</div>
</div>
</Show>
<Show when={detailTab() === 'metrics'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Performance Metrics</h3>
<div style="display:flex;flex-direction:column;gap:12px">
{[
{ l: 'Job Postings', v: String(selectedCompany()!.jobPostingsCount || 0) },
{ l: 'Total Hires', v: String(selectedCompany()!.totalHires || 0) },
].map((item) => (
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">{item.l}</span>
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
</div>
))}
</div>
</div>
</div>
</Show>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
<button type="button" onClick={() => { setSelectedCompany(null); 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>
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
<div style="position:relative;margin-top:1.5rem;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
type="text"
placeholder="Search companies..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
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={() => {
setSortMenuOpen((v) => !v);
setFilterMenuOpen(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={sortMenuOpen()}>
<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)">
{([
{ key: 'name_asc', label: 'Name (A-Z)' },
{ key: 'name_desc', label: 'Name (Z-A)' },
{ key: 'joined_desc', label: 'Joined (Newest)' },
{ key: 'joined_asc', label: 'Joined (Oldest)' },
] as const).map((item) => (
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortMenuOpen(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>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => {
setFilterMenuOpen((v) => !v);
setSortMenuOpen(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={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
{ key: 'all', label: 'All Status' },
{ key: 'ACTIVE', label: 'Active' },
{ key: 'PENDING', label: 'Pending' },
{ key: 'SUSPENDED', label: 'Suspended' },
{ key: 'INACTIVE', label: 'Inactive' },
] as const).map((item) => (
<button
type="button"
onClick={() => {
setStatusFilter(item.key);
setFilterMenuOpen(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:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}
>
{item.label}
</button>
))}
</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">
Export
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Company', 'Industry', 'Location', 'Status', 'Joined', 'Actions'].map((h) => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<Show when={isLoading()}>
<tr><td colSpan={6} style="padding:32px;text-align:center;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!isLoading() && !!loadError()}>
<tr><td colSpan={6} style="padding:32px;text-align:center;color:#b91c1c">{loadError()}</td></tr>
</Show>
<Show
when={!isLoading() && !loadError() && filteredRows().length > 0}
fallback={
<tr>
<td colSpan={6} style="padding:32px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No companies found</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Try changing filters or search.</p>
</td>
</tr>
}
>
<For each={filteredRows()}>
{(row) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px">
<p style="font-size:14px;font-weight:600;color:#111827">{row.name}</p>
<p style="font-size:12px;color:#6B7280">{row.companyCode}</p>
</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.industry}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.location}</td>
<td style="padding:12px 20px"><StatusBadge status={row.accountStatus} /></td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.joinedOn}</td>
<td style="padding:12px 20px;position:relative">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)}
style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => openView(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Company</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
<Show when={!isLoading() && !loadError() && filteredRows().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{filteredRows().length}</strong> of{' '}
<strong style="font-weight:600;color:#111827">{filteredRows().length}</strong> companies
</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>
</div>
</div>
</div>
);
}