feat: align external dashboards, profile settings split, and role flows
This commit is contained in:
parent
f990b9a9e0
commit
b8faf752e9
28 changed files with 3146 additions and 96 deletions
|
|
@ -41,7 +41,7 @@ export default function PublicHeader() {
|
|||
|
||||
<div class="desktop-only nav-actions">
|
||||
<A class="nav-auth-btn nav-auth-secondary" href="/login">Login</A>
|
||||
<A class="nav-auth-btn nav-auth-primary" href="/users/choose-role">Sign up</A>
|
||||
<A class="nav-auth-btn nav-auth-primary" href="/signup">Sign up</A>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -71,7 +71,7 @@ export default function PublicHeader() {
|
|||
<A href="/contact" onClick={() => setMobileOpen(false)}>Contact Us</A>
|
||||
<div class="mobile-nav-actions">
|
||||
<A class="mobile-login" href="/login" onClick={() => setMobileOpen(false)}>Login</A>
|
||||
<A class="mobile-signup" href="/users/choose-role" onClick={() => setMobileOpen(false)}>Sign up</A>
|
||||
<A class="mobile-signup" href="/signup" onClick={() => setMobileOpen(false)}>Sign up</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
CUSTOMER: {
|
||||
title: 'Service Seeker Profile',
|
||||
subtitle: 'Manage your personal details, service preferences, documents, and account settings.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Area', 'Place', 'PIN Code', 'Service Category'],
|
||||
documents: ['Identity Proof', 'Address Proof'],
|
||||
|
|
@ -103,7 +103,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
COMPANY: {
|
||||
title: 'Company Profile',
|
||||
subtitle: 'Configure organization details, hiring preferences, compliance documents, and settings.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['Company Name', 'Company Email', 'Company Phone', 'City', 'Area', 'Place', 'PIN Code', 'Website URL'],
|
||||
documents: ['GST Certificate', 'PAN Card', 'Incorporation Certificate'],
|
||||
|
|
@ -113,7 +113,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
JOB_SEEKER: {
|
||||
title: 'Job Seeker Profile',
|
||||
subtitle: 'Maintain your career profile, resume, preferences, and verification docs.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Current Role', 'Total Experience', 'City', 'Area', 'Place'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Education Proof'],
|
||||
|
|
@ -123,7 +123,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
PHOTOGRAPHER: {
|
||||
title: 'Photographer Profile',
|
||||
subtitle: 'Manage your photography details, pricing, portfolio, and documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Portfolio Ownership Proof'],
|
||||
|
|
@ -133,7 +133,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
MAKEUP_ARTIST: {
|
||||
title: 'Makeup Artist Profile',
|
||||
subtitle: 'Manage makeup specialization, services, portfolio, and compliance documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Professional Certifications'],
|
||||
|
|
@ -143,7 +143,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
DEVELOPER: {
|
||||
title: 'Developer Profile',
|
||||
subtitle: 'Showcase technical profile, pricing models, portfolio projects, and documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
@ -153,7 +153,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
VIDEO_EDITOR: {
|
||||
title: 'Video Editor Profile',
|
||||
subtitle: 'Manage editing profile, services, portfolio, and verification documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
@ -163,7 +163,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
UGC_CONTENT_CREATOR: {
|
||||
title: 'UGC Content Creator Profile',
|
||||
subtitle: 'Manage your creator profile, content style, pricing, and portfolio deliverables.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
@ -173,7 +173,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
GRAPHIC_DESIGNER: {
|
||||
title: 'Graphic Designer Profile',
|
||||
subtitle: 'Manage design profile, service pricing, portfolio assets, and documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
@ -183,7 +183,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
SOCIAL_MEDIA_MANAGER: {
|
||||
title: 'Social Media Manager Profile',
|
||||
subtitle: 'Manage social profile details, service plans, case studies, and documents.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
@ -193,7 +193,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
FITNESS_TRAINER: {
|
||||
title: 'Fitness Trainer Profile',
|
||||
subtitle: 'Manage training details, plans, certifications, and profile settings.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Certification Proof'],
|
||||
|
|
@ -203,7 +203,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
TUTOR: {
|
||||
title: 'Tutor Profile',
|
||||
subtitle: 'Manage teaching details, subjects, pricing, documents, and settings.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Educational Proof'],
|
||||
|
|
@ -213,7 +213,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
CATERING_SERVICES: {
|
||||
title: 'Catering Services Profile',
|
||||
subtitle: 'Manage business details, menu packages, gallery, and compliance docs.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['Business Name', 'Contact Person Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Food License'],
|
||||
|
|
@ -223,7 +223,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
|||
PROFESSIONAL: {
|
||||
title: 'Professional Profile',
|
||||
subtitle: 'Manage professional details, pricing, portfolio, and account settings.',
|
||||
tabs: ['basic information', 'documents', 'settings'],
|
||||
tabs: ['basic information', 'documents'],
|
||||
tabFields: {
|
||||
'basic information': ['Full Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
|
||||
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
|
||||
|
|
|
|||
320
src/components/dashboard/CompanyApplicationsPage.tsx
Normal file
320
src/components/dashboard/CompanyApplicationsPage.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
interface JobItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ApplicationItem {
|
||||
id: string;
|
||||
job_id: string;
|
||||
job_seeker_id: string;
|
||||
status: string;
|
||||
cover_letter?: string | null;
|
||||
resume_url?: string | null;
|
||||
applied_at?: string;
|
||||
contact_viewed?: boolean;
|
||||
}
|
||||
|
||||
interface ContactInfo {
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
quota?: {
|
||||
total_remaining: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CompanyApplicationsPage() {
|
||||
const [jobs, setJobs] = createSignal<JobItem[]>([]);
|
||||
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 [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');
|
||||
if (!res.ok) {
|
||||
setJobs([]);
|
||||
return;
|
||||
}
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
setJobs(list);
|
||||
if (list.length > 0) {
|
||||
const nextJobId = selectedJobId() || list[0].id;
|
||||
setSelectedJobId(nextJobId);
|
||||
await loadApplications(nextJobId, statusFilter());
|
||||
} else {
|
||||
setSelectedJobId('');
|
||||
setApplications([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadApplications = async (jobId: string, status?: string) => {
|
||||
if (!jobId) {
|
||||
setApplications([]);
|
||||
return;
|
||||
}
|
||||
setLoadingApps(true);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
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([]);
|
||||
return;
|
||||
}
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
setApplications(Array.isArray(payload?.data) ? payload.data : []);
|
||||
} finally {
|
||||
setLoadingApps(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await loadJobs();
|
||||
});
|
||||
|
||||
const refreshCurrent = async () => {
|
||||
await loadApplications(selectedJobId(), statusFilter());
|
||||
};
|
||||
|
||||
const updateApplicationStatus = async (id: string, status: string) => {
|
||||
setBusyAppId(id);
|
||||
setActionMsg('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/applications/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
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.');
|
||||
}
|
||||
} finally {
|
||||
setBusyAppId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadContact = async (id: string) => {
|
||||
setBusyAppId(id);
|
||||
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.');
|
||||
}
|
||||
} finally {
|
||||
setBusyAppId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const prettyDate = (value?: string) => {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Show when={actionMsg()}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '12px', display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={LABEL}>Select Job</label>
|
||||
<select
|
||||
value={selectedJobId()}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setSelectedJobId(next);
|
||||
loadApplications(next, statusFilter());
|
||||
}}
|
||||
style={INPUT}
|
||||
>
|
||||
<Show when={jobs().length === 0}>
|
||||
<option value="">No jobs available</option>
|
||||
</Show>
|
||||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<option value={job.id}>
|
||||
{job.title} ({job.status.replace(/_/g, ' ')})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Filter by Status</label>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setStatusFilter(next);
|
||||
loadApplications(selectedJobId(), next);
|
||||
}}
|
||||
style={INPUT}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="APPLIED">Applied</option>
|
||||
<option value="SHORTLISTED">Shortlisted</option>
|
||||
<option value="INTERVIEW">Interview</option>
|
||||
<option value="OFFERED">Offered</option>
|
||||
<option value="HIRED">Hired</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="WITHDRAWN">Withdrawn</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!selectedJobId()}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={!loadingApps() && selectedJobId() && applications().length === 0}>
|
||||
<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' }}>
|
||||
<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>
|
||||
<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' }}>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Show when={app.cover_letter}>
|
||||
<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' }}>
|
||||
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' }}>
|
||||
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' }}>
|
||||
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' }}>
|
||||
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' }}>
|
||||
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' }}>
|
||||
Reject
|
||||
</button>
|
||||
<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'}
|
||||
</p>
|
||||
<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>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '11px', color: '#6B7280' }}>
|
||||
Remaining contact views: {contactByApp()[app.id]?.quota?.total_remaining ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
src/components/dashboard/CompanyJobsPage.tsx
Normal file
351
src/components/dashboard/CompanyJobsPage.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
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';
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: JobFormState = {
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
location: '',
|
||||
job_type: 'FULL_TIME',
|
||||
salary_min: '',
|
||||
salary_max: '',
|
||||
experience_years: '',
|
||||
skills: '',
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CompanyJobsPage() {
|
||||
const [jobs, setJobs] = createSignal<JobItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [showForm, setShowForm] = createSignal(false);
|
||||
const [form, setForm] = createSignal<JobFormState>({ ...EMPTY_FORM });
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [actionMsg, setActionMsg] = createSignal('');
|
||||
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/companies/jobs?page=1&limit=50');
|
||||
if (!res.ok) {
|
||||
setJobs([]);
|
||||
return;
|
||||
}
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
setJobs(Array.isArray(payload?.data) ? payload.data : []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadJobs);
|
||||
|
||||
const setField = (key: keyof JobFormState, val: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
setActionMsg('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const closeCreate = () => {
|
||||
setShowForm(false);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form().title.trim() || !form().description.trim() || !form().location.trim()) {
|
||||
setError('Title, description, and location are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setActionMsg('');
|
||||
|
||||
const payload = {
|
||||
title: form().title.trim(),
|
||||
category: form().category.trim() || undefined,
|
||||
description: form().description.trim(),
|
||||
location: form().location.trim(),
|
||||
job_type: form().job_type,
|
||||
salary_min: form().salary_min ? Number(form().salary_min) : undefined,
|
||||
salary_max: form().salary_max ? Number(form().salary_max) : undefined,
|
||||
experience_years: form().experience_years ? Number(form().experience_years) : undefined,
|
||||
skills: form().skills
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/companies/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? data.message ?? 'Failed to create job.');
|
||||
return;
|
||||
}
|
||||
setActionMsg('Job created as draft.');
|
||||
closeCreate();
|
||||
await loadJobs();
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitJob = async (jobId: string) => {
|
||||
setBusyJobId(jobId);
|
||||
setActionMsg('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
setActionMsg('Job submitted for verification.');
|
||||
await loadJobs();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionMsg(data.error ?? data.message ?? 'Unable to submit this job.');
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const closeJob = async (jobId: string) => {
|
||||
setBusyJobId(jobId);
|
||||
setActionMsg('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
setActionMsg('Job closed.');
|
||||
await loadJobs();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionMsg(data.error ?? data.message ?? 'Unable to close this job.');
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '#6B7280';
|
||||
case 'PENDING_APPROVAL': return '#F59E0B';
|
||||
case 'LIVE': return '#10B981';
|
||||
case 'REJECTED': return '#EF4444';
|
||||
case 'CLOSED': return '#374151';
|
||||
default: return '#6B7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '920px' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '16px', gap: '12px', 'flex-wrap': 'wrap' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '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>
|
||||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
||||
+ Create Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={actionMsg()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '12px 14px', 'font-size': '13px', color: '#374151' }}>
|
||||
{actionMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showForm()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
|
||||
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '800', color: '#111827' }}>
|
||||
New Job
|
||||
</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Category</label>
|
||||
<input value={form().category} onInput={(e) => setField('category', e.currentTarget.value)} style={INPUT} placeholder="Engineering" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Job Type</label>
|
||||
<select value={form().job_type} onChange={(e) => setField('job_type', e.currentTarget.value)} style={INPUT}>
|
||||
<option value="FULL_TIME">Full Time</option>
|
||||
<option value="PART_TIME">Part Time</option>
|
||||
<option value="CONTRACT">Contract</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<label style={LABEL}>Location</label>
|
||||
<input value={form().location} onInput={(e) => setField('location', e.currentTarget.value)} style={INPUT} placeholder="Bengaluru (Hybrid)" />
|
||||
</div>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<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' }}
|
||||
placeholder="Role overview, responsibilities, and requirements"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Min Salary</label>
|
||||
<input type="number" value={form().salary_min} onInput={(e) => setField('salary_min', e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Max Salary</label>
|
||||
<input type="number" value={form().salary_max} onInput={(e) => setField('salary_max', e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Experience (years)</label>
|
||||
<input type="number" value={form().experience_years} onInput={(e) => setField('experience_years', e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Skills (comma separated)</label>
|
||||
<input value={form().skills} onInput={(e) => setField('skills', e.currentTarget.value)} style={INPUT} placeholder="Rust, SQL, Docker" />
|
||||
</div>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
|
||||
</Show>
|
||||
<div style={{ display: 'flex', gap: '10px', 'margin-top': '14px' }}>
|
||||
<button type="button" onClick={handleCreate} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.7' : '1' }}>
|
||||
{saving() ? 'Creating…' : 'Create Draft'}
|
||||
</button>
|
||||
<button type="button" onClick={closeCreate} style={BTN_GHOST}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && jobs().length > 0}>
|
||||
<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>
|
||||
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '800', color: '#111827' }}>{job.title}</p>
|
||||
<p style={{ margin: '3px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
{[job.location, job.job_type, job.category || 'General'].join(' • ')}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
padding: '0 10px',
|
||||
height: '24px',
|
||||
'border-radius': '999px',
|
||||
background: `${statusColor(job.status)}20`,
|
||||
color: statusColor(job.status),
|
||||
'font-size': '11px',
|
||||
'font-weight': '700',
|
||||
}}>
|
||||
{job.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151', 'line-height': '1.5' }}>
|
||||
{job.description}
|
||||
</p>
|
||||
<Show when={(job.skills || []).length > 0}>
|
||||
<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' }}>
|
||||
{skill}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div style={{ display: 'flex', gap: '8px', 'margin-top': '12px', 'flex-wrap': 'wrap' }}>
|
||||
<Show when={job.status === 'DRAFT'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitJob(job.id)}
|
||||
disabled={busyJobId() === job.id}
|
||||
style={{ ...BTN_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'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeJob(job.id)}
|
||||
disabled={busyJobId() === job.id}
|
||||
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 14px', opacity: busyJobId() === job.id ? '0.65' : '1' }}
|
||||
>
|
||||
Close Job
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/components/dashboard/CompanyShortlistedCandidatesPage.tsx
Normal file
109
src/components/dashboard/CompanyShortlistedCandidatesPage.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type JobItem = { id: string; title: string };
|
||||
type ApplicationItem = {
|
||||
id: string;
|
||||
job_id: string;
|
||||
job_seeker_id: string;
|
||||
status: string;
|
||||
applied_at?: string;
|
||||
cover_letter?: string | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CompanyShortlistedCandidatesPage() {
|
||||
const [rows, setRows] = createSignal<Array<ApplicationItem & { job_title?: string }>>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const loadAllShortlisted = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const jobsRes = await apiFetch('/api/companies/jobs?page=1&limit=100');
|
||||
const jobsPayload = await jobsRes.json().catch(() => ({}));
|
||||
const jobs = (Array.isArray(jobsPayload?.data) ? jobsPayload.data : []) as JobItem[];
|
||||
const titleById = new Map(jobs.map((j) => [j.id, j.title]));
|
||||
|
||||
const allRows: Array<ApplicationItem & { job_title?: string }> = [];
|
||||
for (const job of jobs) {
|
||||
const res = await apiFetch(`/api/companies/jobs/${job.id}/applications?page=1&limit=100&status=SHORTLISTED`);
|
||||
if (!res.ok) continue;
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
for (const app of list) {
|
||||
allRows.push({ ...app, job_title: titleById.get(app.job_id) || job.title });
|
||||
}
|
||||
}
|
||||
setRows(allRows);
|
||||
} catch {
|
||||
setErr('Failed to load shortlisted candidates.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadAllShortlisted);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Shortlisted Candidates</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Candidates shortlisted across all approved job posts.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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' }}>Candidate List</p>
|
||||
<button type="button" onClick={loadAllShortlisted} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading shortlisted candidates...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No shortlisted candidates yet.</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<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>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
|
||||
Candidate #{row.job_seeker_id?.slice(0, 8) || row.id.slice(0, 8)}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
{row.job_title || '—'} {row.applied_at ? `• ${new Date(row.applied_at).toLocaleString('en-IN')}` : ''}
|
||||
</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' }}>
|
||||
{String(row.status || 'SHORTLISTED').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter || 'No cover letter available.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
118
src/components/dashboard/CreditsPage.tsx
Normal file
118
src/components/dashboard/CreditsPage.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Props = { roleKey: RoleKey };
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CreditsPage(props: Props) {
|
||||
const [wallet, setWallet] = createSignal<any>(null);
|
||||
const [ledger, setLedger] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
|
||||
const prefix = () => ROLE_PREFIXES[props.roleKey];
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
if (!isProfessional()) {
|
||||
setWallet(null);
|
||||
setLedger([]);
|
||||
return;
|
||||
}
|
||||
const [walletRes, ledgerRes] = await Promise.all([
|
||||
apiFetch(`/api/${prefix()}/wallet/me`),
|
||||
apiFetch(`/api/${prefix()}/wallet/me/ledger?page=1&limit=20`),
|
||||
]);
|
||||
const walletJson = await walletRes.json().catch(() => ({}));
|
||||
const ledgerJson = await ledgerRes.json().catch(() => ({}));
|
||||
if (walletRes.ok) setWallet(walletJson);
|
||||
else setErr(walletJson.error || walletJson.message || 'Failed to load wallet.');
|
||||
if (ledgerRes.ok) setLedger(Array.isArray(ledgerJson?.data) ? ledgerJson.data : []);
|
||||
else if (!err()) setErr(ledgerJson.error || ledgerJson.message || 'Failed to load ledger.');
|
||||
} catch {
|
||||
setErr('Network error while loading credits.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Credits</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
{isProfessional() ? 'Track Tracecoin balance and usage history.' : 'Credits and billing summary for your account.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading credits...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && !isProfessional()}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Billing Overview</p>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Your role does not use professional wallet endpoints. Billing and payments are managed through role-specific transactions.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && isProfessional()}>
|
||||
<div style={{ ...CARD, display: 'grid', 'grid-template-columns': '1fr auto', gap: '10px', 'align-items': 'center' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '12px', 'letter-spacing': '0.06em', 'text-transform': 'uppercase', color: '#6B7280' }}>Current Balance</p>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '28px', 'font-weight': '800', color: '#111827' }}>
|
||||
{wallet()?.balance ?? 0} <span style={{ 'font-size': '14px', color: '#6B7280', 'font-weight': '600' }}>Tracecoins</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Recent Ledger</p>
|
||||
<Show when={ledger().length === 0}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>No ledger entries yet.</p>
|
||||
</Show>
|
||||
<Show when={ledger().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
<For each={ledger()}>
|
||||
{(item: any) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{item.reason || item.type || 'Transaction'}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{item.created_at ? new Date(item.created_at).toLocaleString('en-IN') : '—'}</p>
|
||||
</div>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '800', color: Number(item.amount || 0) >= 0 ? '#15803D' : '#B91C1C' }}>
|
||||
{Number(item.amount || 0) >= 0 ? '+' : ''}{item.amount || 0}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
217
src/components/dashboard/CustomerRequirementsPage.tsx
Normal file
217
src/components/dashboard/CustomerRequirementsPage.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type RequirementItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string;
|
||||
budget_min?: number | null;
|
||||
budget_max?: number | null;
|
||||
area?: string | null;
|
||||
city?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function CustomerRequirementsPage() {
|
||||
const [requirements, setRequirements] = createSignal<RequirementItem[]>([]);
|
||||
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 [form, setForm] = createSignal({
|
||||
title: '',
|
||||
description: '',
|
||||
budget_min: '',
|
||||
budget_max: '',
|
||||
area: '',
|
||||
city: '',
|
||||
});
|
||||
|
||||
const loadRequirements = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
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.');
|
||||
setRequirements([]);
|
||||
return;
|
||||
}
|
||||
setRequirements(Array.isArray(payload?.data) ? payload.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading requirements.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRequirements);
|
||||
|
||||
const setField = (key: keyof ReturnType<typeof form>, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const createRequirement = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const payload = {
|
||||
title: form().title.trim(),
|
||||
description: form().description.trim() || undefined,
|
||||
budget_min: form().budget_min ? Number(form().budget_min) : undefined,
|
||||
budget_max: form().budget_max ? Number(form().budget_max) : undefined,
|
||||
area: form().area.trim() || undefined,
|
||||
city: form().city.trim() || undefined,
|
||||
};
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
setMsg('Requirement created.');
|
||||
setForm({ title: '', description: '', budget_min: '', budget_max: '', area: '', city: '' });
|
||||
await loadRequirements();
|
||||
} catch {
|
||||
setErr('Network error while creating requirement.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRequirement = async (id: string) => {
|
||||
setBusyId(id);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
setMsg('Requirement submitted to verification.');
|
||||
await loadRequirements();
|
||||
} catch {
|
||||
setErr('Network error while submitting requirement.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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' }}>
|
||||
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' }}>
|
||||
<label style={LABEL}>Title</label>
|
||||
<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' }}>
|
||||
<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' }}
|
||||
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" />
|
||||
</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" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Area</label>
|
||||
<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" />
|
||||
</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'}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
<Show when={loading()}>
|
||||
<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>
|
||||
</Show>
|
||||
<Show when={!loading() && requirements().length > 0}>
|
||||
<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>
|
||||
<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>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
223
src/components/dashboard/CustomerResponsesPage.tsx
Normal file
223
src/components/dashboard/CustomerResponsesPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type RequirementItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type LeadRequestItem = {
|
||||
id: string;
|
||||
professional_id?: string;
|
||||
status?: string;
|
||||
message?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: 'received' | 'shortlisted';
|
||||
};
|
||||
|
||||
export default function CustomerResponsesPage(props: Props) {
|
||||
const [requirements, setRequirements] = createSignal<RequirementItem[]>([]);
|
||||
const [selectedRequirementId, setSelectedRequirementId] = createSignal('');
|
||||
const [rows, setRows] = createSignal<LeadRequestItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadingRows, setLoadingRows] = createSignal(false);
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null);
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const loadRequirements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
setRequirements(list);
|
||||
if (list.length > 0) {
|
||||
const first = selectedRequirementId() || list[0].id;
|
||||
setSelectedRequirementId(first);
|
||||
await loadResponses(first);
|
||||
} else {
|
||||
setSelectedRequirementId('');
|
||||
setRows([]);
|
||||
}
|
||||
} catch {
|
||||
setErr('Failed to load requirements.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadResponses = async (requirementId: string) => {
|
||||
if (!requirementId) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setLoadingRows(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/customers/requirements/${requirementId}/requests`);
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setRows([]);
|
||||
setErr(payload.error || payload.message || 'Failed to load responses.');
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
setRows(list);
|
||||
} catch {
|
||||
setErr('Network error while loading responses.');
|
||||
} finally {
|
||||
setLoadingRows(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRequirements);
|
||||
|
||||
const runDecision = async (leadId: string, approve: boolean) => {
|
||||
if (!selectedRequirementId()) return;
|
||||
setBusyId(leadId);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const path = approve
|
||||
? `/api/customers/requirements/${selectedRequirementId()}/requests/${leadId}/approve`
|
||||
: `/api/customers/requirements/${selectedRequirementId()}/requests/${leadId}/reject`;
|
||||
const res = await apiFetch(path, { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to update response.');
|
||||
return;
|
||||
}
|
||||
setMsg(approve ? 'Response moved to shortlisted.' : 'Response rejected.');
|
||||
await loadResponses(selectedRequirementId());
|
||||
} catch {
|
||||
setErr('Network error while updating response.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRows = () => {
|
||||
if (props.mode === 'shortlisted') {
|
||||
return rows().filter((row) => {
|
||||
const status = String(row.status || '').toUpperCase();
|
||||
return status === 'APPROVED' || status === 'SHORTLISTED';
|
||||
});
|
||||
}
|
||||
return rows();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>
|
||||
{props.mode === 'shortlisted' ? 'Shortlisted Responses' : 'Received Responses'}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
{props.mode === 'shortlisted'
|
||||
? 'Review approved responses from your requirements.'
|
||||
: 'Review incoming responses and shortlist the right professionals.'}
|
||||
</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>
|
||||
</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>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-end', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div style={{ 'min-width': '300px', flex: '1' }}>
|
||||
<label style={LABEL}>Select Requirement</label>
|
||||
<select
|
||||
value={selectedRequirementId()}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setSelectedRequirementId(next);
|
||||
loadResponses(next);
|
||||
}}
|
||||
style={INPUT}
|
||||
>
|
||||
<Show when={requirements().length === 0}>
|
||||
<option value="">No requirements</option>
|
||||
</Show>
|
||||
<For each={requirements()}>
|
||||
{(item) => (
|
||||
<option value={item.id}>
|
||||
{item.title} ({String(item.status || 'DRAFT').replace(/_/g, ' ')})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={() => loadResponses(selectedRequirementId())} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading requirements...</div>
|
||||
</Show>
|
||||
<Show when={!loading() && !selectedRequirementId()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>Post a requirement first to view responses.</div>
|
||||
</Show>
|
||||
<Show when={loadingRows()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading responses...</div>
|
||||
</Show>
|
||||
<Show when={!loadingRows() && selectedRequirementId() && filteredRows().length === 0}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No responses found.</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loadingRows() && filteredRows().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<div style={{ ...CARD, padding: '14px' }}>
|
||||
<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' }}>
|
||||
Response #{row.id.slice(0, 8)}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
Professional: {row.professional_id || '—'} {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 || 'NEW').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.message || 'No message added by professional.'}</p>
|
||||
<Show when={props.mode === 'received'}>
|
||||
<div style={{ display: 'flex', gap: '8px', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
|
||||
<button type="button" onClick={() => runDecision(row.id, false)} disabled={busyId() === row.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
Reject
|
||||
</button>
|
||||
<button type="button" onClick={() => runDecision(row.id, true)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
Shortlist
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/components/dashboard/ExploreServicesPage.tsx
Normal file
46
src/components/dashboard/ExploreServicesPage.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { For } from 'solid-js';
|
||||
import { CARD } from '~/components/DashboardShell';
|
||||
|
||||
const ROLES = [
|
||||
'Photographer',
|
||||
'Makeup Artist',
|
||||
'Tutor',
|
||||
'Developer',
|
||||
'Video Editor',
|
||||
'UGC Content Creator',
|
||||
'Graphic Designer',
|
||||
'Social Media Manager',
|
||||
'Fitness Trainer',
|
||||
'Catering Services',
|
||||
'Company',
|
||||
'Job Seeker',
|
||||
'Customer',
|
||||
];
|
||||
|
||||
export default function ExploreServicesPage() {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Explore Nxtgauge</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Discover available services and role journeys across the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Available Services</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={ROLES}>
|
||||
{(name) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{name}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>Explore this role in dashboard and management flows.</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/dashboard/HelpCenterDashboardPage.tsx
Normal file
143
src/components/dashboard/HelpCenterDashboardPage.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD, INPUT } from '~/components/DashboardShell';
|
||||
import { type RoleKey } from './RoleDashboardShared';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Props = { roleKey: RoleKey };
|
||||
|
||||
type Category = { id: string; name: string; slug: string; description?: string };
|
||||
type Article = { id: string; title: string; slug: string; summary?: string; category?: string; updatedAt?: string };
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeArticle(raw: any): Article {
|
||||
return {
|
||||
id: String(raw?.id || ''),
|
||||
title: String(raw?.title || ''),
|
||||
slug: String(raw?.slug || ''),
|
||||
summary: raw?.summary ? String(raw.summary) : '',
|
||||
category: String(raw?.category || raw?.category_name || ''),
|
||||
updatedAt: String(raw?.updatedAt || raw?.updated_at || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export default function HelpCenterDashboardPage(props: Props) {
|
||||
const [categories, setCategories] = createSignal<Category[]>([]);
|
||||
const [articles, setArticles] = createSignal<Article[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const [catRes, artRes] = await Promise.all([
|
||||
apiFetch('/api/kb/categories'),
|
||||
apiFetch(`/api/kb/articles?role=${encodeURIComponent(props.roleKey)}&page=1&limit=200`),
|
||||
]);
|
||||
const catJson = await catRes.json().catch(() => ({}));
|
||||
const artJson = await artRes.json().catch(() => ({}));
|
||||
|
||||
if (catRes.ok) {
|
||||
const catList = Array.isArray(catJson?.categories)
|
||||
? catJson.categories
|
||||
: Array.isArray(catJson)
|
||||
? catJson
|
||||
: [];
|
||||
setCategories(catList);
|
||||
}
|
||||
|
||||
if (artRes.ok) {
|
||||
const rawArticles = Array.isArray(artJson?.articles)
|
||||
? artJson.articles
|
||||
: Array.isArray(artJson)
|
||||
? artJson
|
||||
: [];
|
||||
setArticles(rawArticles.map(normalizeArticle).filter((a: Article) => Boolean(a.id && a.slug && a.title)));
|
||||
}
|
||||
|
||||
if (!catRes.ok && !artRes.ok) setErr('Failed to load help center resources.');
|
||||
} catch {
|
||||
setErr('Network error while loading help center.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const q = search().trim().toLowerCase();
|
||||
if (!q) return articles();
|
||||
return articles().filter((a) =>
|
||||
String(a.title || '').toLowerCase().includes(q)
|
||||
|| String(a.summary || '').toLowerCase().includes(q)
|
||||
|| String(a.category || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Help Center</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Find guides and articles for your role.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<div style={{ ...CARD, display: 'grid', gap: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', 'align-items': 'center' }}>
|
||||
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} style={INPUT} placeholder="Search help articles" />
|
||||
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading help center...</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!loading() && categories().length > 0}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Categories</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={categories().slice(0, 6)}>
|
||||
{(cat) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{cat.name}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{cat.description || 'Knowledge base category'}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Articles</p>
|
||||
<Show when={!loading() && filtered().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No articles found.</p>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
<For each={filtered()}>
|
||||
{(a) => (
|
||||
<a href={`/help-center/article/${a.slug}`} style={{ display: 'block', border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', color: 'inherit', 'text-decoration': 'none' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{a.title}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{a.summary || 'Open article'}</p>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/components/dashboard/JobSeekerApplicationsPage.tsx
Normal file
133
src/components/dashboard/JobSeekerApplicationsPage.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type ApplicationItem = {
|
||||
id: string;
|
||||
job_id: string;
|
||||
status: string;
|
||||
applied_at?: string;
|
||||
resume_url?: string | null;
|
||||
cover_letter?: string | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function JobSeekerApplicationsPage() {
|
||||
const [rows, setRows] = createSignal<ApplicationItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [statusFilter, setStatusFilter] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const loadRows = async (status = statusFilter()) => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', '1');
|
||||
query.set('limit', '100');
|
||||
if (status) query.set('status', status);
|
||||
const res = await apiFetch(`/api/jobseeker/applications?${query.toString()}`);
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(payload.error || payload.message || 'Failed to load applications.');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(payload?.data) ? payload.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading applications.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => loadRows());
|
||||
|
||||
return (
|
||||
<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 Applications</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Track all jobs you applied for.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-end', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '600', color: '#374151' }}>Status Filter</p>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setStatusFilter(next);
|
||||
loadRows(next);
|
||||
}}
|
||||
style={{ height: '38px', 'border-radius': '8px', border: '1px solid #E5E7EB', padding: '0 10px', 'font-size': '13px' }}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="APPLIED">Applied</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="SHORTLISTED">Shortlisted</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="WITHDRAWN">Withdrawn</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={() => loadRows()} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading applications...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No applications found.</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<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>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>Application #{row.id.slice(0, 8)}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
Job: {row.job_id?.slice(0, 8) || '—'} {row.applied_at ? `• ${new Date(row.applied_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 || 'APPLIED').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={row.cover_letter}>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter}</p>
|
||||
</Show>
|
||||
<Show when={row.resume_url}>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px' }}>
|
||||
<a href={row.resume_url!} target="_blank" rel="noreferrer" style={{ color: '#1D4ED8', 'font-weight': '600' }}>
|
||||
View Resume
|
||||
</a>
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
148
src/components/dashboard/JobSeekerJobsPage.tsx
Normal file
148
src/components/dashboard/JobSeekerJobsPage.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type JobItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
company_name?: string;
|
||||
location?: string;
|
||||
salary_min?: number | null;
|
||||
salary_max?: number | null;
|
||||
employment_type?: string;
|
||||
status?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function JobSeekerJobsPage() {
|
||||
const [rows, setRows] = createSignal<JobItem[]>([]);
|
||||
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 loadRows = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
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.');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(data?.data) ? data.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading jobs.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRows);
|
||||
|
||||
const applyJob = async (jobId: string) => {
|
||||
setBusyId(jobId);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/jobseeker/jobs/${jobId}/apply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to apply for job.');
|
||||
return;
|
||||
}
|
||||
setMsg('Application submitted successfully.');
|
||||
} catch {
|
||||
setErr('Network error while applying.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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' }}>
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<Show when={loading()}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && filtered().length > 0}>
|
||||
<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>
|
||||
<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>
|
||||
</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: {row.salary_min || 0} - {row.salary_max || 0}
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
|
||||
<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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/dashboard/JobSeekerSavedJobsPage.tsx
Normal file
92
src/components/dashboard/JobSeekerSavedJobsPage.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const STORAGE_KEY = 'nxtgauge_saved_jobs_v1';
|
||||
|
||||
type SavedJob = {
|
||||
id: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
saved_at: string;
|
||||
};
|
||||
|
||||
function loadSaved(): SavedJob[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist(list: SavedJob[]) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function JobSeekerSavedJobsPage() {
|
||||
const [rows, setRows] = createSignal<SavedJob[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
setRows(loadSaved());
|
||||
});
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
const next = rows().filter((row) => row.id !== id);
|
||||
setRows(next);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Saved Jobs</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Jobs bookmarked for later. Saved locally on this device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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' }}>Bookmarked Jobs</p>
|
||||
<button type="button" onClick={() => setRows(loadSaved())} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
<Show when={rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No saved jobs yet.</p>
|
||||
</Show>
|
||||
<Show when={rows().length > 0}>
|
||||
<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>
|
||||
<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.company || '—'} {row.location ? `• ${row.location}` : ''} {row.salary ? `• ${row.salary}` : ''}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>
|
||||
Saved on {new Date(row.saved_at).toLocaleString('en-IN')}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeRow(row.id)} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
69
src/components/dashboard/LogoutPage.tsx
Normal file
69
src/components/dashboard/LogoutPage.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function LogoutPage() {
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const doLogout = async () => {
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await apiFetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// ignore and clear local state anyway
|
||||
} finally {
|
||||
try {
|
||||
window.localStorage.removeItem('nxtgauge_signup_profile_v1');
|
||||
window.localStorage.removeItem('nxtgauge_auth_user');
|
||||
window.localStorage.removeItem('nxtgauge_user');
|
||||
window.sessionStorage.removeItem('nxtgauge_access_token');
|
||||
window.sessionStorage.removeItem('nxtgauge_refresh_token');
|
||||
window.sessionStorage.removeItem('nxtgauge_auth_user');
|
||||
window.sessionStorage.removeItem('nxtgauge_user');
|
||||
} catch {
|
||||
setErr('Could not clear local session completely.');
|
||||
}
|
||||
window.location.href = '/login';
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '760px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Logout</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>End your current session securely.</p>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '14px', color: '#374151' }}>
|
||||
Are you sure you want to logout from this account?
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button type="button" onClick={() => window.history.back()} style={BTN_GHOST}>Cancel</button>
|
||||
<button type="button" onClick={doLogout} disabled={busy()} style={{ ...BTN_ORANGE, opacity: busy() ? '0.7' : '1' }}>
|
||||
{busy() ? 'Logging out...' : 'Confirm Logout'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err() && (
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>
|
||||
{err()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/components/dashboard/MyDashboardPage.tsx
Normal file
162
src/components/dashboard/MyDashboardPage.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Props = {
|
||||
roleKey: RoleKey;
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type Metric = {
|
||||
title: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function MyDashboardPage(props: Props) {
|
||||
const [metrics, setMetrics] = createSignal<Metric[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const roleLabel = createMemo(() => String(props.roleKey || '').replace(/_/g, ' '));
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
const next: Metric[] = [];
|
||||
|
||||
try {
|
||||
if (props.roleKey === 'COMPANY') {
|
||||
const [jobsRes, appsRes] = await Promise.all([
|
||||
apiFetch('/api/companies/jobs?page=1&limit=100'),
|
||||
apiFetch('/api/companies/jobs?page=1&limit=1'),
|
||||
]);
|
||||
const jobsJson = await jobsRes.json().catch(() => ({}));
|
||||
const appsJson = await appsRes.json().catch(() => ({}));
|
||||
const jobs = Array.isArray(jobsJson?.data) ? jobsJson.data : [];
|
||||
next.push(
|
||||
{ title: 'Total Jobs', value: String(jobs.length), hint: 'All job posts created' },
|
||||
{ title: 'Active Jobs', value: String(jobs.filter((j: any) => String(j.status || '').toUpperCase() === 'OPEN').length), hint: 'Open to applications' },
|
||||
{ title: 'Jobs In Verification', value: String(jobs.filter((j: any) => String(j.status || '').toUpperCase().includes('PENDING')).length), hint: 'Pending verification/approval' },
|
||||
{ title: 'Latest Sync', value: appsRes.ok ? 'Live' : 'Partial', hint: 'Dashboard data status' },
|
||||
);
|
||||
if (!jobsRes.ok && !appsRes.ok) setErr('Some company metrics could not be loaded.');
|
||||
} else if (props.roleKey === 'CUSTOMER') {
|
||||
const reqRes = await apiFetch('/api/customers/requirements?page=1&limit=100');
|
||||
const reqJson = await reqRes.json().catch(() => ({}));
|
||||
const reqs = Array.isArray(reqJson?.data) ? reqJson.data : [];
|
||||
next.push(
|
||||
{ title: 'My Requirements', value: String(reqs.length), hint: 'Total posted requirements' },
|
||||
{ title: 'Open Requirements', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase() === 'OPEN').length), hint: 'Visible to professionals' },
|
||||
{ title: 'In Verification', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase().includes('PENDING')).length), hint: 'Verification/approval stage' },
|
||||
{ title: 'Drafts', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase() === 'DRAFT').length), hint: 'Not yet submitted' },
|
||||
);
|
||||
if (!reqRes.ok) setErr('Some customer metrics could not be loaded.');
|
||||
} else if (props.roleKey === 'JOB_SEEKER') {
|
||||
const [jobsRes, appsRes] = await Promise.all([
|
||||
apiFetch('/api/jobseeker/jobs?page=1&limit=100'),
|
||||
apiFetch('/api/jobseeker/applications?page=1&limit=100'),
|
||||
]);
|
||||
const jobsJson = await jobsRes.json().catch(() => ({}));
|
||||
const appsJson = await appsRes.json().catch(() => ({}));
|
||||
const jobs = Array.isArray(jobsJson?.data) ? jobsJson.data : [];
|
||||
const apps = Array.isArray(appsJson?.data) ? appsJson.data : [];
|
||||
next.push(
|
||||
{ title: 'Available Jobs', value: String(jobs.length), hint: 'Open approved jobs' },
|
||||
{ title: 'My Applications', value: String(apps.length), hint: 'Total applications submitted' },
|
||||
{ title: 'Shortlisted', value: String(apps.filter((a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED').length), hint: 'Moved ahead in process' },
|
||||
{ title: 'Under Review', value: String(apps.filter((a: any) => String(a.status || '').toUpperCase().includes('REVIEW')).length), hint: 'Awaiting decision' },
|
||||
);
|
||||
if (!jobsRes.ok && !appsRes.ok) setErr('Some job seeker metrics could not be loaded.');
|
||||
} else if (PROFESSIONAL_ROLE_SET.has(props.roleKey)) {
|
||||
const prefix = ROLE_PREFIXES[props.roleKey];
|
||||
const [marketRes, reqRes, walletRes] = await Promise.all([
|
||||
apiFetch(`/api/${prefix}/marketplace?page=1&limit=100`),
|
||||
apiFetch(`/api/${prefix}/leads/requests/me?page=1&limit=100`),
|
||||
apiFetch(`/api/${prefix}/wallet/me`),
|
||||
]);
|
||||
const marketJson = await marketRes.json().catch(() => ({}));
|
||||
const reqJson = await reqRes.json().catch(() => ({}));
|
||||
const walletJson = await walletRes.json().catch(() => ({}));
|
||||
const market = Array.isArray(marketJson?.data) ? marketJson.data : [];
|
||||
const requests = Array.isArray(reqJson?.data) ? reqJson.data : [];
|
||||
next.push(
|
||||
{ title: 'Open Leads', value: String(market.length), hint: 'Available opportunities' },
|
||||
{ title: 'My Requests', value: String(requests.length), hint: 'Lead requests sent' },
|
||||
{ title: 'Accepted Requests', value: String(requests.filter((r: any) => ['APPROVED', 'CONTACT_UNLOCKED'].includes(String(r.status || '').toUpperCase())).length), hint: 'Approved responses' },
|
||||
{ title: 'Tracecoins', value: String(walletJson?.balance ?? 0), hint: 'Current wallet balance' },
|
||||
);
|
||||
if (!marketRes.ok && !reqRes.ok && !walletRes.ok) setErr('Some professional metrics could not be loaded.');
|
||||
} else {
|
||||
next.push(
|
||||
{ title: 'Welcome', value: 'Ready', hint: 'Dashboard initialized' },
|
||||
{ title: 'Role', value: roleLabel(), hint: 'Current active role' },
|
||||
);
|
||||
}
|
||||
|
||||
setMetrics(next);
|
||||
} catch {
|
||||
setErr('Network error while loading dashboard metrics.');
|
||||
setMetrics([
|
||||
{ title: 'Status', value: 'Unavailable', hint: 'Please retry' },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
return (
|
||||
<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 Dashboard</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Welcome {props.userName || 'User'}. Role: {roleLabel()}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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' }}>Quick Summary</p>
|
||||
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading dashboard...</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(4,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={metrics()}>
|
||||
{(m) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
|
||||
<p style={{ margin: '0', 'font-size': '11px', 'letter-spacing': '0.05em', 'text-transform': 'uppercase', color: '#6B7280' }}>{m.title}</p>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '28px', 'line-height': '1', 'font-weight': '800', color: '#111827' }}>{m.value}</p>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '12px', color: '#6B7280' }}>{m.hint}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
137
src/components/dashboard/ProfessionalLeadsPage.tsx
Normal file
137
src/components/dashboard/ProfessionalLeadsPage.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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';
|
||||
|
||||
type Props = { roleKey: RoleKey };
|
||||
|
||||
type MarketplaceItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
location?: string;
|
||||
budget?: number | null;
|
||||
profession_key?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
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 prefix = () => ROLE_PREFIXES[props.roleKey];
|
||||
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
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.');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(data?.data) ? data.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading leads.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRows);
|
||||
|
||||
const requestLead = async (requirementId: string) => {
|
||||
setBusyId(requirementId);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/${prefix()}/leads/request`, {
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
setMsg('Lead request submitted to verification and approval flow.');
|
||||
} catch {
|
||||
setErr('Network error while requesting lead.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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' }}>
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<Show when={loading()}>
|
||||
<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>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<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>
|
||||
<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>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/components/dashboard/ProfessionalResponsesPage.tsx
Normal file
107
src/components/dashboard/ProfessionalResponsesPage.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
import { ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Props = { roleKey: RoleKey };
|
||||
|
||||
type LeadRequestItem = {
|
||||
id: string;
|
||||
status?: string;
|
||||
requirement_id?: string;
|
||||
requested_at?: string;
|
||||
expires_at?: string;
|
||||
decision_at?: string;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProfessionalResponsesPage(props: Props) {
|
||||
const [rows, setRows] = createSignal<LeadRequestItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [err, setErr] = createSignal('');
|
||||
const prefix = () => ROLE_PREFIXES[props.roleKey];
|
||||
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch(`/api/${prefix()}/leads/requests/me?page=1&limit=100`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to load my responses.');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(Array.isArray(data?.data) ? data.data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading my responses.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRows);
|
||||
|
||||
return (
|
||||
<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 Responses</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Track your lead requests and current response status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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' }}>Requested 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 my responses...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No lead requests yet.</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<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>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>Lead Request #{row.id.slice(0, 8)}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
Requirement: {row.requirement_id?.slice(0, 8) || '—'} {row.requested_at ? `• ${new Date(row.requested_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 || 'REQUESTED').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
Expires: {row.expires_at ? new Date(row.expires_at).toLocaleString('en-IN') : '—'} {row.decision_at ? `• Decision: ${new Date(row.decision_at).toLocaleString('en-IN')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* ProfilePage — real My Profile form, wired to backend.
|
||||
* Supports all 13 roles. Tabs: Basic Info · Documents · Settings.
|
||||
* Supports all 13 roles. Tabs: Basic Info · Documents.
|
||||
* User fills and saves freely; "Submit for Verification" locks and queues for admin.
|
||||
*/
|
||||
import {
|
||||
|
|
@ -134,7 +134,7 @@ interface Props {
|
|||
roleKey: string;
|
||||
}
|
||||
|
||||
type Tab = 'basic' | 'documents' | 'settings';
|
||||
type Tab = 'basic' | 'documents';
|
||||
|
||||
export default function ProfilePage(props: Props) {
|
||||
const [tab, setTab] = createSignal<Tab>('basic');
|
||||
|
|
@ -303,7 +303,6 @@ export default function ProfilePage(props: Props) {
|
|||
<For each={[
|
||||
{ key: 'basic', label: 'Basic Information' },
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
] as Array<{ key: Tab; label: string }>}>
|
||||
{(t) => (
|
||||
<button
|
||||
|
|
@ -464,63 +463,29 @@ export default function ProfilePage(props: Props) {
|
|||
</div>
|
||||
</Match>
|
||||
|
||||
{/* Settings */}
|
||||
<Match when={tab() === 'settings'}>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Password & Login</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Change your account password.</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={LABEL}>Current Password</label>
|
||||
<input type="password" style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>New Password</label>
|
||||
<input type="password" style={INPUT} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ 'border-top': '1px solid #F3F4F6', 'padding-top': '16px' }}>
|
||||
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Notification Preferences</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Choose how you receive notifications.</p>
|
||||
<For each={['Email Notifications', 'In-App Notifications', 'SMS Alerts']}>
|
||||
{(label) => (
|
||||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', padding: '10px 0', 'border-bottom': '1px solid #F3F4F6' }}>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>{label}</span>
|
||||
<input type="checkbox" checked style={{ width: '16px', height: '16px', cursor: 'pointer' }} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
{/* ── Save button (not on settings) ─────────────────────────────── */}
|
||||
<Show when={tab() !== 'settings'}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving() || isLocked()}
|
||||
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
|
||||
>
|
||||
{saving() ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<Show when={saveMsg()}>
|
||||
<span style={{
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
|
||||
}}>
|
||||
{saveMsg()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{/* ── Save button ─────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving() || isLocked()}
|
||||
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
|
||||
>
|
||||
{saving() ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<Show when={saveMsg()}>
|
||||
<span style={{
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
|
||||
}}>
|
||||
{saveMsg()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
44
src/components/dashboard/RoleDashboardShared.ts
Normal file
44
src/components/dashboard/RoleDashboardShared.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export type RoleKey =
|
||||
| 'COMPANY'
|
||||
| 'CUSTOMER'
|
||||
| 'JOB_SEEKER'
|
||||
| 'PHOTOGRAPHER'
|
||||
| 'MAKEUP_ARTIST'
|
||||
| 'TUTOR'
|
||||
| 'DEVELOPER'
|
||||
| 'VIDEO_EDITOR'
|
||||
| 'UGC_CONTENT_CREATOR'
|
||||
| 'GRAPHIC_DESIGNER'
|
||||
| 'SOCIAL_MEDIA_MANAGER'
|
||||
| 'FITNESS_TRAINER'
|
||||
| 'CATERING_SERVICES';
|
||||
|
||||
export const ROLE_PREFIXES: Record<RoleKey, string> = {
|
||||
PHOTOGRAPHER: 'photographers',
|
||||
MAKEUP_ARTIST: 'makeup-artists',
|
||||
TUTOR: 'tutors',
|
||||
DEVELOPER: 'developers',
|
||||
VIDEO_EDITOR: 'video-editors',
|
||||
GRAPHIC_DESIGNER: 'graphic-designers',
|
||||
SOCIAL_MEDIA_MANAGER: 'social-media-managers',
|
||||
FITNESS_TRAINER: 'fitness-trainers',
|
||||
CATERING_SERVICES: 'catering-services',
|
||||
UGC_CONTENT_CREATOR: 'ugc-content-creators',
|
||||
CUSTOMER: 'customers',
|
||||
COMPANY: 'companies',
|
||||
JOB_SEEKER: 'jobseeker',
|
||||
};
|
||||
|
||||
export const PROFESSIONAL_ROLE_SET = new Set<RoleKey>([
|
||||
'PHOTOGRAPHER',
|
||||
'MAKEUP_ARTIST',
|
||||
'TUTOR',
|
||||
'DEVELOPER',
|
||||
'VIDEO_EDITOR',
|
||||
'UGC_CONTENT_CREATOR',
|
||||
'GRAPHIC_DESIGNER',
|
||||
'SOCIAL_MEDIA_MANAGER',
|
||||
'FITNESS_TRAINER',
|
||||
'CATERING_SERVICES',
|
||||
]);
|
||||
|
||||
297
src/components/dashboard/SettingsPage.tsx
Normal file
297
src/components/dashboard/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = createSignal('');
|
||||
const [newPassword, setNewPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
const [emailNotif, setEmailNotif] = createSignal(true);
|
||||
const [appNotif, setAppNotif] = createSignal(true);
|
||||
const [smsNotif, setSmsNotif] = createSignal(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = createSignal('');
|
||||
const [deleteReason, setDeleteReason] = createSignal('');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [savingPassword, setSavingPassword] = createSignal(false);
|
||||
const [savingNotifications, setSavingNotifications] = createSignal(false);
|
||||
const [requestingDelete, setRequestingDelete] = createSignal(false);
|
||||
const [deleteRequestStatus, setDeleteRequestStatus] = createSignal<'NONE' | 'DELETED'>('NONE');
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
onMount(async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const [settingsRes, deleteReqRes] = await Promise.all([
|
||||
apiFetch('/api/me/settings'),
|
||||
apiFetch('/api/me/settings/delete-account-request'),
|
||||
]);
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json().catch(() => ({}));
|
||||
setEmailNotif(Boolean(data.email_notifications));
|
||||
setAppNotif(Boolean(data.in_app_notifications));
|
||||
setSmsNotif(Boolean(data.sms_notifications));
|
||||
}
|
||||
|
||||
if (deleteReqRes.ok) {
|
||||
const data = await deleteReqRes.json().catch(() => ({}));
|
||||
const status = String(data.status || 'NONE').toUpperCase();
|
||||
if (status === 'DELETED') setDeleteRequestStatus('DELETED');
|
||||
else setDeleteRequestStatus('NONE');
|
||||
}
|
||||
} catch {
|
||||
setErr('Failed to load account settings.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const saveNotificationPrefs = async () => {
|
||||
setErr('');
|
||||
setMsg('');
|
||||
setSavingNotifications(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/me/settings/notifications', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
email_notifications: emailNotif(),
|
||||
in_app_notifications: appNotif(),
|
||||
sms_notifications: smsNotif(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to save notification preferences.');
|
||||
return;
|
||||
}
|
||||
setMsg('Notification preferences saved.');
|
||||
} catch {
|
||||
setErr('Network error while saving notification preferences.');
|
||||
} finally {
|
||||
setSavingNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
setErr('');
|
||||
setMsg('');
|
||||
if (!currentPassword() || !newPassword() || !confirmPassword()) {
|
||||
setErr('Please fill all password fields.');
|
||||
return;
|
||||
}
|
||||
if (newPassword() !== confirmPassword()) {
|
||||
setErr('New password and confirm password do not match.');
|
||||
return;
|
||||
}
|
||||
if (newPassword().length < 8) {
|
||||
setErr('New password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword(),
|
||||
new_password: newPassword(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to update password.');
|
||||
return;
|
||||
}
|
||||
setMsg('Password updated successfully.');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch {
|
||||
setErr('Network error while updating password.');
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteAccount = async () => {
|
||||
setErr('');
|
||||
setMsg('');
|
||||
if (deleteConfirm().trim().toUpperCase() !== 'DELETE') {
|
||||
setErr('Type DELETE to confirm account deletion request.');
|
||||
return;
|
||||
}
|
||||
setRequestingDelete(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/me/settings/delete-account-request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: deleteReason().trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to submit delete account request.');
|
||||
return;
|
||||
}
|
||||
setDeleteRequestStatus('DELETED');
|
||||
setMsg('Your account has been deleted. Redirecting to login...');
|
||||
setDeleteConfirm('');
|
||||
setDeleteReason('');
|
||||
try {
|
||||
window.localStorage.removeItem('nxtgauge_signup_profile_v1');
|
||||
window.localStorage.removeItem('nxtgauge_auth_user');
|
||||
window.localStorage.removeItem('nxtgauge_user');
|
||||
window.sessionStorage.removeItem('nxtgauge_access_token');
|
||||
window.sessionStorage.removeItem('nxtgauge_refresh_token');
|
||||
window.sessionStorage.removeItem('nxtgauge_auth_user');
|
||||
window.sessionStorage.removeItem('nxtgauge_user');
|
||||
} catch {
|
||||
// ignore storage cleanup errors
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 1200);
|
||||
} catch {
|
||||
setErr('Network error while submitting delete account request.');
|
||||
} finally {
|
||||
setRequestingDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '760px', display: 'grid', gap: '14px' }}>
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>
|
||||
Loading settings...
|
||||
</div>
|
||||
</Show>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 4px', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>
|
||||
Settings
|
||||
</p>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Manage account security, notifications, and privacy controls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>
|
||||
Password & Login
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Change your account password.
|
||||
</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<label style={LABEL}>Current Password</label>
|
||||
<input type="password" value={currentPassword()} onInput={(e) => setCurrentPassword(e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>New Password</label>
|
||||
<input type="password" value={newPassword()} onInput={(e) => setNewPassword(e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} style={INPUT} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', 'margin-top': '12px' }}>
|
||||
<button type="button" onClick={changePassword} disabled={savingPassword()} style={{ ...BTN_PRIMARY, opacity: savingPassword() ? '0.7' : '1' }}>
|
||||
{savingPassword() ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>
|
||||
Notification Preferences
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Choose how you receive notifications.
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
|
||||
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0', 'border-bottom': '1px solid #F3F4F6' }}>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>Email Notifications</span>
|
||||
<input type="checkbox" checked={emailNotif()} onChange={(e) => setEmailNotif(e.currentTarget.checked)} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0', 'border-bottom': '1px solid #F3F4F6' }}>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>In-App Notifications</span>
|
||||
<input type="checkbox" checked={appNotif()} onChange={(e) => setAppNotif(e.currentTarget.checked)} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0' }}>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>SMS Alerts</span>
|
||||
<input type="checkbox" checked={smsNotif()} onChange={(e) => setSmsNotif(e.currentTarget.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', 'margin-top': '12px' }}>
|
||||
<button type="button" onClick={saveNotificationPrefs} disabled={savingNotifications()} style={{ ...BTN_GHOST, opacity: savingNotifications() ? '0.7' : '1' }}>
|
||||
{savingNotifications() ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2' }}>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#991B1B' }}>
|
||||
Delete Account
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#7F1D1D' }}>
|
||||
This action is irreversible. Type <strong>DELETE</strong> to confirm.
|
||||
</p>
|
||||
<Show when={deleteRequestStatus() === 'DELETED'}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '12px', color: '#92400E', 'font-weight': '700' }}>
|
||||
Your account is already deleted.
|
||||
</p>
|
||||
</Show>
|
||||
<div style={{ 'margin-bottom': '10px' }}>
|
||||
<label style={LABEL}>Reason (optional)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Tell us why you want to delete your account"
|
||||
value={deleteReason()}
|
||||
onInput={(e) => setDeleteReason(e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr auto', gap: '10px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type DELETE"
|
||||
value={deleteConfirm()}
|
||||
onInput={(e) => setDeleteConfirm(e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestDeleteAccount}
|
||||
disabled={requestingDelete() || deleteRequestStatus() === 'DELETED'}
|
||||
style={{ ...BTN_ORANGE, background: '#DC2626', opacity: requestingDelete() || deleteRequestStatus() === 'DELETED' ? '0.7' : '1' }}
|
||||
>
|
||||
{requestingDelete() ? 'Submitting...' : 'Request Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={msg()}>
|
||||
<div style={{ ...CARD, padding: '12px 14px', border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>
|
||||
{msg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, padding: '12px 14px', border: '1px solid #FECACA', background: '#FEF2F2', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>
|
||||
{err()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/dashboard/SwitchServicesPage.tsx
Normal file
231
src/components/dashboard/SwitchServicesPage.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type UserRoleItem = {
|
||||
role_key: string;
|
||||
role_name?: string;
|
||||
status?: string;
|
||||
approved_at?: string | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
const REGISTER_OPTIONS = [
|
||||
{ key: 'PHOTOGRAPHER', label: 'Photographer' },
|
||||
{ key: 'MAKEUP_ARTIST', label: 'Makeup Artist' },
|
||||
{ key: 'TUTOR', label: 'Tutor' },
|
||||
{ key: 'DEVELOPER', label: 'Developer' },
|
||||
{ key: 'VIDEO_EDITOR', label: 'Video Editor' },
|
||||
{ key: 'UGC_CONTENT_CREATOR', label: 'UGC Content Creator' },
|
||||
{ key: 'GRAPHIC_DESIGNER', label: 'Graphic Designer' },
|
||||
{ key: 'SOCIAL_MEDIA_MANAGER', label: 'Social Media Manager' },
|
||||
{ key: 'FITNESS_TRAINER', label: 'Fitness Trainer' },
|
||||
{ key: 'CATERING_SERVICES', label: 'Catering Services' },
|
||||
{ key: 'COMPANY', label: 'Company' },
|
||||
{ key: 'CUSTOMER', label: 'Customer' },
|
||||
{ key: 'JOB_SEEKER', label: 'Job Seeker' },
|
||||
];
|
||||
|
||||
export default function SwitchServicesPage() {
|
||||
const [roles, setRoles] = createSignal<UserRoleItem[]>([]);
|
||||
const [activeRole, setActiveRole] = createSignal('');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyRole, setBusyRole] = createSignal<string | null>(null);
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const loadRoles = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem('nxtgauge_auth_user') : null;
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
const active = String(parsed?.active_role || parsed?.role || '').trim().toUpperCase();
|
||||
setActiveRole(active);
|
||||
} catch {
|
||||
setActiveRole('');
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/me/roles');
|
||||
const data = await res.json().catch(() => []);
|
||||
if (!res.ok) {
|
||||
setErr('Failed to load roles.');
|
||||
setRoles([]);
|
||||
return;
|
||||
}
|
||||
setRoles(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setErr('Network error while loading roles.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadRoles);
|
||||
|
||||
const registerRole = async (roleKey: string) => {
|
||||
setBusyRole(roleKey);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch('/api/me/roles/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role_key: roleKey }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to register role.');
|
||||
return;
|
||||
}
|
||||
setMsg(`${roleKey} registered. You can switch after refresh/login.`);
|
||||
await loadRoles();
|
||||
} catch {
|
||||
setErr('Network error while registering role.');
|
||||
} finally {
|
||||
setBusyRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const switchRole = async (roleKey: string) => {
|
||||
setBusyRole(roleKey);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/switch-role', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role_key: roleKey }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || data.message || 'Failed to switch role.');
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = String(data?.access_token || '').trim();
|
||||
if (accessToken && typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = ['nxtgauge_auth_user', 'nxtgauge_user', 'nxtgauge_signup_profile_v1'];
|
||||
for (const key of keys) {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const next = {
|
||||
...parsed,
|
||||
active_role: roleKey,
|
||||
role: roleKey.toLowerCase(),
|
||||
roleKey: roleKey.toLowerCase(),
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveRole(roleKey);
|
||||
setMsg(`Switched to ${roleKey}. Refreshing dashboard...`);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 250);
|
||||
} catch {
|
||||
setErr('Network error while switching role.');
|
||||
} finally {
|
||||
setBusyRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasRole = (roleKey: string) => roles().some((r) => String(r.role_key || '').toUpperCase() === roleKey);
|
||||
const isActive = (roleKey: string) => activeRole() === roleKey;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Switch Services</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Manage approved roles and register additional services.
|
||||
</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>
|
||||
</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>
|
||||
</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 Roles</p>
|
||||
<button type="button" onClick={loadRoles} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading roles...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && roles().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No roles found for this user.</p>
|
||||
</Show>
|
||||
<Show when={!loading() && roles().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
<For each={roles()}>
|
||||
{(r) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{r.role_name || r.role_key}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
|
||||
Status: {String(r.status || 'APPROVED').replace(/_/g, ' ')} {r.approved_at ? `• Approved ${new Date(r.approved_at).toLocaleString('en-IN')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
|
||||
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#ECFDF5', color: '#15803D', 'font-size': '11px', 'font-weight': '700' }}>Approved</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchRole(String(r.role_key || '').toUpperCase())}
|
||||
disabled={busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase())}
|
||||
style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', opacity: busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase()) ? '0.7' : '1' }}
|
||||
>
|
||||
{isActive(String(r.role_key || '').toUpperCase()) ? 'Active' : busyRole() === String(r.role_key || '').toUpperCase() ? 'Switching...' : 'Switch'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Register Another Service</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={REGISTER_OPTIONS}>
|
||||
{(opt) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{opt.label}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={hasRole(opt.key) || busyRole() === opt.key}
|
||||
onClick={() => registerRole(opt.key)}
|
||||
style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', 'margin-top': '8px', width: '100%', opacity: hasRole(opt.key) || busyRole() === opt.key ? '0.7' : '1' }}
|
||||
>
|
||||
{hasRole(opt.key) ? 'Registered' : busyRole() === opt.key ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ const API = '/api/gateway';
|
|||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = typeof window !== 'undefined'
|
||||
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
|
||||
? (sessionStorage.getItem('nxtgauge_access_token') || '')
|
||||
: '';
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -160,7 +160,7 @@ export async function uploadDocument(rolePrefix: string, file: File, documentTyp
|
|||
formData.append('document_type', documentType);
|
||||
|
||||
const token = typeof window !== 'undefined'
|
||||
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
|
||||
? (sessionStorage.getItem('nxtgauge_access_token') || '')
|
||||
: '';
|
||||
|
||||
const res = await fetch(`${API}/api/${rolePrefix}/profile/documents`, {
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ describe('normalizeIntent', () => {
|
|||
});
|
||||
|
||||
describe('intentToOnboardingPath', () => {
|
||||
it('maps each intent to expected onboarding path', () => {
|
||||
expect(intentToOnboardingPath('company')).toBe('/users/onboarding/company');
|
||||
expect(intentToOnboardingPath('job_seeker')).toBe('/users/onboarding/job-seeker');
|
||||
expect(intentToOnboardingPath('professional')).toBe('/users/onboarding/professional');
|
||||
expect(intentToOnboardingPath('customer')).toBe('/users/onboarding/customer');
|
||||
it('maps each intent to signup route with intent query', () => {
|
||||
expect(intentToOnboardingPath('company')).toBe('/signup?intent=company');
|
||||
expect(intentToOnboardingPath('job_seeker')).toBe('/signup?intent=job_seeker');
|
||||
expect(intentToOnboardingPath('professional')).toBe('/signup?intent=professional');
|
||||
expect(intentToOnboardingPath('customer')).toBe('/signup?intent=customer');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ export function normalizeIntent(value: string | null | undefined): CanonicalInte
|
|||
}
|
||||
|
||||
export function intentToOnboardingPath(intent: CanonicalIntent): string {
|
||||
if (intent === 'company') return '/users/onboarding/company';
|
||||
if (intent === 'job_seeker') return '/users/onboarding/job-seeker';
|
||||
if (intent === 'professional') return '/users/onboarding/professional';
|
||||
return '/users/onboarding/customer';
|
||||
if (intent === 'company') return '/signup?intent=company';
|
||||
if (intent === 'job_seeker') return '/signup?intent=job_seeker';
|
||||
if (intent === 'professional') return '/signup?intent=professional';
|
||||
return '/signup?intent=customer';
|
||||
}
|
||||
|
||||
export function saveCanonicalIntent(intent: CanonicalIntent): void {
|
||||
|
|
|
|||
|
|
@ -23,16 +23,13 @@ const AuthContext = createContext<AuthState>();
|
|||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem('nxtgauge_access_token')
|
||||
|| localStorage.getItem('nxtgauge_access_token')
|
||||
|| null;
|
||||
return sessionStorage.getItem('nxtgauge_access_token') || null;
|
||||
}
|
||||
|
||||
function clearAuthStorage() {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem('nxtgauge_access_token');
|
||||
sessionStorage.removeItem('nxtgauge_frontend_access_token');
|
||||
localStorage.removeItem('nxtgauge_access_token');
|
||||
localStorage.removeItem('nxtgauge_auth_user');
|
||||
localStorage.removeItem('nxtgauge_user');
|
||||
localStorage.removeItem('nxtgauge_signup_profile_v1');
|
||||
|
|
|
|||
|
|
@ -6,9 +6,32 @@ import DashboardShell from '~/components/DashboardShell';
|
|||
import ProfilePage from '~/components/dashboard/ProfilePage';
|
||||
import PortfolioPage from '~/components/dashboard/PortfolioPage';
|
||||
import VerificationStatusPage from '~/components/dashboard/VerificationStatusPage';
|
||||
import CompanyJobsPage from '~/components/dashboard/CompanyJobsPage';
|
||||
import CompanyApplicationsPage from '~/components/dashboard/CompanyApplicationsPage';
|
||||
import SettingsPage from '~/components/dashboard/SettingsPage';
|
||||
import MyDashboardPage from '~/components/dashboard/MyDashboardPage';
|
||||
import CustomerRequirementsPage from '~/components/dashboard/CustomerRequirementsPage';
|
||||
import CustomerResponsesPage from '~/components/dashboard/CustomerResponsesPage';
|
||||
import CompanyShortlistedCandidatesPage from '~/components/dashboard/CompanyShortlistedCandidatesPage';
|
||||
import JobSeekerApplicationsPage from '~/components/dashboard/JobSeekerApplicationsPage';
|
||||
import JobSeekerSavedJobsPage from '~/components/dashboard/JobSeekerSavedJobsPage';
|
||||
import JobSeekerJobsPage from '~/components/dashboard/JobSeekerJobsPage';
|
||||
import ProfessionalLeadsPage from '~/components/dashboard/ProfessionalLeadsPage';
|
||||
import ProfessionalResponsesPage from '~/components/dashboard/ProfessionalResponsesPage';
|
||||
import CreditsPage from '~/components/dashboard/CreditsPage';
|
||||
import ExploreServicesPage from '~/components/dashboard/ExploreServicesPage';
|
||||
import HelpCenterDashboardPage from '~/components/dashboard/HelpCenterDashboardPage';
|
||||
import SwitchServicesPage from '~/components/dashboard/SwitchServicesPage';
|
||||
import LogoutPage from '~/components/dashboard/LogoutPage';
|
||||
import { PROFESSIONAL_ROLE_SET } from '~/components/dashboard/RoleDashboardShared';
|
||||
|
||||
// Sidebar items that have real implementations (not the preview mock)
|
||||
const REAL_PAGES = ['my profile', 'my portfolio', 'verification'];
|
||||
const BASE_REAL_PAGES = ['my dashboard', 'my profile', 'my portfolio', 'verification', 'settings'];
|
||||
const COMMON_REAL_PAGES = ['credits', 'explore nxtgauge', 'help center', 'switch services', 'logout'];
|
||||
const COMPANY_REAL_PAGES = ['jobs', 'applications', 'shortlisted candidates'];
|
||||
const CUSTOMER_REAL_PAGES = ['my requirements', 'received responses', 'shortlisted responses'];
|
||||
const JOB_SEEKER_REAL_PAGES = ['jobs', 'my applications', 'saved jobs'];
|
||||
const PROFESSIONAL_REAL_PAGES = ['leads', 'my responses'];
|
||||
|
||||
type RoleKey =
|
||||
| 'COMPANY'
|
||||
|
|
@ -32,6 +55,7 @@ type RuntimeBundle = {
|
|||
tabs: string[];
|
||||
widgets: string[];
|
||||
fields: string[];
|
||||
verificationStatus?: string;
|
||||
source: 'dashboard-config';
|
||||
};
|
||||
|
||||
|
|
@ -173,6 +197,42 @@ async function loadRoleBundle(role: RoleKey): Promise<RuntimeBundle | null> {
|
|||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const runtime = await fetchJson('/api/runtime-config');
|
||||
if (runtime) {
|
||||
const runtimeRole = normalizeRole(
|
||||
String(runtime?.role || runtime?.user?.active_role || role),
|
||||
);
|
||||
const runtimeSidebar = asStringArray(
|
||||
runtime?.dashboard_config?.sidebar_items
|
||||
?? runtime?.dashboard_config?.sidebarItems
|
||||
?? runtime?.sidebar_items
|
||||
?? runtime?.sidebarItems,
|
||||
);
|
||||
const runtimeTabs = asStringArray(
|
||||
runtime?.dashboard_config?.tabs ?? runtime?.tabs,
|
||||
);
|
||||
const runtimeWidgetsRaw = Array.isArray(runtime?.dashboard_config?.widgets)
|
||||
? runtime.dashboard_config.widgets
|
||||
: (Array.isArray(runtime?.widgets) ? runtime.widgets : []);
|
||||
const runtimeWidgets = runtimeWidgetsRaw
|
||||
.map((item: any) => String(typeof item === 'string' ? item : (item?.key || item?.id || '')).trim())
|
||||
.filter(Boolean);
|
||||
const runtimeFields = asStringArray(
|
||||
runtime?.dashboard_config?.fields ?? runtime?.fields,
|
||||
);
|
||||
|
||||
return {
|
||||
role: runtimeRole,
|
||||
status: 'ACTIVE',
|
||||
sidebarItems: runtimeSidebar,
|
||||
tabs: runtimeTabs,
|
||||
widgets: runtimeWidgets,
|
||||
fields: runtimeFields,
|
||||
verificationStatus: String(runtime?.verification_status || runtime?.user?.verification_status || '').toUpperCase() || undefined,
|
||||
source: 'dashboard-config',
|
||||
};
|
||||
}
|
||||
|
||||
let payload = await fetchJson(`/api/config/dashboard/by-key/${encodeURIComponent(role)}?audience=EXTERNAL`);
|
||||
if (!payload) {
|
||||
const listPayload = await fetchJson('/api/admin/dashboard-config?audience=EXTERNAL');
|
||||
|
|
@ -200,7 +260,7 @@ async function loadRoleBundle(role: RoleKey): Promise<RuntimeBundle | null> {
|
|||
};
|
||||
}
|
||||
|
||||
function mergeSidebar(role: RoleKey, runtimeSidebar: string[]): string[] {
|
||||
function mergeSidebar(role: RoleKey, runtimeSidebar: string[], verificationStatus?: string): string[] {
|
||||
const base = ROLE_BASED_SIDEBAR[role] || ['My Dashboard', 'My Profile', 'Switch Services', 'Logout'];
|
||||
const fromRuntime = runtimeSidebar.filter(Boolean);
|
||||
const map = new Map<string, string>();
|
||||
|
|
@ -217,6 +277,21 @@ function mergeSidebar(role: RoleKey, runtimeSidebar: string[]): string[] {
|
|||
if (insertBefore >= 0) merged.splice(insertBefore, 0, 'Explore Nxtgauge');
|
||||
else merged.push('Explore Nxtgauge');
|
||||
}
|
||||
const status = String(verificationStatus || '').toUpperCase();
|
||||
const approved = status === 'APPROVED';
|
||||
if (!approved && status) {
|
||||
const restricted = new Set(
|
||||
[
|
||||
'my profile',
|
||||
'help center',
|
||||
'settings',
|
||||
'verification',
|
||||
'logout',
|
||||
...(PROFESSIONAL_ROLE_SET.has(role) ? ['my portfolio', 'credits'] : []),
|
||||
],
|
||||
);
|
||||
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +332,12 @@ export default function RuntimeDashboardPage() {
|
|||
loadRoleBundle,
|
||||
);
|
||||
|
||||
const sidebarItems = createMemo(() => mergeSidebar(role(), bundle()?.sidebarItems || []));
|
||||
const sidebarItems = createMemo(() => mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.verificationStatus));
|
||||
|
||||
createEffect(() => {
|
||||
const runtimeRole = bundle()?.role;
|
||||
if (runtimeRole && runtimeRole !== role()) setRole(runtimeRole);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const first = sidebarItems()[0] || 'My Dashboard';
|
||||
|
|
@ -286,9 +366,16 @@ export default function RuntimeDashboardPage() {
|
|||
return { userName: userName(), userId: userId(), rolePrefix: prefix };
|
||||
});
|
||||
|
||||
const isRealPage = createMemo(() =>
|
||||
REAL_PAGES.includes(activeSidebar().toLowerCase()),
|
||||
);
|
||||
const isRealPage = createMemo(() => {
|
||||
const key = activeSidebar().toLowerCase();
|
||||
if (BASE_REAL_PAGES.includes(key)) return true;
|
||||
if (COMMON_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'COMPANY' && COMPANY_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'CUSTOMER' && CUSTOMER_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'JOB_SEEKER' && JOB_SEEKER_REAL_PAGES.includes(key)) return true;
|
||||
if (PROFESSIONAL_ROLE_SET.has(role()) && PROFESSIONAL_REAL_PAGES.includes(key)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
|
|
@ -310,6 +397,9 @@ export default function RuntimeDashboardPage() {
|
|||
userName={userName()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my dashboard'}>
|
||||
<MyDashboardPage roleKey={role()} userName={userName()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my profile'}>
|
||||
<ProfilePage roleKey={role()} />
|
||||
</Match>
|
||||
|
|
@ -322,6 +412,57 @@ export default function RuntimeDashboardPage() {
|
|||
onNavigate={setActiveSidebar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'settings'}>
|
||||
<SettingsPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'credits'}>
|
||||
<CreditsPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'explore nxtgauge'}>
|
||||
<ExploreServicesPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'help center'}>
|
||||
<HelpCenterDashboardPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'switch services'}>
|
||||
<SwitchServicesPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'logout'}>
|
||||
<LogoutPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'jobs'}>
|
||||
<CompanyJobsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'applications'}>
|
||||
<CompanyApplicationsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'shortlisted candidates'}>
|
||||
<CompanyShortlistedCandidatesPage />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'my requirements'}>
|
||||
<CustomerRequirementsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'received responses'}>
|
||||
<CustomerResponsesPage mode="received" />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'shortlisted responses'}>
|
||||
<CustomerResponsesPage mode="shortlisted" />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'my applications'}>
|
||||
<JobSeekerApplicationsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'jobs'}>
|
||||
<JobSeekerJobsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'saved jobs'}>
|
||||
<JobSeekerSavedJobsPage />
|
||||
</Match>
|
||||
<Match when={PROFESSIONAL_ROLE_SET.has(role()) && activeSidebar().toLowerCase() === 'leads'}>
|
||||
<ProfessionalLeadsPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={PROFESSIONAL_ROLE_SET.has(role()) && activeSidebar().toLowerCase() === 'my responses'}>
|
||||
<ProfessionalResponsesPage roleKey={role()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</DashboardShell>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ export default function LoginRoute() {
|
|||
if (typeof window !== 'undefined' && accessToken) {
|
||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||
window.localStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
}
|
||||
saveUser(data?.user || {});
|
||||
if (auth.setUser) {
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ export default function SignupRoute() {
|
|||
if (typeof window !== 'undefined' && accessToken) {
|
||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||
window.localStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -199,6 +198,8 @@ export default function SignupRoute() {
|
|||
email: email().trim().toLowerCase(),
|
||||
password: password(),
|
||||
phone: null,
|
||||
intent: role(),
|
||||
profession: selectedProfessionalRole() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue