2026-04-10 01:25:49 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-06-15 09:39:26 +05:30
|
|
|
|
* 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
|
2026-04-10 01:25:49 +02:00
|
|
|
|
*/
|
2026-04-26 23:58:43 +02:00
|
|
|
|
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
|
2026-05-01 02:54:25 +02:00
|
|
|
|
import { Sparkles, Loader } from "lucide-solid";
|
2026-04-10 01:25:49 +02:00
|
|
|
|
import {
|
|
|
|
|
|
BTN_GHOST,
|
|
|
|
|
|
BTN_PRIMARY,
|
|
|
|
|
|
CARD,
|
|
|
|
|
|
INPUT,
|
|
|
|
|
|
LABEL,
|
|
|
|
|
|
} from "~/components/DashboardShell";
|
2026-04-08 22:40:43 +02:00
|
|
|
|
|
2026-07-18 11:40:20 +02:00
|
|
|
|
const API = "";
|
2026-04-08 22:40:43 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
feat: notifications page, auto-apply settings, customer response profiles, job status UI
- NotificationsPage: full paginated notification list with unread filter, mark read, load more
- SettingsPage: AI auto-apply section for job seekers (toggle, preferences, skills/titles/locations, salary range)
- CustomerResponsesPage: enriched professional response cards with avatar, bio, skills, location
- CompanyJobsPage: show rejection reason banner and pending-approval notice on job cards
- NotificationBell: fix "View all" link to /dashboard?nav=notifications (deep-link support)
- dashboard.tsx: ?nav= param reads sidebar page on mount; Notifications added to all role sidebars
- PayU integration: payu.ts lib, payu-return route, wallet buy/invoice pages, marketplace route
- Razorpay removed, replaced by PayU across payments flow
- ProfilePage: photo upload UI with avatar preview for all roles
- PortfolioPage: showcase image upload with file picker and preview
- CompanyApplicationsPage: applicant profile snapshot with avatar, headline, skills, resume download
- profile-fields-config: removed resume_doc from job seeker (resume is now AI-generated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:33:42 +02:00
|
|
|
|
rejection_reason?: string | null;
|
|
|
|
|
|
approved_at?: string | null;
|
2026-04-08 22:40:43 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 23:58:43 +02:00
|
|
|
|
type SortKey = "newest" | "salary_desc" | "salary_asc" | "title_asc";
|
|
|
|
|
|
|
2026-04-08 22:40:43 +02:00
|
|
|
|
const EMPTY_FORM: JobFormState = {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
title: "",
|
|
|
|
|
|
category: "",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
location: "",
|
|
|
|
|
|
job_type: "FULL_TIME",
|
|
|
|
|
|
salary_min: "",
|
|
|
|
|
|
salary_max: "",
|
|
|
|
|
|
experience_years: "",
|
|
|
|
|
|
skills: "",
|
2026-04-08 22:40:43 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function apiFetch(path: string, opts?: RequestInit) {
|
2026-04-22 01:32:43 +02:00
|
|
|
|
const token =
|
|
|
|
|
|
typeof window !== "undefined"
|
|
|
|
|
|
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
|
|
|
|
|
|
: "";
|
2026-04-08 22:40:43 +02:00
|
|
|
|
return fetch(`${API}${path}`, {
|
|
|
|
|
|
...opts,
|
2026-04-10 01:25:49 +02:00
|
|
|
|
credentials: "include",
|
2026-04-22 01:32:43 +02:00
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
|
|
|
|
...(opts?.headers ?? {}),
|
|
|
|
|
|
},
|
2026-04-08 22:40:43 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-10 01:25:49 +02:00
|
|
|
|
const [error, setError] = createSignal("");
|
|
|
|
|
|
const [actionMsg, setActionMsg] = createSignal("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
|
2026-04-26 23:58:43 +02:00
|
|
|
|
const [search, setSearch] = createSignal("");
|
|
|
|
|
|
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
|
|
|
|
|
|
const [activeTag, setActiveTag] = createSignal("");
|
2026-05-01 02:54:25 +02:00
|
|
|
|
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);
|
2026-04-26 23:58:43 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-04-08 22:40:43 +02:00
|
|
|
|
|
|
|
|
|
|
const loadJobs = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
const res = await apiFetch("/api/companies/jobs?page=1&limit=50");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
setJobs([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload = await res.json().catch(() => ({}));
|
|
|
|
|
|
setJobs(Array.isArray(payload?.data) ? payload.data : []);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
onMount(loadJobs);
|
|
|
|
|
|
|
2026-05-01 02:54:25 +02:00
|
|
|
|
const loadAiUsage = async () => {
|
|
|
|
|
|
const token = window.sessionStorage.getItem("nxtgauge_access_token") || "";
|
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
|
|
|
|
// 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`, {
|
2026-05-01 02:54:25 +02:00
|
|
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json();
|
2026-06-15 09:39:26 +05:30
|
|
|
|
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);
|
2026-05-01 02:54:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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") || "";
|
2026-07-06 01:58:29 +05:30
|
|
|
|
// 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.
|
2026-05-01 02:54:25 +02:00
|
|
|
|
try {
|
2026-07-06 01:58:29 +05:30
|
|
|
|
const res = await fetch(`${API}/api/ai/generate-job-field`, {
|
2026-05-01 02:54:25 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
|
},
|
2026-07-06 01:58:29 +05:30
|
|
|
|
body: JSON.stringify({ field, context }),
|
2026-05-01 02:54:25 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (res.status === 429) {
|
|
|
|
|
|
setError("Daily AI generation limit reached. Upgrade to AI Pack for more.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
2026-06-15 09:39:26 +05:30
|
|
|
|
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));
|
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
|
|
|
|
setError(""); // clear any stale error from a previous failed attempt
|
2026-06-15 09:39:26 +05:30
|
|
|
|
setAiRemaining(data.remaining_today ?? data.remaining_daily_actions ?? Math.max(0, aiRemaining() - 1));
|
2026-05-01 02:54:25 +02:00
|
|
|
|
setAiLimit(data.daily_limit ?? aiLimit());
|
2026-06-15 09:39:26 +05:30
|
|
|
|
setHasAiPack(hasAiPack());
|
2026-05-01 02:54:25 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
setError(data.error || "Generation failed");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError("Network error during generation");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setters[field](false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-08 22:40:43 +02:00
|
|
|
|
const setField = (key: keyof JobFormState, val: string) =>
|
|
|
|
|
|
setForm((prev) => ({ ...prev, [key]: val }));
|
|
|
|
|
|
|
|
|
|
|
|
const openCreate = () => {
|
|
|
|
|
|
setForm({ ...EMPTY_FORM });
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setError("");
|
|
|
|
|
|
setActionMsg("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
setShowForm(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const closeCreate = () => {
|
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
|
setForm({ ...EMPTY_FORM });
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setError("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreate = async () => {
|
|
|
|
|
|
if (!form().title.trim() || !form().description.trim() || !form().location.trim()) {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setError("Title, description, and location are required.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSaving(true);
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setError("");
|
|
|
|
|
|
setActionMsg("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-10 01:25:49 +02:00
|
|
|
|
skills: form()
|
|
|
|
|
|
.skills.split(",")
|
2026-04-08 22:40:43 +02:00
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
const res = await apiFetch("/api/companies/jobs", {
|
|
|
|
|
|
method: "POST",
|
2026-04-08 22:40:43 +02:00
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
|
if (!res.ok) {
|
2026-08-03 17:04:43 +05:30
|
|
|
|
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.");
|
|
|
|
|
|
}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg("Job created as draft.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
closeCreate();
|
|
|
|
|
|
await loadJobs();
|
|
|
|
|
|
} catch {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setError("Network error. Please try again.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submitJob = async (jobId: string) => {
|
|
|
|
|
|
setBusyJobId(jobId);
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
try {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: "POST" });
|
2026-04-08 22:40:43 +02:00
|
|
|
|
if (res.ok) {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg("Job submitted for verification.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
await loadJobs();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg(data.error ?? data.message ?? "Unable to submit this job.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyJobId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const closeJob = async (jobId: string) => {
|
|
|
|
|
|
setBusyJobId(jobId);
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg("");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
try {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: "POST" });
|
2026-04-08 22:40:43 +02:00
|
|
|
|
if (res.ok) {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg("Job closed.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
await loadJobs();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
2026-04-10 01:25:49 +02:00
|
|
|
|
setActionMsg(data.error ?? data.message ?? "Unable to close this job.");
|
2026-04-08 22:40:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyJobId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const statusColor = (status: string) => {
|
|
|
|
|
|
switch (status) {
|
2026-04-10 01:25:49 +02:00
|
|
|
|
case "DRAFT":
|
|
|
|
|
|
return "#6B7280";
|
|
|
|
|
|
case "PENDING_APPROVAL":
|
|
|
|
|
|
return "#F59E0B";
|
|
|
|
|
|
case "LIVE":
|
|
|
|
|
|
return "#10B981";
|
|
|
|
|
|
case "REJECTED":
|
|
|
|
|
|
return "#EF4444";
|
|
|
|
|
|
case "CLOSED":
|
|
|
|
|
|
return "#374151";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "#6B7280";
|
2026-04-08 22:40:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ "max-width": "920px" }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
"justify-content": "space-between",
|
|
|
|
|
|
"align-items": "center",
|
|
|
|
|
|
"margin-bottom": "16px",
|
|
|
|
|
|
gap: "12px",
|
|
|
|
|
|
"flex-wrap": "wrap",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<div>
|
2026-04-22 01:26:36 +02:00
|
|
|
|
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
Jobs
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
Create and manage your job postings.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<button type="button" onClick={openCreate} style={BTN_PRIMARY}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
+ Create Job
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={actionMsg()}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
...CARD,
|
|
|
|
|
|
"margin-bottom": "14px",
|
|
|
|
|
|
padding: "12px 14px",
|
|
|
|
|
|
"font-size": "13px",
|
|
|
|
|
|
color: "#374151",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
{actionMsg()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={showForm()}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ ...CARD, "margin-bottom": "16px", border: "1px solid #FF5E13" }}>
|
|
|
|
|
|
<p
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: "0 0 14px",
|
|
|
|
|
|
"font-size": "16px",
|
|
|
|
|
|
"font-weight": "800",
|
|
|
|
|
|
color: "#111827",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
New Job
|
|
|
|
|
|
</p>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
2026-05-01 02:54:25 +02:00
|
|
|
|
<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>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ "grid-column": "span 2" }}>
|
2026-05-01 02:54:25 +02:00
|
|
|
|
<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>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
value={form().title}
|
|
|
|
|
|
onInput={(e) => setField("title", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
placeholder="Frontend Developer"
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-05-01 02:54:25 +02:00
|
|
|
|
<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>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
value={form().category}
|
|
|
|
|
|
onInput={(e) => setField("category", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
placeholder="Engineering"
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style={LABEL}>Job Type</label>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<select
|
|
|
|
|
|
value={form().job_type}
|
|
|
|
|
|
onChange={(e) => setField("job_type", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<option value="FULL_TIME">Full Time</option>
|
|
|
|
|
|
<option value="PART_TIME">Part Time</option>
|
|
|
|
|
|
<option value="CONTRACT">Contract</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ "grid-column": "span 2" }}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<label style={LABEL}>Location</label>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
value={form().location}
|
|
|
|
|
|
onInput={(e) => setField("location", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
placeholder="Bengaluru (Hybrid)"
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ "grid-column": "span 2" }}>
|
2026-05-01 02:54:25 +02:00
|
|
|
|
<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>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={form().description}
|
2026-04-10 01:25:49 +02:00
|
|
|
|
onInput={(e) => setField("description", e.currentTarget.value)}
|
|
|
|
|
|
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
placeholder="Role overview, responsibilities, and requirements"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style={LABEL}>Min Salary</label>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={form().salary_min}
|
|
|
|
|
|
onInput={(e) => setField("salary_min", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style={LABEL}>Max Salary</label>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={form().salary_max}
|
|
|
|
|
|
onInput={(e) => setField("salary_max", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style={LABEL}>Experience (years)</label>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={form().experience_years}
|
|
|
|
|
|
onInput={(e) => setField("experience_years", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-05-01 02:54:25 +02:00
|
|
|
|
<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>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<input
|
|
|
|
|
|
value={form().skills}
|
|
|
|
|
|
onInput={(e) => setField("skills", e.currentTarget.value)}
|
|
|
|
|
|
style={INPUT}
|
|
|
|
|
|
placeholder="Rust, SQL, Docker"
|
|
|
|
|
|
/>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Show when={error()}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<p
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: "12px 0 0",
|
|
|
|
|
|
"font-size": "13px",
|
|
|
|
|
|
color: "#EF4444",
|
|
|
|
|
|
"font-weight": "600",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{error()}
|
|
|
|
|
|
</p>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</Show>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<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
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<Show when={loading()}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs…</div>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</Show>
|
|
|
|
|
|
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<Show when={!loading() && filteredSortedJobs().length === 0}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ ...CARD, "text-align": "center", padding: "34px 24px" }}>
|
|
|
|
|
|
<p
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: "0 0 6px",
|
|
|
|
|
|
"font-size": "16px",
|
|
|
|
|
|
"font-weight": "700",
|
|
|
|
|
|
color: "#111827",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
No jobs found
|
2026-04-10 01:25:49 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
Try a different search or create your first draft job.
|
2026-04-10 01:25:49 +02:00
|
|
|
|
</p>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<Show when={!loading() && filteredSortedJobs().length > 0}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ display: "grid", gap: "10px" }}>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<For each={filteredSortedJobs()}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
{(job) => (
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div style={{ ...CARD, padding: "16px" }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
"justify-content": "space-between",
|
|
|
|
|
|
gap: "10px",
|
|
|
|
|
|
"align-items": "flex-start",
|
|
|
|
|
|
"flex-wrap": "wrap",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<div>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<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(" • ")}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<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, " ")}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<p
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: "8px 0 0",
|
|
|
|
|
|
"font-size": "13px",
|
|
|
|
|
|
color: "#374151",
|
|
|
|
|
|
"line-height": "1.5",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
{job.description}
|
|
|
|
|
|
</p>
|
feat: notifications page, auto-apply settings, customer response profiles, job status UI
- NotificationsPage: full paginated notification list with unread filter, mark read, load more
- SettingsPage: AI auto-apply section for job seekers (toggle, preferences, skills/titles/locations, salary range)
- CustomerResponsesPage: enriched professional response cards with avatar, bio, skills, location
- CompanyJobsPage: show rejection reason banner and pending-approval notice on job cards
- NotificationBell: fix "View all" link to /dashboard?nav=notifications (deep-link support)
- dashboard.tsx: ?nav= param reads sidebar page on mount; Notifications added to all role sidebars
- PayU integration: payu.ts lib, payu-return route, wallet buy/invoice pages, marketplace route
- Razorpay removed, replaced by PayU across payments flow
- ProfilePage: photo upload UI with avatar preview for all roles
- PortfolioPage: showcase image upload with file picker and preview
- CompanyApplicationsPage: applicant profile snapshot with avatar, headline, skills, resume download
- profile-fields-config: removed resume_doc from job seeker (resume is now AI-generated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:33:42 +02:00
|
|
|
|
<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 24–48 hours.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<Show when={rowTags(job).length > 0}>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
gap: "6px",
|
|
|
|
|
|
"flex-wrap": "wrap",
|
|
|
|
|
|
"margin-top": "8px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
<For each={rowTags(job).slice(0, 8)}>
|
|
|
|
|
|
{(tag) => (
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<span
|
|
|
|
|
|
style={{
|
|
|
|
|
|
"font-size": "11px",
|
|
|
|
|
|
color: "#4B5563",
|
|
|
|
|
|
background: "#F3F4F6",
|
|
|
|
|
|
border: "1px solid #E5E7EB",
|
|
|
|
|
|
"border-radius": "6px",
|
|
|
|
|
|
padding: "2px 8px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-26 23:58:43 +02:00
|
|
|
|
{tag}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<div
|
|
|
|
|
|
style={{ display: "flex", gap: "8px", "margin-top": "12px", "flex-wrap": "wrap" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Show when={job.status === "DRAFT"}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => submitJob(job.id)}
|
|
|
|
|
|
disabled={busyJobId() === job.id}
|
2026-04-10 01:25:49 +02:00
|
|
|
|
style={{
|
2026-04-26 23:58:43 +02:00
|
|
|
|
...BTN_PRIMARY,
|
2026-04-10 01:25:49 +02:00
|
|
|
|
height: "32px",
|
|
|
|
|
|
"font-size": "12px",
|
|
|
|
|
|
padding: "0 14px",
|
|
|
|
|
|
opacity: busyJobId() === job.id ? "0.65" : "1",
|
|
|
|
|
|
}}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
>
|
|
|
|
|
|
Submit for Approval
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Show>
|
2026-04-10 01:25:49 +02:00
|
|
|
|
<Show when={job.status !== "CLOSED"}>
|
2026-04-08 22:40:43 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => closeJob(job.id)}
|
|
|
|
|
|
disabled={busyJobId() === job.id}
|
2026-04-10 01:25:49 +02:00
|
|
|
|
style={{
|
|
|
|
|
|
...BTN_GHOST,
|
|
|
|
|
|
height: "32px",
|
|
|
|
|
|
"font-size": "12px",
|
|
|
|
|
|
padding: "0 14px",
|
|
|
|
|
|
opacity: busyJobId() === job.id ? "0.65" : "1",
|
|
|
|
|
|
}}
|
2026-04-08 22:40:43 +02:00
|
|
|
|
>
|
|
|
|
|
|
Close Job
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|