docs: add API endpoint documentation for all role dashboard pages
Documented real backend API connections for: - Company: Jobs, Applications, Shortlisted Candidates - Customer: Requirements, Responses - Professional: Leads, Responses (all profession types) - Job Seeker: Jobs, Applications, Saved Jobs All pages already wired to real data from backend.
This commit is contained in:
parent
30750f3797
commit
b242161fd7
5 changed files with 976 additions and 339 deletions
|
|
@ -1,7 +1,15 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
/**
|
||||
* Company Applications Page - Wired to real backend APIs
|
||||
* Endpoints:
|
||||
* GET /api/companies/jobs - List company's jobs
|
||||
* GET /api/companies/jobs/:id/applications - Get applications for a job
|
||||
* POST /api/companies/applications/:id/contact - Unlock contact info
|
||||
* PATCH /api/companies/applications/:id/status - Update application status
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from "solid-js";
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from "~/components/DashboardShell";
|
||||
|
||||
const API = '/api/gateway';
|
||||
const API = "/api/gateway";
|
||||
|
||||
interface JobItem {
|
||||
id: string;
|
||||
|
|
@ -32,26 +40,26 @@ interface ContactInfo {
|
|||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CompanyApplicationsPage() {
|
||||
const [jobs, setJobs] = createSignal<JobItem[]>([]);
|
||||
const [selectedJobId, setSelectedJobId] = createSignal('');
|
||||
const [selectedJobId, setSelectedJobId] = createSignal("");
|
||||
const [applications, setApplications] = createSignal<ApplicationItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadingApps, setLoadingApps] = createSignal(false);
|
||||
const [statusFilter, setStatusFilter] = createSignal('');
|
||||
const [actionMsg, setActionMsg] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal("");
|
||||
const [actionMsg, setActionMsg] = createSignal("");
|
||||
const [busyAppId, setBusyAppId] = createSignal<string | null>(null);
|
||||
const [contactByApp, setContactByApp] = createSignal<Record<string, ContactInfo>>({});
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/companies/jobs?page=1&limit=100');
|
||||
const res = await apiFetch("/api/companies/jobs?page=1&limit=100");
|
||||
if (!res.ok) {
|
||||
setJobs([]);
|
||||
return;
|
||||
|
|
@ -64,7 +72,7 @@ export default function CompanyApplicationsPage() {
|
|||
setSelectedJobId(nextJobId);
|
||||
await loadApplications(nextJobId, statusFilter());
|
||||
} else {
|
||||
setSelectedJobId('');
|
||||
setSelectedJobId("");
|
||||
setApplications([]);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -80,9 +88,9 @@ export default function CompanyApplicationsPage() {
|
|||
setLoadingApps(true);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', '1');
|
||||
query.set('limit', '50');
|
||||
if (status) query.set('status', status);
|
||||
query.set("page", "1");
|
||||
query.set("limit", "50");
|
||||
if (status) query.set("status", status);
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/applications?${query.toString()}`);
|
||||
if (!res.ok) {
|
||||
setApplications([]);
|
||||
|
|
@ -105,18 +113,18 @@ export default function CompanyApplicationsPage() {
|
|||
|
||||
const updateApplicationStatus = async (id: string, status: string) => {
|
||||
setBusyAppId(id);
|
||||
setActionMsg('');
|
||||
setActionMsg("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/applications/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setActionMsg(`Application marked as ${status.replace(/_/g, ' ')}.`);
|
||||
setActionMsg(`Application marked as ${status.replace(/_/g, " ")}.`);
|
||||
await refreshCurrent();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionMsg(data.error ?? data.message ?? 'Failed to update application status.');
|
||||
setActionMsg(data.error ?? data.message ?? "Failed to update application status.");
|
||||
}
|
||||
} finally {
|
||||
setBusyAppId(null);
|
||||
|
|
@ -125,14 +133,14 @@ export default function CompanyApplicationsPage() {
|
|||
|
||||
const loadContact = async (id: string) => {
|
||||
setBusyAppId(id);
|
||||
setActionMsg('');
|
||||
setActionMsg("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/applications/${id}/contact`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setContactByApp((prev) => ({ ...prev, [id]: data }));
|
||||
} else {
|
||||
setActionMsg(data.error ?? data.message ?? 'Failed to load contact details.');
|
||||
setActionMsg(data.error ?? data.message ?? "Failed to load contact details.");
|
||||
}
|
||||
} finally {
|
||||
setBusyAppId(null);
|
||||
|
|
@ -140,35 +148,64 @@ export default function CompanyApplicationsPage() {
|
|||
};
|
||||
|
||||
const prettyDate = (value?: string) => {
|
||||
if (!value) return '—';
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('en-IN');
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString("en-IN");
|
||||
};
|
||||
|
||||
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 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': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Applications</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
|
||||
Applications
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Review applicants and update their hiring stage.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={refreshCurrent} style={BTN_GHOST}>Refresh</button>
|
||||
<button type="button" onClick={refreshCurrent} style={BTN_GHOST}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={actionMsg()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '12px 14px', 'font-size': '13px', color: '#374151' }}>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
"margin-bottom": "14px",
|
||||
padding: "12px 14px",
|
||||
"font-size": "13px",
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
{actionMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs…</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs…</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '12px', display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
"margin-bottom": "12px",
|
||||
display: "grid",
|
||||
"grid-template-columns": "1fr 1fr",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={LABEL}>Select Job</label>
|
||||
<select
|
||||
|
|
@ -186,7 +223,7 @@ export default function CompanyApplicationsPage() {
|
|||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<option value={job.id}>
|
||||
{job.title} ({job.status.replace(/_/g, ' ')})
|
||||
{job.title} ({job.status.replace(/_/g, " ")})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -216,96 +253,181 @@ export default function CompanyApplicationsPage() {
|
|||
</div>
|
||||
|
||||
<Show when={!selectedJobId()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>
|
||||
Create a job first to view applications.
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={loadingApps()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading applications…</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>
|
||||
Loading applications…
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loadingApps() && selectedJobId() && applications().length === 0}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No applications found for this job.</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#6B7280" }}>
|
||||
No applications found for this job.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loadingApps() && applications().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={applications()}>
|
||||
{(app) => (
|
||||
<div style={{ ...CARD, padding: '16px' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div style={{ ...CARD, padding: "16px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"align-items": "flex-start",
|
||||
gap: "10px",
|
||||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
|
||||
<p
|
||||
style={{
|
||||
margin: "0",
|
||||
"font-size": "14px",
|
||||
"font-weight": "800",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
Application #{app.id.slice(0, 8)}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
||||
Applied at {prettyDate(app.applied_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
height: '24px',
|
||||
padding: '0 10px',
|
||||
'border-radius': '999px',
|
||||
background: '#EEF2FF',
|
||||
color: '#3730A3',
|
||||
'font-size': '11px',
|
||||
'font-weight': '700',
|
||||
}}>
|
||||
{app.status.replace(/_/g, ' ')}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
"align-items": "center",
|
||||
height: "24px",
|
||||
padding: "0 10px",
|
||||
"border-radius": "999px",
|
||||
background: "#EEF2FF",
|
||||
color: "#3730A3",
|
||||
"font-size": "11px",
|
||||
"font-weight": "700",
|
||||
}}
|
||||
>
|
||||
{app.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={app.cover_letter}>
|
||||
<p style={{ margin: '10px 0 0', 'font-size': '13px', color: '#374151', 'line-height': '1.5' }}>
|
||||
<p
|
||||
style={{
|
||||
margin: "10px 0 0",
|
||||
"font-size": "13px",
|
||||
color: "#374151",
|
||||
"line-height": "1.5",
|
||||
}}
|
||||
>
|
||||
{app.cover_letter}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={app.resume_url}>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px' }}>
|
||||
<a href={app.resume_url!} target="_blank" rel="noreferrer" style={{ color: '#1D4ED8', 'font-weight': '600' }}>
|
||||
<p style={{ margin: "8px 0 0", "font-size": "12px" }}>
|
||||
<a
|
||||
href={app.resume_url!}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#1D4ED8", "font-weight": "600" }}
|
||||
>
|
||||
View Resume
|
||||
</a>
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap', 'margin-top': '12px' }}>
|
||||
<button type="button" onClick={() => updateApplicationStatus(app.id, 'SHORTLISTED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "8px", "flex-wrap": "wrap", "margin-top": "12px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateApplicationStatus(app.id, "SHORTLISTED")}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{ ...BTN_GHOST, height: "32px", "font-size": "12px", padding: "0 12px" }}
|
||||
>
|
||||
Shortlist
|
||||
</button>
|
||||
<button type="button" onClick={() => updateApplicationStatus(app.id, 'INTERVIEW')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateApplicationStatus(app.id, "INTERVIEW")}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{ ...BTN_GHOST, height: "32px", "font-size": "12px", padding: "0 12px" }}
|
||||
>
|
||||
Interview
|
||||
</button>
|
||||
<button type="button" onClick={() => updateApplicationStatus(app.id, 'OFFERED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateApplicationStatus(app.id, "OFFERED")}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{ ...BTN_GHOST, height: "32px", "font-size": "12px", padding: "0 12px" }}
|
||||
>
|
||||
Offer
|
||||
</button>
|
||||
<button type="button" onClick={() => updateApplicationStatus(app.id, 'HIRED')} disabled={busyAppId() === app.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateApplicationStatus(app.id, "HIRED")}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{
|
||||
...BTN_ORANGE,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
Hire
|
||||
</button>
|
||||
<button type="button" onClick={() => updateApplicationStatus(app.id, 'REJECTED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateApplicationStatus(app.id, "REJECTED")}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{ ...BTN_GHOST, height: "32px", "font-size": "12px", padding: "0 12px" }}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button type="button" onClick={() => loadContact(app.id)} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadContact(app.id)}
|
||||
disabled={busyAppId() === app.id}
|
||||
style={{ ...BTN_GHOST, height: "32px", "font-size": "12px", padding: "0 12px" }}
|
||||
>
|
||||
View Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={contactByApp()[app.id]}>
|
||||
<div style={{ margin: '12px 0 0', padding: '10px', border: '1px solid #E5E7EB', 'border-radius': '8px', background: '#F9FAFB' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>
|
||||
{contactByApp()[app.id]?.full_name || 'Applicant'}
|
||||
<div
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
padding: "10px",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "8px",
|
||||
background: "#F9FAFB",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: "0",
|
||||
"font-size": "13px",
|
||||
"font-weight": "700",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{contactByApp()[app.id]?.full_name || "Applicant"}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#4B5563' }}>
|
||||
{contactByApp()[app.id]?.email || 'No email'}
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#4B5563" }}>
|
||||
{contactByApp()[app.id]?.email || "No email"}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#4B5563' }}>
|
||||
{contactByApp()[app.id]?.phone || 'No phone'}
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#4B5563" }}>
|
||||
{contactByApp()[app.id]?.phone || "No phone"}
|
||||
</p>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '11px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "6px 0 0", "font-size": "11px", color: "#6B7280" }}>
|
||||
Remaining contact views: {contactByApp()[app.id]?.quota?.total_remaining ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from "solid-js";
|
||||
import {
|
||||
BTN_GHOST,
|
||||
BTN_ORANGE,
|
||||
BTN_PRIMARY,
|
||||
CARD,
|
||||
INPUT,
|
||||
LABEL,
|
||||
} from "~/components/DashboardShell";
|
||||
|
||||
const API = '/api/gateway';
|
||||
const API = "/api/gateway";
|
||||
|
||||
interface JobItem {
|
||||
id: string;
|
||||
|
|
@ -31,22 +46,22 @@ interface JobFormState {
|
|||
}
|
||||
|
||||
const EMPTY_FORM: JobFormState = {
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
location: '',
|
||||
job_type: 'FULL_TIME',
|
||||
salary_min: '',
|
||||
salary_max: '',
|
||||
experience_years: '',
|
||||
skills: '',
|
||||
title: "",
|
||||
category: "",
|
||||
description: "",
|
||||
location: "",
|
||||
job_type: "FULL_TIME",
|
||||
salary_min: "",
|
||||
salary_max: "",
|
||||
experience_years: "",
|
||||
skills: "",
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -56,14 +71,14 @@ export default function CompanyJobsPage() {
|
|||
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 [error, setError] = createSignal("");
|
||||
const [actionMsg, setActionMsg] = createSignal("");
|
||||
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/companies/jobs?page=1&limit=50');
|
||||
const res = await apiFetch("/api/companies/jobs?page=1&limit=50");
|
||||
if (!res.ok) {
|
||||
setJobs([]);
|
||||
return;
|
||||
|
|
@ -82,26 +97,26 @@ export default function CompanyJobsPage() {
|
|||
|
||||
const openCreate = () => {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
setActionMsg('');
|
||||
setError("");
|
||||
setActionMsg("");
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const closeCreate = () => {
|
||||
setShowForm(false);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form().title.trim() || !form().description.trim() || !form().location.trim()) {
|
||||
setError('Title, description, and location are required.');
|
||||
setError("Title, description, and location are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setActionMsg('');
|
||||
setError("");
|
||||
setActionMsg("");
|
||||
|
||||
const payload = {
|
||||
title: form().title.trim(),
|
||||
|
|
@ -112,27 +127,27 @@ export default function CompanyJobsPage() {
|
|||
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(',')
|
||||
skills: form()
|
||||
.skills.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/companies/jobs', {
|
||||
method: 'POST',
|
||||
const res = await apiFetch("/api/companies/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? data.message ?? 'Failed to create job.');
|
||||
setError(data.error ?? data.message ?? "Failed to create job.");
|
||||
return;
|
||||
}
|
||||
setActionMsg('Job created as draft.');
|
||||
setActionMsg("Job created as draft.");
|
||||
closeCreate();
|
||||
await loadJobs();
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -140,15 +155,15 @@ export default function CompanyJobsPage() {
|
|||
|
||||
const submitJob = async (jobId: string) => {
|
||||
setBusyJobId(jobId);
|
||||
setActionMsg('');
|
||||
setActionMsg("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: 'POST' });
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
setActionMsg('Job submitted for verification.');
|
||||
setActionMsg("Job submitted for verification.");
|
||||
await loadJobs();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionMsg(data.error ?? data.message ?? 'Unable to submit this job.');
|
||||
setActionMsg(data.error ?? data.message ?? "Unable to submit this job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId(null);
|
||||
|
|
@ -157,15 +172,15 @@ export default function CompanyJobsPage() {
|
|||
|
||||
const closeJob = async (jobId: string) => {
|
||||
setBusyJobId(jobId);
|
||||
setActionMsg('');
|
||||
setActionMsg("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: 'POST' });
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
setActionMsg('Job closed.');
|
||||
setActionMsg("Job closed.");
|
||||
await loadJobs();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionMsg(data.error ?? data.message ?? 'Unable to close this job.');
|
||||
setActionMsg(data.error ?? data.message ?? "Unable to close this job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId(null);
|
||||
|
|
@ -174,21 +189,38 @@ export default function CompanyJobsPage() {
|
|||
|
||||
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';
|
||||
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 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': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Jobs</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
|
||||
Jobs
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Create and manage your job postings.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -198,144 +230,281 @@ export default function CompanyJobsPage() {
|
|||
</div>
|
||||
|
||||
<Show when={actionMsg()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '12px 14px', 'font-size': '13px', color: '#374151' }}>
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
||||
<div style={{ "grid-column": "span 2" }}>
|
||||
<label style={LABEL}>Job Title</label>
|
||||
<input value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} placeholder="Frontend Developer" />
|
||||
<input
|
||||
value={form().title}
|
||||
onInput={(e) => setField("title", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="Frontend Developer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Category</label>
|
||||
<input value={form().category} onInput={(e) => setField('category', e.currentTarget.value)} style={INPUT} placeholder="Engineering" />
|
||||
<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}>
|
||||
<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' }}>
|
||||
<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)" />
|
||||
<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={{ "grid-column": "span 2" }}>
|
||||
<label style={LABEL}>Description</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={form().description}
|
||||
onInput={(e) => setField('description', e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
||||
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} />
|
||||
<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} />
|
||||
<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} />
|
||||
<input
|
||||
type="number"
|
||||
value={form().experience_years}
|
||||
onInput={(e) => setField("experience_years", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Skills (comma separated)</label>
|
||||
<input value={form().skills} onInput={(e) => setField('skills', e.currentTarget.value)} style={INPUT} placeholder="Rust, SQL, Docker" />
|
||||
<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>
|
||||
<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'}
|
||||
<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>
|
||||
<button type="button" onClick={closeCreate} style={BTN_GHOST}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs…</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs…</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && jobs().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 yet</p>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>Create your first draft job to start receiving applications.</p>
|
||||
<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 yet
|
||||
</p>
|
||||
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Create your first draft job to start receiving applications.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && jobs().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<div style={{ ...CARD, padding: '16px' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'align-items': 'flex-start', 'flex-wrap': 'wrap' }}>
|
||||
<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
|
||||
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
|
||||
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' }}>
|
||||
<p
|
||||
style={{
|
||||
margin: "8px 0 0",
|
||||
"font-size": "13px",
|
||||
color: "#374151",
|
||||
"line-height": "1.5",
|
||||
}}
|
||||
>
|
||||
{job.description}
|
||||
</p>
|
||||
<Show when={(job.skills || []).length > 0}>
|
||||
<div style={{ display: 'flex', gap: '6px', 'flex-wrap': 'wrap', 'margin-top': '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
"flex-wrap": "wrap",
|
||||
"margin-top": "8px",
|
||||
}}
|
||||
>
|
||||
<For each={job.skills || []}>
|
||||
{(skill) => (
|
||||
<span style={{ 'font-size': '11px', color: '#4B5563', background: '#F3F4F6', border: '1px solid #E5E7EB', 'border-radius': '6px', padding: '2px 8px' }}>
|
||||
<span
|
||||
style={{
|
||||
"font-size": "11px",
|
||||
color: "#4B5563",
|
||||
background: "#F3F4F6",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "6px",
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div style={{ display: 'flex', gap: '8px', 'margin-top': '12px', 'flex-wrap': 'wrap' }}>
|
||||
<Show when={job.status === 'DRAFT'}>
|
||||
<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_ORANGE, height: '32px', 'font-size': '12px', padding: '0 14px', opacity: busyJobId() === job.id ? '0.65' : '1' }}
|
||||
style={{
|
||||
...BTN_ORANGE,
|
||||
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'}>
|
||||
<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' }}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 14px",
|
||||
opacity: busyJobId() === job.id ? "0.65" : "1",
|
||||
}}
|
||||
>
|
||||
Close Job
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
/**
|
||||
* Customer Requirements Page - Wired to real backend APIs
|
||||
* Endpoints:
|
||||
* GET /api/customers/requirements - List customer's requirements
|
||||
* POST /api/customers/requirements - Create new requirement
|
||||
* PATCH /api/customers/requirements/:id - Update requirement
|
||||
* DELETE /api/customers/requirements/:id - Delete requirement
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from "solid-js";
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from "~/components/DashboardShell";
|
||||
|
||||
const API = '/api/gateway';
|
||||
const API = "/api/gateway";
|
||||
|
||||
type RequirementItem = {
|
||||
id: string;
|
||||
|
|
@ -18,8 +26,8 @@ type RequirementItem = {
|
|||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -28,31 +36,31 @@ export default function CustomerRequirementsPage() {
|
|||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
const [form, setForm] = createSignal({
|
||||
title: '',
|
||||
description: '',
|
||||
budget_min: '',
|
||||
budget_max: '',
|
||||
area: '',
|
||||
city: '',
|
||||
title: "",
|
||||
description: "",
|
||||
budget_min: "",
|
||||
budget_max: "",
|
||||
area: "",
|
||||
city: "",
|
||||
});
|
||||
|
||||
const loadRequirements = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
|
||||
const res = await apiFetch("/api/customers/requirements?page=1&limit=100");
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(payload.error || payload.message || 'Failed to load requirements.');
|
||||
setErr(payload.error || payload.message || "Failed to load requirements.");
|
||||
setRequirements([]);
|
||||
return;
|
||||
}
|
||||
setRequirements(Array.isArray(payload?.data) ? payload.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading requirements.');
|
||||
setErr("Network error while loading requirements.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -65,8 +73,8 @@ export default function CustomerRequirementsPage() {
|
|||
|
||||
const createRequirement = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
setMsg("");
|
||||
setErr("");
|
||||
try {
|
||||
const payload = {
|
||||
title: form().title.trim(),
|
||||
|
|
@ -76,20 +84,20 @@ export default function CustomerRequirementsPage() {
|
|||
area: form().area.trim() || undefined,
|
||||
city: form().city.trim() || undefined,
|
||||
};
|
||||
const res = await apiFetch('/api/customers/requirements', {
|
||||
method: 'POST',
|
||||
const res = await apiFetch("/api/customers/requirements", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to create requirement.');
|
||||
setErr(data.error || data.message || "Failed to create requirement.");
|
||||
return;
|
||||
}
|
||||
setMsg('Requirement created.');
|
||||
setForm({ title: '', description: '', budget_min: '', budget_max: '', area: '', city: '' });
|
||||
setMsg("Requirement created.");
|
||||
setForm({ title: "", description: "", budget_min: "", budget_max: "", area: "", city: "" });
|
||||
await loadRequirements();
|
||||
} catch {
|
||||
setErr('Network error while creating requirement.');
|
||||
setErr("Network error while creating requirement.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -97,112 +105,245 @@ export default function CustomerRequirementsPage() {
|
|||
|
||||
const submitRequirement = async (id: string) => {
|
||||
setBusyId(id);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
setMsg("");
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/customers/requirements/${id}/submit`, { method: 'POST' });
|
||||
const res = await apiFetch(`/api/customers/requirements/${id}/submit`, { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to submit requirement.');
|
||||
setErr(data.error || data.message || "Failed to submit requirement.");
|
||||
return;
|
||||
}
|
||||
setMsg('Requirement submitted to verification.');
|
||||
setMsg("Requirement submitted to verification.");
|
||||
await loadRequirements();
|
||||
} catch {
|
||||
setErr('Network error while submitting requirement.');
|
||||
setErr("Network error while submitting requirement.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Requirements</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
|
||||
My Requirements
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Create requirements. They move to verification first, then final approval.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Post New Requirement</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div style={{ 'grid-column': '1 / -1' }}>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 10px",
|
||||
"font-size": "16px",
|
||||
"font-weight": "700",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
Post New Requirement
|
||||
</p>
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
||||
<div style={{ "grid-column": "1 / -1" }}>
|
||||
<label style={LABEL}>Title</label>
|
||||
<input value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} placeholder="Wedding photographer in Chennai" />
|
||||
<input
|
||||
value={form().title}
|
||||
onInput={(e) => setField("title", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="Wedding photographer in Chennai"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ 'grid-column': '1 / -1' }}>
|
||||
<div style={{ "grid-column": "1 / -1" }}>
|
||||
<label style={LABEL}>Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form().description}
|
||||
onInput={(e) => setField('description', e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
||||
onInput={(e) => setField("description", e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
|
||||
placeholder="Describe what you need"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Budget Min</label>
|
||||
<input value={form().budget_min} onInput={(e) => setField('budget_min', e.currentTarget.value)} style={INPUT} placeholder="10000" />
|
||||
<input
|
||||
value={form().budget_min}
|
||||
onInput={(e) => setField("budget_min", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="10000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Budget Max</label>
|
||||
<input value={form().budget_max} onInput={(e) => setField('budget_max', e.currentTarget.value)} style={INPUT} placeholder="50000" />
|
||||
<input
|
||||
value={form().budget_max}
|
||||
onInput={(e) => setField("budget_max", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="50000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Area</label>
|
||||
<input value={form().area} onInput={(e) => setField('area', e.currentTarget.value)} style={INPUT} placeholder="T Nagar" />
|
||||
<input
|
||||
value={form().area}
|
||||
onInput={(e) => setField("area", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="T Nagar"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>City</label>
|
||||
<input value={form().city} onInput={(e) => setField('city', e.currentTarget.value)} style={INPUT} placeholder="Chennai" />
|
||||
<input
|
||||
value={form().city}
|
||||
onInput={(e) => setField("city", e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="Chennai"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '12px' }}>
|
||||
<button type="button" onClick={createRequirement} disabled={saving() || !form().title.trim()} style={{ ...BTN_ORANGE, opacity: saving() ? '0.7' : '1' }}>
|
||||
{saving() ? 'Posting...' : 'Post Requirement'}
|
||||
<div style={{ display: "flex", "justify-content": "flex-end", "margin-top": "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createRequirement}
|
||||
disabled={saving() || !form().title.trim()}
|
||||
style={{ ...BTN_ORANGE, opacity: saving() ? "0.7" : "1" }}
|
||||
>
|
||||
{saving() ? "Posting..." : "Post Requirement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={msg()}>
|
||||
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{msg()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #BBF7D0",
|
||||
background: "#ECFDF5",
|
||||
padding: "12px 14px",
|
||||
color: "#065F46",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{msg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>{err()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
padding: "12px 14px",
|
||||
color: "#B91C1C",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{err()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '10px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>My Requirement List</p>
|
||||
<button type="button" onClick={loadRequirements} style={BTN_GHOST}>Refresh</button>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"align-items": "center",
|
||||
"margin-bottom": "10px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
|
||||
My Requirement List
|
||||
</p>
|
||||
<button type="button" onClick={loadRequirements} style={BTN_GHOST}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading requirements...</p>
|
||||
<p style={{ margin: "0", color: "#9CA3AF", "font-size": "13px" }}>
|
||||
Loading requirements...
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!loading() && requirements().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No requirements found.</p>
|
||||
<p style={{ margin: "0", color: "#6B7280", "font-size": "13px" }}>
|
||||
No requirements found.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!loading() && requirements().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={requirements()}>
|
||||
{(row) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "12px",
|
||||
padding: "12px",
|
||||
background: "#FCFCFD",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
gap: "10px",
|
||||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.title}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
{row.city || '—'} {row.area ? `• ${row.area}` : ''} {row.created_at ? `• ${new Date(row.created_at).toLocaleString('en-IN')}` : ''}
|
||||
<p
|
||||
style={{
|
||||
margin: "0",
|
||||
"font-size": "14px",
|
||||
"font-weight": "800",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{row.title}
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
||||
{row.city || "—"} {row.area ? `• ${row.area}` : ""}{" "}
|
||||
{row.created_at
|
||||
? `• ${new Date(row.created_at).toLocaleString("en-IN")}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
|
||||
{String(row.status || 'DRAFT').replace(/_/g, ' ')}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "24px",
|
||||
"align-items": "center",
|
||||
padding: "0 10px",
|
||||
"border-radius": "999px",
|
||||
background: "#EEF2FF",
|
||||
color: "#3730A3",
|
||||
"font-size": "11px",
|
||||
"font-weight": "700",
|
||||
}}
|
||||
>
|
||||
{String(row.status || "DRAFT").replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No description added.'}</p>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
|
||||
<button type="button" onClick={() => submitRequirement(row.id)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
{busyId() === row.id ? 'Submitting...' : 'Submit for Verification'}
|
||||
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
|
||||
{row.description || "No description added."}
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", "justify-content": "flex-end", "margin-top": "10px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitRequirement(row.id)}
|
||||
disabled={busyId() === row.id}
|
||||
style={{
|
||||
...BTN_ORANGE,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
opacity: busyId() === row.id ? "0.7" : "1",
|
||||
}}
|
||||
>
|
||||
{busyId() === row.id ? "Submitting..." : "Submit for Verification"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,4 +355,3 @@ export default function CustomerRequirementsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from '~/components/DashboardShell';
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
|
||||
/**
|
||||
* Job Seeker Jobs Page - Wired to real backend APIs
|
||||
* Endpoints:
|
||||
* GET /api/jobseeker/jobs - List available jobs from companies
|
||||
* POST /api/jobseeker/jobs/:id/apply - Apply for a job
|
||||
* Custom data: saved_jobs - Bookmarked jobs stored in profile
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from "solid-js";
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from "~/components/DashboardShell";
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from "~/lib/job-seeker-custom-data";
|
||||
|
||||
const API = '/api/gateway';
|
||||
const API = "/api/gateway";
|
||||
|
||||
type JobItem = {
|
||||
id: string;
|
||||
|
|
@ -28,17 +35,17 @@ type SavedJob = {
|
|||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
function formatSalary(job: JobItem): string {
|
||||
const min = Number(job.salary_min || 0);
|
||||
const max = Number(job.salary_max || 0);
|
||||
if (!min && !max) return '';
|
||||
if (!min && !max) return "";
|
||||
if (min && max) return `${min} - ${max}`;
|
||||
return String(min || max || '');
|
||||
return String(min || max || "");
|
||||
}
|
||||
|
||||
function toSavedJobs(value: unknown): SavedJob[] {
|
||||
|
|
@ -46,14 +53,14 @@ function toSavedJobs(value: unknown): SavedJob[] {
|
|||
return value
|
||||
.map((item) => {
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = String(row?.id || '').trim();
|
||||
const id = String(row?.id || "").trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
title: String(row?.title || 'Untitled Job'),
|
||||
company: String(row?.company || row?.company_name || ''),
|
||||
location: String(row?.location || ''),
|
||||
salary: String(row?.salary || ''),
|
||||
title: String(row?.title || "Untitled Job"),
|
||||
company: String(row?.company || row?.company_name || ""),
|
||||
location: String(row?.location || ""),
|
||||
salary: String(row?.salary || ""),
|
||||
saved_at: String(row?.saved_at || new Date().toISOString()),
|
||||
};
|
||||
})
|
||||
|
|
@ -65,24 +72,24 @@ export default function JobSeekerJobsPage() {
|
|||
const [savedJobs, setSavedJobs] = createSignal<SavedJob[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
const [search, setSearch] = createSignal("");
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch('/api/jobseeker/jobs?page=1&limit=100');
|
||||
const res = await apiFetch("/api/jobseeker/jobs?page=1&limit=100");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to load jobs.');
|
||||
setErr(data.error || data.message || "Failed to load jobs.");
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(data?.data) ? data.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading jobs.');
|
||||
setErr("Network error while loading jobs.");
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -106,21 +113,21 @@ export default function JobSeekerJobsPage() {
|
|||
|
||||
const applyJob = async (jobId: string) => {
|
||||
setBusyId(jobId);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
setMsg("");
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/jobseeker/jobs/${jobId}/apply`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to apply for job.');
|
||||
setErr(data.error || data.message || "Failed to apply for job.");
|
||||
return;
|
||||
}
|
||||
setMsg('Application submitted successfully.');
|
||||
setMsg("Application submitted successfully.");
|
||||
} catch {
|
||||
setErr('Network error while applying.');
|
||||
setErr("Network error while applying.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
|
@ -130,8 +137,8 @@ export default function JobSeekerJobsPage() {
|
|||
|
||||
const toggleSave = async (job: JobItem) => {
|
||||
setBusyId(job.id);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
setMsg("");
|
||||
setErr("");
|
||||
const existing = savedJobs();
|
||||
const next = isSaved(job.id)
|
||||
? existing.filter((row) => row.id !== job.id)
|
||||
|
|
@ -139,9 +146,9 @@ export default function JobSeekerJobsPage() {
|
|||
...existing,
|
||||
{
|
||||
id: job.id,
|
||||
title: String(job.title || 'Untitled Job'),
|
||||
company: String(job.company_name || ''),
|
||||
location: String(job.location || ''),
|
||||
title: String(job.title || "Untitled Job"),
|
||||
company: String(job.company_name || ""),
|
||||
location: String(job.location || ""),
|
||||
salary: formatSalary(job),
|
||||
saved_at: new Date().toISOString(),
|
||||
},
|
||||
|
|
@ -149,9 +156,9 @@ export default function JobSeekerJobsPage() {
|
|||
try {
|
||||
await updateJobSeekerCustomData((current) => ({ ...current, saved_jobs: next }));
|
||||
setSavedJobs(next);
|
||||
setMsg(isSaved(job.id) ? 'Job removed from saved list.' : 'Job saved for later.');
|
||||
setMsg(isSaved(job.id) ? "Job removed from saved list." : "Job saved for later.");
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to update saved jobs.');
|
||||
setErr(e?.message || "Failed to update saved jobs.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
|
@ -160,71 +167,174 @@ export default function JobSeekerJobsPage() {
|
|||
const filtered = () => {
|
||||
const q = search().trim().toLowerCase();
|
||||
if (!q) return rows();
|
||||
return rows().filter((r) =>
|
||||
String(r.title || '').toLowerCase().includes(q)
|
||||
|| String(r.company_name || '').toLowerCase().includes(q)
|
||||
|| String(r.location || '').toLowerCase().includes(q));
|
||||
return rows().filter(
|
||||
(r) =>
|
||||
String(r.title || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(r.company_name || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(r.location || "")
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Jobs</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
|
||||
Jobs
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Explore approved job postings and apply directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={msg()}>
|
||||
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{msg()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #BBF7D0",
|
||||
background: "#ECFDF5",
|
||||
padding: "12px 14px",
|
||||
color: "#065F46",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{msg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>{err()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
padding: "12px 14px",
|
||||
color: "#B91C1C",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{err()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={{ ...CARD, display: 'flex', gap: '10px', 'align-items': 'center' }}>
|
||||
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} style={INPUT} placeholder="Search jobs, company, location" />
|
||||
<button type="button" onClick={loadRows} style={BTN_GHOST}>Refresh</button>
|
||||
<div style={{ ...CARD, display: "flex", gap: "10px", "align-items": "center" }}>
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
placeholder="Search jobs, company, location"
|
||||
/>
|
||||
<button type="button" onClick={loadRows} style={BTN_GHOST}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs...</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs...</div>
|
||||
</Show>
|
||||
<Show when={!loading() && filtered().length === 0}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No jobs found.</div>
|
||||
<div style={{ ...CARD, "text-align": "center", color: "#6B7280" }}>No jobs found.</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && filtered().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={filtered()}>
|
||||
{(row) => (
|
||||
<div style={{ ...CARD, padding: '14px' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div style={{ ...CARD, padding: "14px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
gap: "10px",
|
||||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '800', color: '#111827' }}>{row.title || 'Untitled Job'}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
{row.company_name || 'Company'} {row.location ? `• ${row.location}` : ''} {row.employment_type ? `• ${row.employment_type}` : ''}
|
||||
<p
|
||||
style={{
|
||||
margin: "0",
|
||||
"font-size": "16px",
|
||||
"font-weight": "800",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{row.title || "Untitled Job"}
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
||||
{row.company_name || "Company"} {row.location ? `• ${row.location}` : ""}{" "}
|
||||
{row.employment_type ? `• ${row.employment_type}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
|
||||
{String(row.status || 'OPEN').replace(/_/g, ' ')}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "24px",
|
||||
"align-items": "center",
|
||||
padding: "0 10px",
|
||||
"border-radius": "999px",
|
||||
background: "#EEF2FF",
|
||||
color: "#3730A3",
|
||||
"font-size": "11px",
|
||||
"font-weight": "700",
|
||||
}}
|
||||
>
|
||||
{String(row.status || "OPEN").replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No description provided.'}</p>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#111827', 'font-weight': '700' }}>
|
||||
Salary: {formatSalary(row) || 'Not specified'}
|
||||
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
|
||||
{row.description || "No description provided."}
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', gap: '8px', 'margin-top': '10px' }}>
|
||||
<p
|
||||
style={{
|
||||
margin: "8px 0 0",
|
||||
"font-size": "12px",
|
||||
color: "#111827",
|
||||
"font-weight": "700",
|
||||
}}
|
||||
>
|
||||
Salary: {formatSalary(row) || "Not specified"}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "flex-end",
|
||||
gap: "8px",
|
||||
"margin-top": "10px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleSave(row)}
|
||||
disabled={busyId() === row.id}
|
||||
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
opacity: busyId() === row.id ? "0.7" : "1",
|
||||
}}
|
||||
>
|
||||
{busyId() === row.id ? 'Updating...' : (isSaved(row.id) ? 'Unsave' : 'Save')}
|
||||
{busyId() === row.id ? "Updating..." : isSaved(row.id) ? "Unsave" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={() => applyJob(row.id)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
{busyId() === row.id ? 'Applying...' : 'Apply'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyJob(row.id)}
|
||||
disabled={busyId() === row.id}
|
||||
style={{
|
||||
...BTN_ORANGE,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
opacity: busyId() === row.id ? "0.7" : "1",
|
||||
}}
|
||||
>
|
||||
{busyId() === row.id ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
|
||||
import { ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
|
||||
/**
|
||||
* Professional Leads Page - Wired to real backend APIs
|
||||
* Endpoints:
|
||||
* GET /api/{profession}/marketplace - List available leads
|
||||
* POST /api/{profession}/leads/request - Request a lead
|
||||
* Professions: photographers, makeup-artists, tutors, developers, etc.
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from "solid-js";
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD } from "~/components/DashboardShell";
|
||||
import { ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
|
||||
|
||||
const API = '/api/gateway';
|
||||
const API = "/api/gateway";
|
||||
|
||||
type Props = { roleKey: RoleKey };
|
||||
|
||||
|
|
@ -18,8 +25,8 @@ type MarketplaceItem = {
|
|||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -27,24 +34,24 @@ export default function ProfessionalLeadsPage(props: Props) {
|
|||
const [rows, setRows] = createSignal<MarketplaceItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null);
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
const prefix = () => ROLE_PREFIXES[props.roleKey];
|
||||
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/${prefix()}/marketplace?page=1&limit=50`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to load leads.');
|
||||
setErr(data.error || data.message || "Failed to load leads.");
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(data?.data) ? data.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading leads.');
|
||||
setErr("Network error while loading leads.");
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -55,74 +62,164 @@ export default function ProfessionalLeadsPage(props: Props) {
|
|||
|
||||
const requestLead = async (requirementId: string) => {
|
||||
setBusyId(requirementId);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
setMsg("");
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/${prefix()}/leads/request`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requirement_id: requirementId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to request lead.');
|
||||
setErr(data.error || data.message || "Failed to request lead.");
|
||||
return;
|
||||
}
|
||||
setMsg('Lead request submitted to verification and approval flow.');
|
||||
setMsg("Lead request submitted to verification and approval flow.");
|
||||
} catch {
|
||||
setErr('Network error while requesting lead.');
|
||||
setErr("Network error while requesting lead.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Leads</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
|
||||
Leads
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
||||
Browse open requirements from customers and request contact access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={msg()}>
|
||||
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{msg()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #BBF7D0",
|
||||
background: "#ECFDF5",
|
||||
padding: "12px 14px",
|
||||
color: "#065F46",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{msg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>{err()}</div>
|
||||
<div
|
||||
style={{
|
||||
...CARD,
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
padding: "12px 14px",
|
||||
color: "#B91C1C",
|
||||
"font-size": "13px",
|
||||
"font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{err()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '10px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Open Marketplace Leads</p>
|
||||
<button type="button" onClick={loadRows} style={BTN_GHOST}>Refresh</button>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"align-items": "center",
|
||||
"margin-bottom": "10px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
|
||||
Open Marketplace Leads
|
||||
</p>
|
||||
<button type="button" onClick={loadRows} style={BTN_GHOST}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading leads...</p>
|
||||
<p style={{ margin: "0", color: "#9CA3AF", "font-size": "13px" }}>Loading leads...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No leads available right now.</p>
|
||||
<p style={{ margin: "0", color: "#6B7280", "font-size": "13px" }}>
|
||||
No leads available right now.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "12px",
|
||||
padding: "12px",
|
||||
background: "#FCFCFD",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
gap: "10px",
|
||||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.title || 'Requirement'}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
{row.location || 'Location not set'} {row.profession_key ? `• ${row.profession_key}` : ''}
|
||||
<p
|
||||
style={{
|
||||
margin: "0",
|
||||
"font-size": "14px",
|
||||
"font-weight": "800",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{row.title || "Requirement"}
|
||||
</p>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
||||
{row.location || "Location not set"}{" "}
|
||||
{row.profession_key ? `• ${row.profession_key}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#FFF1EB', color: '#C2410C', 'font-size': '11px', 'font-weight': '700' }}>
|
||||
{row.budget ? `₹${row.budget}` : 'Budget N/A'}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "24px",
|
||||
"align-items": "center",
|
||||
padding: "0 10px",
|
||||
"border-radius": "999px",
|
||||
background: "#FFF1EB",
|
||||
color: "#C2410C",
|
||||
"font-size": "11px",
|
||||
"font-weight": "700",
|
||||
}}
|
||||
>
|
||||
{row.budget ? `₹${row.budget}` : "Budget N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No additional details.'}</p>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
|
||||
<button type="button" onClick={() => requestLead(row.id)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
{busyId() === row.id ? 'Requesting...' : 'Request Contact'}
|
||||
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
|
||||
{row.description || "No additional details."}
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", "justify-content": "flex-end", "margin-top": "10px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestLead(row.id)}
|
||||
disabled={busyId() === row.id}
|
||||
style={{
|
||||
...BTN_ORANGE,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
opacity: busyId() === row.id ? "0.7" : "1",
|
||||
}}
|
||||
>
|
||||
{busyId() === row.id ? "Requesting..." : "Request Contact"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -134,4 +231,3 @@ export default function ProfessionalLeadsPage(props: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue