182 lines
10 KiB
TypeScript
182 lines
10 KiB
TypeScript
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||
import AdminShell from '~/components/AdminShell';
|
||
import type { CrudRecord } from '~/lib/admin/types';
|
||
|
||
const API = '/api/gateway';
|
||
|
||
type JobRecord = CrudRecord & {
|
||
title: string;
|
||
company?: string;
|
||
location?: string;
|
||
rate?: string;
|
||
status: 'ACTIVE' | 'DRAFT' | 'PENDING_APPROVAL' | 'CLOSED' | 'EXPIRED';
|
||
postedDate?: string;
|
||
};
|
||
|
||
const FALLBACK_JOBS: JobRecord[] = [
|
||
{ id: 'j1', name: 'Senior UI/UX Designer', title: 'Senior UI/UX Designer', company: 'Creative Designs', location: 'Remote', rate: '₹800–₹1200/hr', status: 'ACTIVE', postedDate: '2026-03-25', updatedAt: '2026-03-27' },
|
||
{ id: 'j2', name: 'Full Stack Developer', title: 'Full Stack Developer', company: 'Tech Solutions Inc', location: 'Mumbai', rate: '₹1000–₹1500/hr', status: 'PENDING_APPROVAL', postedDate: '2026-03-24', updatedAt: '2026-03-27' },
|
||
{ id: 'j3', name: 'Marketing Specialist', title: 'Marketing Specialist', company: 'Global Brands', location: 'Delhi', rate: '₹500–₹700/hr', status: 'DRAFT', postedDate: '2026-03-26', updatedAt: '2026-03-27' },
|
||
];
|
||
|
||
function StatusBadge(props: { status: string }) {
|
||
const getColors = () => {
|
||
switch (props.status) {
|
||
case 'ACTIVE': return { border: '#B7E4C7', bg: '#DEF7E8', text: '#0B8A4A', dot: '#0B8A4A' };
|
||
case 'PENDING_APPROVAL': return { border: '#F6D78F', bg: '#FFF3D6', text: '#B7791F', dot: '#B7791F' };
|
||
case 'DRAFT': return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||
case 'CLOSED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||
case 'EXPIRED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||
default: return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||
}
|
||
};
|
||
const colors = getColors();
|
||
const label = props.status.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ');
|
||
|
||
return (
|
||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${colors.border};background:${colors.bg};color:${colors.text};padding:2px 10px;font-size:11px;font-weight:600`}>
|
||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${colors.dot};margin-right:5px;flex-shrink:0`} />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function JobsManagementPage() {
|
||
const [view, setView] = createSignal<'list' | 'detail'>('list');
|
||
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending'>('all');
|
||
const [search, setSearch] = createSignal('');
|
||
const [rows, setRows] = createSignal<JobRecord[]>([]);
|
||
const [selectedJob, setSelectedJob] = createSignal<JobRecord | null>(null);
|
||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||
|
||
const load = async () => {
|
||
try {
|
||
const res = await fetch(`${API}/api/jobs?limit=100`);
|
||
if (!res.ok) throw new Error();
|
||
const data = await res.json();
|
||
const list = Array.isArray(data) ? data : (data.jobs || []);
|
||
if (list.length === 0) setRows(FALLBACK_JOBS);
|
||
else setRows(list.map(j => ({
|
||
id: j.id,
|
||
name: j.title || '—',
|
||
title: j.title || '—',
|
||
company: j.company_name || j.client_name || '—',
|
||
location: j.location || '—',
|
||
rate: j.hourly_rate_min ? `₹${j.hourly_rate_min}–₹${j.hourly_rate_max ?? j.hourly_rate_min}/hr` : '—',
|
||
status: (j.status || 'ACTIVE').toUpperCase(),
|
||
postedDate: j.created_at ? new Date(j.created_at).toLocaleDateString() : '—',
|
||
updatedAt: j.updated_at || ''
|
||
} as JobRecord)));
|
||
} catch {
|
||
setRows(FALLBACK_JOBS);
|
||
}
|
||
};
|
||
|
||
onMount(() => void load());
|
||
|
||
const filteredRows = createMemo(() => {
|
||
let list = rows();
|
||
if (listTab() === 'active') list = list.filter(r => r.status === 'ACTIVE');
|
||
if (listTab() === 'pending') list = list.filter(r => r.status === 'PENDING_APPROVAL');
|
||
const q = search().toLowerCase();
|
||
if (q) list = list.filter(r => r.title.toLowerCase().includes(q) || r.company?.toLowerCase().includes(q));
|
||
return list;
|
||
});
|
||
|
||
const openDetail = (row: JobRecord) => { setSelectedJob(row); setView('detail'); setOpenMenuId(null); };
|
||
|
||
return (
|
||
<AdminShell>
|
||
<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]">Jobs Management</h1>
|
||
<p class="mt-1 text-[14px] text-[#6B7280]">Review and manage live job postings from companies and clients</p>
|
||
</div>
|
||
|
||
<Show when={view() === 'list'}>
|
||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||
{([
|
||
{ key: 'all', label: 'All Jobs', action: () => setListTab('all') },
|
||
{ key: 'active', label: 'Active', action: () => setListTab('active') },
|
||
{ key: 'pending', label: 'Pending Approval', action: () => setListTab('pending') },
|
||
] 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>
|
||
|
||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;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 jobs..."
|
||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||
/>
|
||
<button type="button" 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">Filters</button>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full">
|
||
<thead>
|
||
<tr style="background:#0D0D2A;text-align:left">
|
||
{['Job Title', 'Company', 'Rate', 'Location', 'Status', '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>
|
||
<For each={filteredRows()}>
|
||
{(row) => (
|
||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.title}</td>
|
||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.company || '—'}</td>
|
||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.rate || '—'}</td>
|
||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.location || '—'}</td>
|
||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></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={() => openDetail(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 Details</button>
|
||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Close Job</button>
|
||
</div>
|
||
</Show>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</For>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={view() === 'detail' && selectedJob()}>
|
||
<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:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||
<div>
|
||
<h2 style="font-size:20px;font-weight:700;color:#111827">{selectedJob()!.title}</h2>
|
||
<p style="font-size:14px;color:#6B7280;margin-top:2px">{selectedJob()!.company} • Posted {selectedJob()!.postedDate}</p>
|
||
</div>
|
||
<div style="display:flex;gap:10px">
|
||
<StatusBadge status={selectedJob()!.status} />
|
||
<button type="button" onClick={() => setView('list')} 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>
|
||
<div style="padding:24px">
|
||
<p style="font-size:14px;color:#6B7280">Full job description, applicant list, and status history will be displayed here.</p>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</AdminShell>
|
||
);
|
||
}
|