/** * Company Jobs Page - Wired to real backend APIs * Endpoints: * GET /api/companies/jobs - List company's jobs * POST /api/companies/jobs - Create new job * PATCH /api/companies/jobs/:id - Update job * DELETE /api/companies/jobs/:id - Delete job * POST /api/ai/company/jobs/generate-description - AI job description generation * POST /api/ai/company/jobs/extract-skills - AI job skill extraction * GET /api/ai/usage/summary - AI usage status */ import { For, Show, createMemo, createSignal, onMount } from "solid-js"; import { Sparkles, Loader } from "lucide-solid"; import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT, LABEL, } from "~/components/DashboardShell"; const API = ""; interface JobItem { id: string; title: string; description: string; location: string; job_type: string; status: string; category?: string | null; salary_min?: number | null; salary_max?: number | null; experience_years?: number | null; skills?: string[] | null; rejection_reason?: string | null; approved_at?: string | null; created_at?: string; } interface JobFormState { title: string; category: string; description: string; location: string; job_type: string; salary_min: string; salary_max: string; experience_years: string; skills: string; } type SortKey = "newest" | "salary_desc" | "salary_asc" | "title_asc"; const EMPTY_FORM: JobFormState = { title: "", category: "", description: "", location: "", job_type: "FULL_TIME", salary_min: "", salary_max: "", experience_years: "", skills: "", }; async function apiFetch(path: string, opts?: RequestInit) { const token = typeof window !== "undefined" ? window.sessionStorage.getItem("nxtgauge_access_token") || "" : ""; return fetch(`${API}${path}`, { ...opts, credentials: "include", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts?.headers ?? {}), }, }); } export default function CompanyJobsPage() { const [jobs, setJobs] = createSignal([]); const [loading, setLoading] = createSignal(true); const [showForm, setShowForm] = createSignal(false); const [form, setForm] = createSignal({ ...EMPTY_FORM }); const [saving, setSaving] = createSignal(false); const [error, setError] = createSignal(""); const [actionMsg, setActionMsg] = createSignal(""); const [busyJobId, setBusyJobId] = createSignal(null); const [search, setSearch] = createSignal(""); const [sortBy, setSortBy] = createSignal("newest"); const [activeTag, setActiveTag] = createSignal(""); const [aiRemaining, setAiRemaining] = createSignal(5); const [aiLimit, setAiLimit] = createSignal(5); const [hasAiPack, setHasAiPack] = createSignal(false); const [genTitle, setGenTitle] = createSignal(false); const [genDesc, setGenDesc] = createSignal(false); const [genSkills, setGenSkills] = createSignal(false); const [genCategory, setGenCategory] = createSignal(false); const rowTags = (row: JobItem) => { const tags = new Set(); if (row.category) tags.add(String(row.category)); if (Array.isArray(row.skills)) { for (const skill of row.skills) { const val = String(skill || "").trim(); if (val) tags.add(val); } } return Array.from(tags); }; const availableTags = createMemo(() => { const tags = new Set(); for (const row of jobs()) { for (const tag of rowTags(row)) tags.add(tag); } return Array.from(tags).sort((a, b) => a.localeCompare(b)); }); const filteredSortedJobs = createMemo(() => { const q = search().trim().toLowerCase(); const tag = activeTag().trim().toLowerCase(); const next = jobs().filter((row) => { const tags = rowTags(row); const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag); if (!matchesTag) return false; if (!q) return true; return ( String(row.title || "").toLowerCase().includes(q) || String(row.location || "").toLowerCase().includes(q) || String(row.description || "").toLowerCase().includes(q) || String(row.job_type || "").toLowerCase().includes(q) || tags.some((t) => t.toLowerCase().includes(q)) ); }); next.sort((a, b) => { if (sortBy() === "salary_desc") return Number(b.salary_max || b.salary_min || 0) - Number(a.salary_max || a.salary_min || 0); if (sortBy() === "salary_asc") return Number(a.salary_min || a.salary_max || 0) - Number(b.salary_min || b.salary_max || 0); if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || "")); return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime(); }); return next; }); const loadJobs = async () => { setLoading(true); try { const res = await apiFetch("/api/companies/jobs?page=1&limit=50"); if (!res.ok) { setJobs([]); return; } const payload = await res.json().catch(() => ({})); setJobs(Array.isArray(payload?.data) ? payload.data : []); } finally { setLoading(false); } }; onMount(loadJobs); const loadAiUsage = async () => { const token = window.sessionStorage.getItem("nxtgauge_access_token") || ""; // Gateway resolve_upstream() matches paths starting with "/api/ai". // API="" so the full path must include /api explicitly. const res = await fetch(`${API}/api/ai/usage/summary`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); const plan = data.plan_details || data.plan || {}; const remainingDaily = plan.remaining_daily_actions ?? data.daily_remaining ?? 0; const dailyLimit = plan.daily_action_limit ?? data.daily_limit ?? 5; setAiRemaining(remainingDaily); setAiLimit(dailyLimit); setHasAiPack((data.addon_balance ?? 0) > 0 || (plan.remaining_credits ?? data.monthly_remaining ?? 0) > 0); } }; onMount(loadAiUsage); const generateField = async (field: "title" | "description" | "skills" | "category") => { if (aiRemaining() <= 0) return; const setters: Record void> = { title: setGenTitle, description: setGenDesc, skills: setGenSkills, category: setGenCategory, }; setters[field](true); const context = form().title || form().description || "job posting"; const token = window.sessionStorage.getItem("nxtgauge_access_token") || ""; // All four fields are generated by the same backend endpoint // (apps/users/src/handlers/ai.rs::ai_generate_job_field), which // branches on `field` itself -- there is no separate // generate-description/extract-skills endpoint. try { const res = await fetch(`${API}/api/ai/generate-job-field`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ field, context }), }); if (res.status === 429) { setError("Daily AI generation limit reached. Upgrade to AI Pack for more."); return; } const data = await res.json(); const generatedText = data.generated_text || data.skills; if (res.ok && generatedText) { if (field === "title") setField("title", String(generatedText).substring(0, 100)); else if (field === "description") setField("description", String(generatedText)); else if (field === "skills") setField("skills", String(generatedText)); else if (field === "category") setField("category", String(generatedText).substring(0, 60)); setError(""); // clear any stale error from a previous failed attempt setAiRemaining(data.remaining_today ?? data.remaining_daily_actions ?? Math.max(0, aiRemaining() - 1)); setAiLimit(data.daily_limit ?? aiLimit()); setHasAiPack(hasAiPack()); } else { setError(data.error || "Generation failed"); } } catch { setError("Network error during generation"); } finally { setters[field](false); } }; const setField = (key: keyof JobFormState, val: string) => setForm((prev) => ({ ...prev, [key]: val })); const openCreate = () => { setForm({ ...EMPTY_FORM }); setError(""); setActionMsg(""); setShowForm(true); }; const closeCreate = () => { setShowForm(false); setForm({ ...EMPTY_FORM }); setError(""); }; const handleCreate = async () => { if (!form().title.trim() || !form().description.trim() || !form().location.trim()) { setError("Title, description, and location are required."); return; } setSaving(true); setError(""); setActionMsg(""); const payload = { title: form().title.trim(), category: form().category.trim() || undefined, description: form().description.trim(), location: form().location.trim(), job_type: form().job_type, salary_min: form().salary_min ? Number(form().salary_min) : undefined, salary_max: form().salary_max ? Number(form().salary_max) : undefined, experience_years: form().experience_years ? Number(form().experience_years) : undefined, skills: form() .skills.split(",") .map((s) => s.trim()) .filter(Boolean), }; try { const res = await apiFetch("/api/companies/jobs", { method: "POST", body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (data.code === "QUOTA_EXHAUSTED") { setError("You've used your free job post for this month. Contact support to purchase additional job slots."); } else { setError(data.error ?? data.message ?? "Failed to create job."); } return; } setActionMsg("Job created as draft."); closeCreate(); await loadJobs(); } catch { setError("Network error. Please try again."); } finally { setSaving(false); } }; const submitJob = async (jobId: string) => { setBusyJobId(jobId); setActionMsg(""); try { const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: "POST" }); if (res.ok) { setActionMsg("Job submitted for verification."); await loadJobs(); } else { const data = await res.json().catch(() => ({})); setActionMsg(data.error ?? data.message ?? "Unable to submit this job."); } } finally { setBusyJobId(null); } }; const closeJob = async (jobId: string) => { setBusyJobId(jobId); setActionMsg(""); try { const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: "POST" }); if (res.ok) { setActionMsg("Job closed."); await loadJobs(); } else { const data = await res.json().catch(() => ({})); setActionMsg(data.error ?? data.message ?? "Unable to close this job."); } } finally { setBusyJobId(null); } }; const statusColor = (status: string) => { switch (status) { case "DRAFT": return "#6B7280"; case "PENDING_APPROVAL": return "#F59E0B"; case "LIVE": return "#10B981"; case "REJECTED": return "#EF4444"; case "CLOSED": return "#374151"; default: return "#6B7280"; } }; return (

Jobs

Create and manage your job postings.

{actionMsg()}

New Job

{aiRemaining()} AI generations left today ({aiLimit()} base limit) AI Pack active
setField("title", e.currentTarget.value)} style={INPUT} placeholder="Frontend Developer" />
setField("category", e.currentTarget.value)} style={INPUT} placeholder="Engineering" />
setField("location", e.currentTarget.value)} style={INPUT} placeholder="Bengaluru (Hybrid)" />