- pricing, credit, coupon, discount, tax, order, invoice: white header, data-table/table-card, navy buttons, inline styles removed - review, leads, jobs, notifications, support, report, ledger: same pattern + orange tab underlines where applicable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
6.7 KiB
TypeScript
159 lines
6.7 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="flex flex-col -mx-6 -mt-6 min-h-full">
|
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
|
<h1 class="text-xl font-semibold text-gray-900">Jobs Management</h1>
|
|
<p class="text-sm text-gray-500 mt-0.5">Review live company job postings</p>
|
|
</div>
|
|
|
|
<div class="flex-1 p-6">
|
|
<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>
|
|
|
|
<div class="table-card">
|
|
<div class="overflow-x-auto">
|
|
<table class="data-table w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Skills</th>
|
|
<th>Company / Client</th>
|
|
<th>Rate</th>
|
|
<th>Location</th>
|
|
<th>Status</th>
|
|
<th class="text-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 class="hover:bg-slate-50">
|
|
<td>
|
|
<div class="font-semibold text-slate-900">{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 class="text-slate-500">
|
|
{job.required_skills?.join(', ') || job.experience_level || '—'}
|
|
</td>
|
|
<td class="text-slate-500">{job.client_name || job.company_name || '—'}</td>
|
|
<td class="text-slate-500">
|
|
{job.hourly_rate_min != null
|
|
? `₹${job.hourly_rate_min}–₹${job.hourly_rate_max ?? job.hourly_rate_min}/hr`
|
|
: '—'}
|
|
</td>
|
|
<td class="text-slate-500">{job.location || '—'}</td>
|
|
<td>
|
|
<span class={statusChipClass(job.status)}>{job.status || '—'}</span>
|
|
</td>
|
|
<td>
|
|
<div class="flex items-center justify-end gap-1">
|
|
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/jobs/${job.id}`}>View</A>
|
|
<Show when={job.status === 'PENDING_APPROVAL'}>
|
|
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/approval">Review</A>
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminShell>
|
|
);
|
|
}
|