nxtgauge-frontend-solid/src/components/dashboard/CompanyJobsPage.tsx
Tracewebstudio Dev 40e79f2d01
All checks were successful
build-and-release / build (push) Successful in 2m16s
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script

SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
  testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
  counters, filter tabs)

Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
  saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00

840 lines
30 KiB
XML
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.

/**
* 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<JobItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [showForm, setShowForm] = createSignal(false);
const [form, setForm] = createSignal<JobFormState>({ ...EMPTY_FORM });
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal("");
const [actionMsg, setActionMsg] = createSignal("");
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<SortKey>("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<string>();
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<string>();
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<typeof field, (v: boolean) => 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 (
<div style={{ "max-width": "920px" }}>
<div
style={{
display: "flex",
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "16px",
gap: "12px",
"flex-wrap": "wrap",
}}
>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>
Jobs
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
Create and manage your job postings.
</p>
</div>
<button type="button" onClick={openCreate} style={BTN_PRIMARY}>
+ Create Job
</button>
</div>
<Show when={actionMsg()}>
<div
style={{
...CARD,
"margin-bottom": "14px",
padding: "12px 14px",
"font-size": "13px",
color: "#374151",
}}
>
{actionMsg()}
</div>
</Show>
<Show when={showForm()}>
<div style={{ ...CARD, "margin-bottom": "16px", border: "1px solid #FF5E13" }}>
<p
style={{
margin: "0 0 14px",
"font-size": "16px",
"font-weight": "800",
color: "#111827",
}}
>
New Job
</p>
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
<Show when={aiRemaining() < aiLimit() || hasAiPack()}>
<div style={{ "grid-column": "span 2", display: "flex", "align-items": "center", gap: "6px", "font-size": "12px", color: "#6B7280" }}>
<Sparkles size={14} color="#FF5E13" />
<span>{aiRemaining()} AI generations left today</span>
<Show when={!hasAiPack()}>
<span style={{ color: "#9CA3AF" }}>({aiLimit()} base limit)</span>
</Show>
<Show when={hasAiPack()}>
<span style={{ color: "#FF5E13", "font-weight": "600" }}>AI Pack active</span>
</Show>
</div>
</Show>
<div style={{ "grid-column": "span 2" }}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
<label style={LABEL}>Job Title</label>
<button
type="button"
onClick={() => generateField("title")}
disabled={genTitle() || aiRemaining() <= 0}
title="Generate with AI"
style={{
background: "none",
border: "none",
cursor: genTitle() || aiRemaining() <= 0 ? "not-allowed" : "pointer",
padding: "4px",
display: "flex",
"align-items": "center",
color: aiRemaining() <= 0 ? "#D1D5DB" : "#FF5E13",
opacity: genTitle() ? "0.6" : "1",
}}
>
<Show when={genTitle()} fallback={<Sparkles size={16} />} >
<Loader size={16} style={{ animation: "spin 1s linear infinite" }} />
</Show>
</button>
</div>
<input
value={form().title}
onInput={(e) => setField("title", e.currentTarget.value)}
style={INPUT}
placeholder="Frontend Developer"
/>
</div>
<div>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
<label style={LABEL}>Category</label>
<button
type="button"
onClick={() => generateField("category")}
disabled={genCategory() || aiRemaining() <= 0}
title="Generate with AI"
style={{
background: "none",
border: "none",
cursor: genCategory() || aiRemaining() <= 0 ? "not-allowed" : "pointer",
padding: "4px",
display: "flex",
"align-items": "center",
color: aiRemaining() <= 0 ? "#D1D5DB" : "#FF5E13",
opacity: genCategory() ? "0.6" : "1",
}}
>
<Show when={genCategory()} fallback={<Sparkles size={16} />} >
<Loader size={16} style={{ animation: "spin 1s linear infinite" }} />
</Show>
</button>
</div>
<input
value={form().category}
onInput={(e) => setField("category", e.currentTarget.value)}
style={INPUT}
placeholder="Engineering"
/>
</div>
<div>
<label style={LABEL}>Job Type</label>
<select
value={form().job_type}
onChange={(e) => setField("job_type", e.currentTarget.value)}
style={INPUT}
>
<option value="FULL_TIME">Full Time</option>
<option value="PART_TIME">Part Time</option>
<option value="CONTRACT">Contract</option>
</select>
</div>
<div style={{ "grid-column": "span 2" }}>
<label style={LABEL}>Location</label>
<input
value={form().location}
onInput={(e) => setField("location", e.currentTarget.value)}
style={INPUT}
placeholder="Bengaluru (Hybrid)"
/>
</div>
<div style={{ "grid-column": "span 2" }}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
<label style={LABEL}>Description</label>
<button
type="button"
onClick={() => generateField("description")}
disabled={genDesc() || aiRemaining() <= 0}
title="Generate with AI"
style={{
background: "none",
border: "none",
cursor: genDesc() || aiRemaining() <= 0 ? "not-allowed" : "pointer",
padding: "4px",
display: "flex",
"align-items": "center",
color: aiRemaining() <= 0 ? "#D1D5DB" : "#FF5E13",
opacity: genDesc() ? "0.6" : "1",
}}
>
<Show when={genDesc()} fallback={<Sparkles size={16} />} >
<Loader size={16} style={{ animation: "spin 1s linear infinite" }} />
</Show>
</button>
</div>
<textarea
rows={4}
value={form().description}
onInput={(e) => setField("description", e.currentTarget.value)}
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
placeholder="Role overview, responsibilities, and requirements"
/>
</div>
<div>
<label style={LABEL}>Min Salary</label>
<input
type="number"
value={form().salary_min}
onInput={(e) => setField("salary_min", e.currentTarget.value)}
style={INPUT}
/>
</div>
<div>
<label style={LABEL}>Max Salary</label>
<input
type="number"
value={form().salary_max}
onInput={(e) => setField("salary_max", e.currentTarget.value)}
style={INPUT}
/>
</div>
<div>
<label style={LABEL}>Experience (years)</label>
<input
type="number"
value={form().experience_years}
onInput={(e) => setField("experience_years", e.currentTarget.value)}
style={INPUT}
/>
</div>
<div>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
<label style={LABEL}>Skills (comma separated)</label>
<button
type="button"
onClick={() => generateField("skills")}
disabled={genSkills() || aiRemaining() <= 0}
title="Generate with AI"
style={{
background: "none",
border: "none",
cursor: genSkills() || aiRemaining() <= 0 ? "not-allowed" : "pointer",
padding: "4px",
display: "flex",
"align-items": "center",
color: aiRemaining() <= 0 ? "#D1D5DB" : "#FF5E13",
opacity: genSkills() ? "0.6" : "1",
}}
>
<Show when={genSkills()} fallback={<Sparkles size={16} />} >
<Loader size={16} style={{ animation: "spin 1s linear infinite" }} />
</Show>
</button>
</div>
<input
value={form().skills}
onInput={(e) => setField("skills", e.currentTarget.value)}
style={INPUT}
placeholder="Rust, SQL, Docker"
/>
</div>
</div>
<Show when={error()}>
<p
style={{
margin: "12px 0 0",
"font-size": "13px",
color: "#EF4444",
"font-weight": "600",
}}
>
{error()}
</p>
</Show>
<div style={{ display: "flex", gap: "10px", "margin-top": "14px" }}>
<button
type="button"
onClick={handleCreate}
disabled={saving()}
style={{ ...BTN_PRIMARY, opacity: saving() ? "0.7" : "1" }}
>
{saving() ? "Creating…" : "Create Draft"}
</button>
<button type="button" onClick={closeCreate} style={BTN_GHOST}>
Cancel
</button>
</div>
</div>
</Show>
<div style={CARD}>
<div
style={{
display: "flex",
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "10px",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
My Job Postings
</p>
<button type="button" onClick={loadJobs} style={BTN_GHOST}>
Refresh
</button>
</div>
<div style={{ display: "grid", gap: "10px" }}>
<div style={{ display: "grid", "grid-template-columns": "1fr 180px", gap: "10px" }}>
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style={INPUT}
placeholder="Search by title, location, type, description, tags"
/>
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
<option value="newest">Sort: Newest</option>
<option value="salary_desc">Salary High to Low</option>
<option value="salary_asc">Salary Low to High</option>
<option value="title_asc">Title A-Z</option>
</select>
</div>
<Show when={availableTags().length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<button
type="button"
onClick={() => setActiveTag("")}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
>
All Tags
</button>
<For each={availableTags()}>
{(tag) => (
<button
type="button"
onClick={() => setActiveTag(tag)}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
>
{tag}
</button>
)}
</For>
</div>
</Show>
</div>
</div>
<Show when={loading()}>
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs…</div>
</Show>
<Show when={!loading() && filteredSortedJobs().length === 0}>
<div style={{ ...CARD, "text-align": "center", padding: "34px 24px" }}>
<p
style={{
margin: "0 0 6px",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
No jobs found
</p>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
Try a different search or create your first draft job.
</p>
</div>
</Show>
<Show when={!loading() && filteredSortedJobs().length > 0}>
<div style={{ display: "grid", gap: "10px" }}>
<For each={filteredSortedJobs()}>
{(job) => (
<div style={{ ...CARD, padding: "16px" }}>
<div
style={{
display: "flex",
"justify-content": "space-between",
gap: "10px",
"align-items": "flex-start",
"flex-wrap": "wrap",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "15px",
"font-weight": "800",
color: "#111827",
}}
>
{job.title}
</p>
<p style={{ margin: "3px 0 0", "font-size": "12px", color: "#6B7280" }}>
{[job.location, job.job_type, job.category || "General"].join(" • ")}
</p>
</div>
<span
style={{
display: "inline-flex",
"align-items": "center",
padding: "0 10px",
height: "24px",
"border-radius": "999px",
background: `${statusColor(job.status)}20`,
color: statusColor(job.status),
"font-size": "11px",
"font-weight": "700",
}}
>
{job.status.replace(/_/g, " ")}
</span>
</div>
<p
style={{
margin: "8px 0 0",
"font-size": "13px",
color: "#374151",
"line-height": "1.5",
}}
>
{job.description}
</p>
<Show when={job.status === "REJECTED" && job.rejection_reason}>
<div style={{ margin: "8px 0 0", padding: "10px 12px", background: "#FEF2F2", "border-radius": "8px", "border-left": "3px solid #FCA5A5" }}>
<p style={{ margin: "0 0 3px", "font-size": "11px", "font-weight": "700", color: "#B91C1C" }}>Rejection Reason</p>
<p style={{ margin: 0, "font-size": "13px", color: "#7F1D1D" }}>{job.rejection_reason}</p>
</div>
</Show>
<Show when={job.status === "PENDING_APPROVAL"}>
<div style={{ margin: "8px 0 0", padding: "8px 12px", background: "#FFF7ED", "border-radius": "8px", "border-left": "3px solid #FCD34D" }}>
<p style={{ margin: 0, "font-size": "12px", color: "#92400E", "font-weight": "600" }}>
Under review — our team will approve or provide feedback within 2448 hours.
</p>
</div>
</Show>
<Show when={rowTags(job).length > 0}>
<div
style={{
display: "flex",
gap: "6px",
"flex-wrap": "wrap",
"margin-top": "8px",
}}
>
<For each={rowTags(job).slice(0, 8)}>
{(tag) => (
<span
style={{
"font-size": "11px",
color: "#4B5563",
background: "#F3F4F6",
border: "1px solid #E5E7EB",
"border-radius": "6px",
padding: "2px 8px",
}}
>
{tag}
</span>
)}
</For>
</div>
</Show>
<div
style={{ display: "flex", gap: "8px", "margin-top": "12px", "flex-wrap": "wrap" }}
>
<Show when={job.status === "DRAFT"}>
<button
type="button"
onClick={() => submitJob(job.id)}
disabled={busyJobId() === job.id}
style={{
...BTN_PRIMARY,
height: "32px",
"font-size": "12px",
padding: "0 14px",
opacity: busyJobId() === job.id ? "0.65" : "1",
}}
>
Submit for Approval
</button>
</Show>
<Show when={job.status !== "CLOSED"}>
<button
type="button"
onClick={() => closeJob(job.id)}
disabled={busyJobId() === job.id}
style={{
...BTN_GHOST,
height: "32px",
"font-size": "12px",
padding: "0 14px",
opacity: busyJobId() === job.id ? "0.65" : "1",
}}
>
Close Job
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}