158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
|
|
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||
|
|
import { A } from '@solidjs/router';
|
||
|
|
import AdminShell from '~/components/AdminShell';
|
||
|
|
|
||
|
|
const API = '/api/gateway';
|
||
|
|
|
||
|
|
type Job = {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
description?: string;
|
||
|
|
required_skills?: string[];
|
||
|
|
experience_level?: string;
|
||
|
|
job_type?: string;
|
||
|
|
status: string;
|
||
|
|
client_name?: string;
|
||
|
|
company_name?: string;
|
||
|
|
location?: string;
|
||
|
|
hourly_rate_min?: number;
|
||
|
|
hourly_rate_max?: number;
|
||
|
|
created_at?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function loadJobs(): Promise<Job[]> {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${API}/api/jobs?limit=100`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load');
|
||
|
|
const data = await res.json();
|
||
|
|
return Array.isArray(data) ? data : (data.jobs || []);
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const STATUS_OPTIONS = ['All', 'DRAFT', 'ACTIVE', 'PENDING_APPROVAL', 'CLOSED', 'EXPIRED'];
|
||
|
|
|
||
|
|
function statusChipClass(status: string): string {
|
||
|
|
if (status === 'ACTIVE') return 'status-chip active';
|
||
|
|
if (status === 'PENDING_APPROVAL') return 'status-chip pending';
|
||
|
|
if (status === 'DRAFT') return 'status-chip draft';
|
||
|
|
if (status === 'CLOSED' || status === 'EXPIRED') return 'status-chip danger';
|
||
|
|
return 'status-chip';
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function JobsPage() {
|
||
|
|
const [jobs] = createResource(loadJobs);
|
||
|
|
const [search, setSearch] = createSignal('');
|
||
|
|
const [statusFilter, setStatusFilter] = createSignal('All');
|
||
|
|
|
||
|
|
const filtered = createMemo(() => {
|
||
|
|
const all = jobs() ?? [];
|
||
|
|
const q = search().toLowerCase();
|
||
|
|
const st = statusFilter();
|
||
|
|
return all.filter((job) => {
|
||
|
|
const matchesSearch =
|
||
|
|
!q ||
|
||
|
|
job.title?.toLowerCase().includes(q) ||
|
||
|
|
job.client_name?.toLowerCase().includes(q) ||
|
||
|
|
job.company_name?.toLowerCase().includes(q);
|
||
|
|
const matchesStatus = st === 'All' || job.status === st;
|
||
|
|
return matchesSearch && matchesStatus;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminShell>
|
||
|
|
<div class="page-actions">
|
||
|
|
<div>
|
||
|
|
<h1 class="page-title">Jobs Management</h1>
|
||
|
|
<p class="page-subtitle">Review live company job postings</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="Search by title or company..."
|
||
|
|
value={search()}
|
||
|
|
onInput={(e) => setSearch(e.currentTarget.value)}
|
||
|
|
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:260px"
|
||
|
|
/>
|
||
|
|
<select
|
||
|
|
value={statusFilter()}
|
||
|
|
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||
|
|
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||
|
|
>
|
||
|
|
<For each={STATUS_OPTIONS}>{(opt) => <option value={opt}>{opt === 'All' ? 'All Statuses' : opt}</option>}</For>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<section class="card" style="padding:0;overflow:hidden">
|
||
|
|
<div class="table-wrap">
|
||
|
|
<table class="list-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Title</th>
|
||
|
|
<th>Skills</th>
|
||
|
|
<th>Company / Client</th>
|
||
|
|
<th>Rate</th>
|
||
|
|
<th>Location</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th class="align-right">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<Show when={jobs.loading}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!jobs.loading && jobs.error}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!jobs.loading && !jobs.error && filtered().length === 0}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No jobs found.</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!jobs.loading && !jobs.error && filtered().length > 0}>
|
||
|
|
<For each={filtered()}>
|
||
|
|
{(job) => (
|
||
|
|
<tr>
|
||
|
|
<td>
|
||
|
|
<div style="font-weight:600;color:#0f172a">{job.title || '—'}</div>
|
||
|
|
<Show when={job.description}>
|
||
|
|
<div style="font-size:12px;color:#64748b;margin-top:2px;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
|
|
{job.description}
|
||
|
|
</div>
|
||
|
|
</Show>
|
||
|
|
</td>
|
||
|
|
<td style="color:#475569">
|
||
|
|
{job.required_skills?.join(', ') || job.experience_level || '—'}
|
||
|
|
</td>
|
||
|
|
<td style="color:#475569">{job.client_name || job.company_name || '—'}</td>
|
||
|
|
<td style="color:#475569">
|
||
|
|
{job.hourly_rate_min != null
|
||
|
|
? `₹${job.hourly_rate_min}–₹${job.hourly_rate_max ?? job.hourly_rate_min}/hr`
|
||
|
|
: '—'}
|
||
|
|
</td>
|
||
|
|
<td style="color:#475569">{job.location || '—'}</td>
|
||
|
|
<td>
|
||
|
|
<span class={statusChipClass(job.status)}>{job.status || '—'}</span>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<div class="table-actions">
|
||
|
|
<A class="btn" href={`/admin/jobs/${job.id}`}>View</A>
|
||
|
|
<Show when={job.status === 'PENDING_APPROVAL'}>
|
||
|
|
<A class="btn" href="/admin/approval">Review</A>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</For>
|
||
|
|
</Show>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</AdminShell>
|
||
|
|
);
|
||
|
|
}
|