From 809e5e4b4b71b463c1249dc885cf76db6cf3c25b Mon Sep 17 00:00:00 2001 From: Brandon Korous Date: Sat, 11 Apr 2026 15:13:07 -0700 Subject: [PATCH 1/3] feat: implement candidate and placement detail pages with API integration --- .../src/app/secure/candidates/[id]/page.tsx | 216 +++++++++++++++ .../candidates/components/candidate-table.tsx | 3 + .../src/app/secure/placements/[id]/page.tsx | 260 ++++++++++++++++++ .../placements/components/placement-table.tsx | 3 + docs/admin/candidates.md | 2 + docs/admin/placements.md | 2 + .../src/v3/admin/views/lists.repository.ts | 54 ++++ .../src/v3/admin/views/lists.route.ts | 28 ++ 8 files changed, 568 insertions(+) create mode 100644 apps/admin/src/app/secure/candidates/[id]/page.tsx create mode 100644 apps/admin/src/app/secure/placements/[id]/page.tsx diff --git a/apps/admin/src/app/secure/candidates/[id]/page.tsx b/apps/admin/src/app/secure/candidates/[id]/page.tsx new file mode 100644 index 000000000..8aaa1a586 --- /dev/null +++ b/apps/admin/src/app/secure/candidates/[id]/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@clerk/nextjs'; +import { createAuthenticatedClient } from '@/lib/api-client'; +import { AdminPageHeader, AdminLoadingState, AdminErrorState, AdminDataTable, type Column } from '@/components/shared'; + +type CandidateApp = { + id: string; + stage: string; + job_id: string; + job_title: string | null; + company_name: string | null; + created_at: string; +}; + +type CandidateDetail = { + id: string; + email: string | null; + full_name: string | null; + first_name?: string | null; + last_name?: string | null; + phone: string | null; + location: string | null; + current_title: string | null; + current_company: string | null; + bio: string | null; + linkedin_url: string | null; + github_url: string | null; + portfolio_url: string | null; + skills: string | null; + verification_status: string | null; + resume_status?: string | null; + desired_salary_min: number | null; + desired_salary_max: number | null; + marketplace_visibility: string | null; + user_id: string | null; + recruiter_id: string | null; + created_at: string; + updated_at: string | null; + applications: CandidateApp[]; + recruiter_relationships: Array<{ id: string; recruiter_id: string; status: string; created_at: string }>; +}; + +const STAGE_BADGE: Record = { + draft: 'badge-ghost', ai_review: 'badge-info', ai_reviewed: 'badge-info', + ai_failed: 'badge-error', submitted: 'badge-info', screen: 'badge-warning', + company_review: 'badge-accent', interview: 'badge-warning', offer: 'badge-primary', + hired: 'badge-success', rejected: 'badge-error', withdrawn: 'badge-ghost', +}; + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? } +
+ ); +} + +function formatDate(iso?: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatSalary(min: number | null, max: number | null) { + const fmt = (n: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); + if (min && max) return `${fmt(min)} – ${fmt(max)}`; + if (min) return `From ${fmt(min)}`; + if (max) return `Up to ${fmt(max)}`; + return null; +} + +const APP_COLUMNS: Column[] = [ + { key: 'job_title', label: 'Job', render: (a) => {a.job_title ?? '—'} }, + { key: 'company_name', label: 'Company', render: (a) => {a.company_name ?? '—'} }, + { key: 'stage', label: 'Stage', render: (a) => {a.stage.replace(/_/g, ' ')} }, + { key: 'created_at', label: 'Applied', render: (a) => {formatDate(a.created_at)} }, +]; + +export default function CandidateDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { getToken } = useAuth(); + const [candidate, setCandidate] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCandidate = useCallback(async () => { + try { + const token = await getToken(); + if (!token) { setError('Not authenticated'); setLoading(false); return; } + const client = createAuthenticatedClient(token); + const res = await client.get<{ data: CandidateDetail }>(`/ats/admin/candidates/${id}`); + setCandidate((res as { data: CandidateDetail }).data); + } catch { + setError('Failed to load candidate'); + } finally { + setLoading(false); + } + }, [id, getToken]); + + useEffect(() => { void fetchCandidate(); }, [fetchCandidate]); + + if (loading) return
; + if (error || !candidate) return
; + + const name = candidate.full_name + || [candidate.first_name, candidate.last_name].filter(Boolean).join(' ') + || 'Unknown Candidate'; + + const VERIF_BADGE: Record = { verified: 'badge-success', unverified: 'badge-ghost', pending: 'badge-warning' }; + + return ( +
+ + + + {candidate.verification_status ?? 'unknown'} + + } + /> + +
+
+ {/* Profile */} +
+
+

Profile

+ + + + + + + + +
+
+ + {candidate.bio && ( +
+
+

Bio

+

{candidate.bio}

+
+
+ )} + + {/* Applications */} +
+
+

+ Applications ({candidate.applications.length}) +

+
+ router.push(`/secure/applications/${app.id}`)} + emptyTitle="No applications" + emptyDescription="This candidate has no applications." + /> +
+
+ + {/* Sidebar */} +
+
+
+

Quick Info

+
+
Applications{candidate.applications.length}
+
Recruiters{candidate.recruiter_relationships.length}
+
Visibility{candidate.marketplace_visibility ?? '—'}
+
+
+
+ + {/* Links */} + {(candidate.linkedin_url || candidate.github_url || candidate.portfolio_url) && ( +
+
+

Links

+
+ {candidate.linkedin_url && {candidate.linkedin_url}} + {candidate.github_url && {candidate.github_url}} + {candidate.portfolio_url && {candidate.portfolio_url}} +
+
+
+ )} + +
+
+

System

+
+

Candidate ID

{candidate.id}

+ {candidate.user_id &&

User ID

{candidate.user_id}

} + {candidate.recruiter_id &&

Source Recruiter ID

{candidate.recruiter_id}

} +
+
+
+
+
+
+ ); +} diff --git a/apps/admin/src/app/secure/candidates/components/candidate-table.tsx b/apps/admin/src/app/secure/candidates/components/candidate-table.tsx index 90ac8f654..6a9710213 100644 --- a/apps/admin/src/app/secure/candidates/components/candidate-table.tsx +++ b/apps/admin/src/app/secure/candidates/components/candidate-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { AdminDataTable, AdminPageHeader, type Column } from '@/components/shared'; import { useStandardList } from '@/hooks/use-standard-list'; import { BuildSmartResumeButton } from './build-smart-resume-button'; @@ -87,6 +88,7 @@ const RESUME_OPTIONS = [ ]; export function CandidateTable() { + const router = useRouter(); const { data, loading, @@ -154,6 +156,7 @@ export function CandidateTable() { sortField={sortBy} sortDir={sortOrder} onSort={handleSort} + onRowClick={(c) => router.push(`/secure/candidates/${c.id}`)} emptyTitle="No candidates found" emptyDescription="No candidates match your search." /> diff --git a/apps/admin/src/app/secure/placements/[id]/page.tsx b/apps/admin/src/app/secure/placements/[id]/page.tsx new file mode 100644 index 000000000..e02c8c916 --- /dev/null +++ b/apps/admin/src/app/secure/placements/[id]/page.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useAuth } from '@clerk/nextjs'; +import { createAuthenticatedClient } from '@/lib/api-client'; +import { AdminPageHeader, AdminLoadingState, AdminErrorState } from '@/components/shared'; + +type PlacementDetail = { + id: string; + job_id: string; + candidate_id: string; + company_id: string | null; + application_id: string | null; + state: string; + salary: number | null; + fee_percentage: number | null; + fee_amount: number | null; + placement_fee: number | null; + recruiter_share: number | null; + platform_share: number | null; + start_date: string | null; + end_date: string | null; + guarantee_days: number; + guarantee_expires_at: string | null; + failure_reason: string | null; + failed_at: string | null; + hired_at: string | null; + candidate_name: string | null; + candidate_email: string | null; + job_title: string | null; + company_name: string | null; + recruiter_name: string | null; + recruiter_email: string | null; + candidate_recruiter_id: string | null; + company_recruiter_id: string | null; + job_owner_recruiter_id: string | null; + created_at: string; + updated_at: string | null; + job: { id: string; title: string; status: string } | null; + candidate: { id: string; full_name: string | null; email: string | null; phone: string | null; location: string | null } | null; + company: { id: string; name: string; logo_url: string | null } | null; +}; + +const STATE_BADGE: Record = { + hired: 'badge-info', active: 'badge-success', completed: 'badge-success', + failed: 'badge-error', +}; + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? } +
+ ); +} + +function formatDate(iso?: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatCurrency(amount?: number | null) { + if (amount == null) return '—'; + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function daysUntil(iso?: string | null): string | null { + if (!iso) return null; + const diff = Math.ceil((new Date(iso).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + if (diff < 0) return 'Expired'; + if (diff === 0) return 'Today'; + return `${diff} days`; +} + +export default function PlacementDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { getToken } = useAuth(); + const [placement, setPlacement] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPlacement = useCallback(async () => { + try { + const token = await getToken(); + if (!token) { setError('Not authenticated'); setLoading(false); return; } + const client = createAuthenticatedClient(token); + const res = await client.get<{ data: PlacementDetail }>(`/ats/admin/placements/${id}`); + setPlacement((res as { data: PlacementDetail }).data); + } catch { + setError('Failed to load placement'); + } finally { + setLoading(false); + } + }, [id, getToken]); + + useEffect(() => { void fetchPlacement(); }, [fetchPlacement]); + + if (loading) return
; + if (error || !placement) return
; + + const candidateName = placement.candidate?.full_name ?? placement.candidate_name ?? 'Unknown'; + const guaranteeRemaining = daysUntil(placement.guarantee_expires_at); + + return ( +
+ + + + {placement.state} + + } + /> + +
+
+ {/* Parties */} +
+
+

Parties

+ + {placement.candidate.full_name ?? placement.candidate_name} + + ) : candidateName + } /> + + + + {placement.job.title} + + ) : placement.job_title + } /> + + {placement.application_id && ( + + {placement.application_id.slice(0, 8)}... + + } /> + )} +
+
+ + {/* Financial */} +
+
+

Financial

+ + + + + + +
+
+ + {/* Recruiters */} + {(placement.candidate_recruiter_id || placement.company_recruiter_id || placement.job_owner_recruiter_id) && ( +
+
+

Recruiter Attribution

+ {placement.recruiter_name && } + {placement.candidate_recruiter_id && ( + + {placement.candidate_recruiter_id.slice(0, 8)}... + + } /> + )} + {placement.company_recruiter_id && ( + + {placement.company_recruiter_id.slice(0, 8)}... + + } /> + )} + {placement.job_owner_recruiter_id && ( + + {placement.job_owner_recruiter_id.slice(0, 8)}... + + } /> + )} +
+
+ )} + + {/* Failure info */} + {placement.failure_reason && ( +
+
+

Failure

+

{placement.failure_reason}

+

Failed {formatDate(placement.failed_at)}

+
+
+ )} +
+ + {/* Sidebar */} +
+ {/* Guarantee */} +
+
+

Guarantee Period

+
+
Days{placement.guarantee_days}
+
Expires{formatDate(placement.guarantee_expires_at)}
+ {guaranteeRemaining && ( +
+ Remaining + {guaranteeRemaining} +
+ )} +
+
+
+ + {/* Timeline */} +
+
+

Timeline

+
+
Hired{formatDate(placement.hired_at)}
+
Start Date{formatDate(placement.start_date)}
+
End Date{formatDate(placement.end_date)}
+
Created{formatDate(placement.created_at)}
+
+
+
+ + {/* System */} +
+
+

System

+
+

Placement ID

{placement.id}

+

Job ID

{placement.job_id}

+

Candidate ID

{placement.candidate_id}

+
+
+
+
+
+
+ ); +} diff --git a/apps/admin/src/app/secure/placements/components/placement-table.tsx b/apps/admin/src/app/secure/placements/components/placement-table.tsx index 02ceec4b1..cbe83ff13 100644 --- a/apps/admin/src/app/secure/placements/components/placement-table.tsx +++ b/apps/admin/src/app/secure/placements/components/placement-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { AdminDataTable, type Column } from '@/components/shared'; type Placement = { @@ -47,6 +48,7 @@ function formatFee(fee?: number) { } export function PlacementTable({ data, loading, sortField, sortDir, onSort }: PlacementTableProps) { + const router = useRouter(); const columns: Column[] = [ { key: 'candidate_name', @@ -105,6 +107,7 @@ export function PlacementTable({ data, loading, sortField, sortDir, onSort }: Pl sortField={sortField} sortDir={sortDir} onSort={onSort} + onRowClick={(item) => router.push(`/secure/placements/${item.id}`)} emptyTitle="No placements found" emptyDescription="No placements match the current filters." /> diff --git a/docs/admin/candidates.md b/docs/admin/candidates.md index a04ab342d..201a20f31 100644 --- a/docs/admin/candidates.md +++ b/docs/admin/candidates.md @@ -1,3 +1,5 @@ +**COMPLETED** + # Candidates Management ## Current State diff --git a/docs/admin/placements.md b/docs/admin/placements.md index 3d02c736f..99cd2234b 100644 --- a/docs/admin/placements.md +++ b/docs/admin/placements.md @@ -1,3 +1,5 @@ +**COMPLETED** + # Placements Management ## Current State diff --git a/services/ats-service/src/v3/admin/views/lists.repository.ts b/services/ats-service/src/v3/admin/views/lists.repository.ts index 59d8ea90c..c4e3f8b7d 100644 --- a/services/ats-service/src/v3/admin/views/lists.repository.ts +++ b/services/ats-service/src/v3/admin/views/lists.repository.ts @@ -67,6 +67,60 @@ export class AdminListsRepository { return { data: data || [], pagination: buildPagination(count || 0, page, limit) }; } + async getCandidateById(id: string): Promise { + const { data: candidate, error } = await this.supabase + .from('candidates') + .select('*') + .eq('id', id) + .single(); + + if (error) throw error; + + const [applications, recruiterRels] = await Promise.all([ + this.supabase + .from('applications') + .select('id, stage, job_id, job_title, company_name, created_at') + .eq('candidate_id', id) + .order('created_at', { ascending: false }) + .limit(20), + this.supabase + .from('recruiter_candidates') + .select('id, recruiter_id, status, candidate_name, created_at') + .eq('candidate_id', id) + .eq('status', 'active') + .limit(10), + ]); + + return { + ...candidate, + applications: applications.data ?? [], + recruiter_relationships: recruiterRels.data ?? [], + }; + } + + async getPlacementById(id: string): Promise { + const { data: placement, error } = await this.supabase + .from('placements') + .select('*, job:jobs(id, title, status), candidate:candidates(id, full_name, email, phone, location), company:companies(id, name, logo_url)') + .eq('id', id) + .single(); + + if (error) throw error; + return placement; + } + + async updatePlacement(id: string, updates: Record): Promise { + const { data, error } = await this.supabase + .from('placements') + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + + if (error) throw error; + return data; + } + async listCandidates(params: AdminListParams) { const { page, limit, offset } = paginate(params); const sortBy = params.sort_by || 'created_at'; diff --git a/services/ats-service/src/v3/admin/views/lists.route.ts b/services/ats-service/src/v3/admin/views/lists.route.ts index 65a53ef99..628419403 100644 --- a/services/ats-service/src/v3/admin/views/lists.route.ts +++ b/services/ats-service/src/v3/admin/views/lists.route.ts @@ -46,6 +46,15 @@ export function registerAdminListViews(app: FastifyInstance, supabase: SupabaseC return reply.send({ data }); }); + // GET /v3/admin/candidates/:id — detail with applications + app.get('/v3/admin/candidates/:id', { + schema: { params: idParamSchema }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + const data = await repository.getCandidateById(id); + return reply.send({ data }); + }); + // GET /v3/admin/candidates — flat list with search app.get('/v3/admin/candidates', { schema: { querystring: adminListQuerySchema }, @@ -64,6 +73,25 @@ export function registerAdminListViews(app: FastifyInstance, supabase: SupabaseC return reply.send(result); }); + // GET /v3/admin/placements/:id — detail with joins + app.get('/v3/admin/placements/:id', { + schema: { params: idParamSchema }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + const data = await repository.getPlacementById(id); + return reply.send({ data }); + }); + + // PATCH /v3/admin/placements/:id — update placement fields + app.patch('/v3/admin/placements/:id', { + schema: { params: idParamSchema }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + const updates = request.body as Record; + const data = await repository.updatePlacement(id, updates); + return reply.send({ data }); + }); + // GET /v3/admin/placements — flat list with search + state filter app.get('/v3/admin/placements', { schema: { querystring: adminPlacementsQuerySchema }, From 738bf2b8348903f5294dc8fb07c434fcb980ddff Mon Sep 17 00:00:00 2001 From: Brandon Korous Date: Sat, 11 Apr 2026 15:45:54 -0700 Subject: [PATCH 2/3] feat: add platform stats feature with API integration and update footer component --- apps/candidate/src/app/layout.tsx | 8 +- .../landing/sections/metrics-section.tsx | 103 ------------------ .../src/components/navigation/footer.tsx | 48 +++++--- apps/candidate/src/lib/platform-stats.ts | 24 ++++ .../src/jobs/marketplace-health.ts | 29 +++++ services/analytics-service/src/v3/routes.ts | 2 +- .../src/v3/stats/repository.ts | 36 +++++- .../analytics-service/src/v3/stats/routes.ts | 13 ++- .../analytics-service/src/v3/stats/service.ts | 6 +- .../analytics-service/src/v3/stats/types.ts | 8 ++ .../api-gateway/src/routes/v3/analytics.ts | 1 + ..._add_cumulative_platform_stats_columns.sql | 11 ++ 12 files changed, 159 insertions(+), 130 deletions(-) delete mode 100644 apps/candidate/src/components/landing/sections/metrics-section.tsx create mode 100644 apps/candidate/src/lib/platform-stats.ts create mode 100644 supabase/migrations/20260411000001_add_cumulative_platform_stats_columns.sql diff --git a/apps/candidate/src/app/layout.tsx b/apps/candidate/src/app/layout.tsx index 61f47aaf4..e6913fd3d 100644 --- a/apps/candidate/src/app/layout.tsx +++ b/apps/candidate/src/app/layout.tsx @@ -5,6 +5,7 @@ import { auth } from "@clerk/nextjs/server"; import Header from "@/components/navigation/header"; import Footer from "@/components/navigation/footer"; import { getHeaderNav, getFooterNav } from "@/lib/content"; +import { getPlatformStats } from "@/lib/platform-stats"; import CookieConsent from "@/components/cookie-consent"; import { ThemeScript, ThemeProvider } from "@splits-network/basel-ui"; import { DevDebugPanel } from "@/components/dev-debug-panel"; @@ -139,10 +140,11 @@ export default async function RootLayout({ }, }; - // Fetch CMS navigation data (ISR cached, 5 min) - const [headerNav, footerNav] = await Promise.all([ + // Fetch CMS navigation data (ISR cached, 5 min) + platform stats (ISR cached, 1 hr) + const [headerNav, footerNav, platformStats] = await Promise.all([ getHeaderNav(), getFooterNav(), + getPlatformStats(), ]); if (!publishableKey) { @@ -198,7 +200,7 @@ export default async function RootLayout({
{children}
-