diff --git a/apps/admin/src/app/secure/applications/[id]/components/application-overview.tsx b/apps/admin/src/app/secure/applications/[id]/components/application-overview.tsx new file mode 100644 index 000000000..d41ebdf31 --- /dev/null +++ b/apps/admin/src/app/secure/applications/[id]/components/application-overview.tsx @@ -0,0 +1,253 @@ +'use client'; + +import Link from 'next/link'; +import type { ApplicationDetail, ApplicationNote } from '../page'; + +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 formatDateTime(iso?: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +const NOTE_TYPE_BADGE: Record = { + stage_change: 'badge-info', + admin_note: 'badge-warning', + recruiter_note: 'badge-primary', + system: 'badge-ghost', + feedback: 'badge-accent', + pitch: 'badge-secondary', +}; + +function NoteTimeline({ notes }: { notes: ApplicationNote[] }) { + if (notes.length === 0) return

No notes yet.

; + + return ( +
+ {notes.map((note) => ( +
+
+
+
+ + {note.note_type.replace(/_/g, ' ')} + + {note.author_name && ( + {note.author_name} + )} + {formatDateTime(note.created_at)} +
+

{note.body}

+
+
+ ))} +
+ ); +} + +type Props = { app: ApplicationDetail }; + +export function ApplicationOverview({ app }: Props) { + const candidate = app.candidate; + const job = app.job; + + return ( +
+ {/* Main content */} +
+ {/* Candidate info */} +
+
+
+

Candidate

+ {candidate && ( + + View Candidates + + )} +
+ + + + + + {candidate.resume_status} + + ) : null + } /> +
+
+ + {/* Job info */} +
+
+
+

Job

+ {job && ( + + View Job + + )} +
+ + + {job.status} : null + } /> +
+
+ + {/* Cover letter */} + {app.cover_letter && ( +
+
+

Cover Letter

+

{app.cover_letter}

+
+
+ )} + + {/* Notes from various sources */} + {(app.recruiter_notes || app.internal_notes || app.candidate_notes) && ( +
+
+

Inline Notes

+ {app.recruiter_notes && ( +
+

Recruiter Notes

+

{app.recruiter_notes}

+
+ )} + {app.internal_notes && ( +
+

Internal Notes

+

{app.internal_notes}

+
+ )} + {app.candidate_notes && ( +
+

Candidate Notes

+

{app.candidate_notes}

+
+ )} +
+
+ )} + + {/* Application notes timeline */} +
+
+

+ Activity ({app.notes_list.length}) +

+ +
+
+
+ + {/* Sidebar */} +
+ {/* Application details */} +
+
+

Details

+
+
+ Source + {app.application_source ?? '—'} +
+
+ AI Reviewed + + {app.ai_reviewed ? 'Yes' : 'No'} + +
+ {app.salary && ( +
+ Salary + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(app.salary)} + +
+ )} +
+
+
+ + {/* Timeline */} +
+
+

Timeline

+
+
+ Created + {formatDate(app.created_at)} +
+
+ Submitted + {formatDate(app.submitted_at)} +
+
+ Accepted + {formatDate(app.accepted_at)} +
+
+ Hired + {formatDate(app.hired_at)} +
+
+ Updated + {formatDate(app.updated_at)} +
+
+
+
+ + {/* System */} +
+
+

System

+
+
+

Application ID

+

{app.id}

+
+
+

Candidate ID

+

{app.candidate_id}

+
+
+

Job ID

+

{app.job_id}

+
+ {app.candidate_recruiter_id && ( +
+

Recruiter ID

+

{app.candidate_recruiter_id}

+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/admin/src/app/secure/applications/[id]/components/application-stage-actions.tsx b/apps/admin/src/app/secure/applications/[id]/components/application-stage-actions.tsx new file mode 100644 index 000000000..7b8ea9c4d --- /dev/null +++ b/apps/admin/src/app/secure/applications/[id]/components/application-stage-actions.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useState } from 'react'; +import { useAuth } from '@clerk/nextjs'; +import { AdminApiClient } from '@/lib/api-client'; +import { AdminConfirmModal } from '@/components/shared'; +import { useAdminToast } from '@/hooks/use-admin-toast'; + +const STAGES = [ + { value: 'draft', label: 'Draft', group: 'Initial' }, + { value: 'ai_review', label: 'AI Review', group: 'AI' }, + { value: 'screen', label: 'Screen', group: 'Screening' }, + { value: 'submitted', label: 'Submitted', group: 'Screening' }, + { value: 'company_review', label: 'Company Review', group: 'Company' }, + { value: 'company_feedback', label: 'Company Feedback', group: 'Company' }, + { value: 'interview', label: 'Interview', group: 'Company' }, + { value: 'offer', label: 'Offer', group: 'Final' }, + { value: 'hired', label: 'Hired', group: 'Final' }, + { value: 'rejected', label: 'Rejected', group: 'Closed' }, + { value: 'withdrawn', label: 'Withdrawn', group: 'Closed' }, +]; + +type Props = { + applicationId: string; + currentStage: string; + onSuccess: () => void; +}; + +export function ApplicationStageActions({ applicationId, currentStage, onSuccess }: Props) { + const { getToken } = useAuth(); + const toast = useAdminToast(); + const [selectedStage, setSelectedStage] = useState(''); + const [confirming, setConfirming] = useState(false); + const [loading, setLoading] = useState(false); + + const availableStages = STAGES.filter(s => s.value !== currentStage); + + async function handleConfirm() { + if (!selectedStage) return; + setLoading(true); + try { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const client = new AdminApiClient(token); + await client.patch(`/ats/admin/applications/${applicationId}/stage`, { + stage: selectedStage, + }); + toast.success(`Stage changed to "${selectedStage.replace(/_/g, ' ')}"`); + setSelectedStage(''); + setConfirming(false); + onSuccess(); + } catch { + toast.error('Failed to change stage'); + } finally { + setLoading(false); + } + } + + return ( + <> +
+
+ + Change Stage +
+
    + {availableStages.map((stage) => ( +
  • + +
  • + ))} +
+
+ + {confirming && selectedStage && ( + { setConfirming(false); setSelectedStage(''); }} + onConfirm={handleConfirm} + title="Change Application Stage" + message={`Move this application from "${currentStage.replace(/_/g, ' ')}" to "${selectedStage.replace(/_/g, ' ')}"? This is an admin override and bypasses normal stage transition rules.`} + confirmLabel="Change Stage" + confirmVariant="warning" + loading={loading} + /> + )} + + ); +} diff --git a/apps/admin/src/app/secure/applications/[id]/page.tsx b/apps/admin/src/app/secure/applications/[id]/page.tsx new file mode 100644 index 000000000..238543e6a --- /dev/null +++ b/apps/admin/src/app/secure/applications/[id]/page.tsx @@ -0,0 +1,139 @@ +'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 } from '@/components/shared'; +import { ApplicationOverview } from './components/application-overview'; +import { ApplicationStageActions } from './components/application-stage-actions'; + +export type ApplicationNote = { + id: string; + note_type: string; + visibility: string; + body: string; + author_name: string | null; + created_at: string; +}; + +export type ApplicationDetail = { + id: string; + job_id: string; + candidate_id: string; + candidate_recruiter_id: string | null; + stage: string; + application_source: string | null; + ai_reviewed: boolean; + notes: string | null; + recruiter_notes: string | null; + candidate_notes: string | null; + internal_notes: string | null; + cover_letter: string | null; + salary: number | null; + candidate_name: string | null; + candidate_email: string | null; + job_title: string | null; + company_name: string | null; + submitted_at: string | null; + accepted_at: string | null; + hired_at: string | null; + created_at: string; + updated_at: string | null; + job: { + id: string; + title: string; + status: string; + company: { id: string; name: string } | null; + } | null; + candidate: { + id: string; + first_name: string | null; + last_name: string | null; + email: string | null; + phone: string | null; + location: string | null; + resume_status: string | null; + } | null; + notes_list: ApplicationNote[]; +}; + +const STAGE_BADGE: Record = { + draft: 'badge-ghost', ai_review: 'badge-info', gpt_review: 'badge-info', + ai_reviewed: 'badge-info', ai_failed: 'badge-error', + recruiter_request: 'badge-warning', recruiter_proposed: 'badge-warning', + recruiter_review: 'badge-warning', screen: 'badge-warning', + submitted: 'badge-info', company_review: 'badge-accent', + company_feedback: 'badge-accent', interview: 'badge-warning', + offer: 'badge-primary', hired: 'badge-success', + rejected: 'badge-error', withdrawn: 'badge-ghost', expired: 'badge-ghost', +}; + +export default function ApplicationDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { getToken } = useAuth(); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchApp = 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: any }>(`/ats/admin/applications/${id}`); + const d = (res as { data: any }).data; + // Map 'notes' array from backend (application_notes) to notes_list to avoid collision with the text notes field + setApp({ ...d, notes_list: d.notes ?? [] }); + } catch { + setError('Failed to load application'); + } finally { + setLoading(false); + } + }, [id, getToken]); + + useEffect(() => { void fetchApp(); }, [fetchApp]); + + if (loading) return
; + if (error || !app) return ( +
+ ); + + const candidateName = app.candidate + ? [app.candidate.first_name, app.candidate.last_name].filter(Boolean).join(' ') + : app.candidate_name ?? 'Unknown Candidate'; + + const jobTitle = app.job?.title ?? app.job_title ?? 'Unknown Job'; + + return ( +
+ + + + + {app.stage.replace(/_/g, ' ')} + + +
+ } + /> + + +
+ ); +} diff --git a/apps/admin/src/app/secure/applications/components/application-table.tsx b/apps/admin/src/app/secure/applications/components/application-table.tsx index 24d30bda9..939c8f885 100644 --- a/apps/admin/src/app/secure/applications/components/application-table.tsx +++ b/apps/admin/src/app/secure/applications/components/application-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { AdminDataTable, type Column } from '@/components/shared'; import { GenerateTailoredResumeButton } from './generate-tailored-resume-button'; @@ -55,6 +56,7 @@ function formatDate(iso: string) { } export function ApplicationTable({ data, loading, sortField, sortDir, onSort, onRefresh }: ApplicationTableProps) { + const router = useRouter(); const columns: Column[] = [ { key: 'candidate', @@ -118,6 +120,7 @@ export function ApplicationTable({ data, loading, sortField, sortDir, onSort, on sortField={sortField} sortDir={sortDir} onSort={onSort} + onRowClick={(item) => router.push(`/secure/applications/${item.id}`)} emptyTitle="No applications found" emptyDescription="No applications match the current filters." /> diff --git a/apps/admin/src/app/secure/jobs/[id]/components/job-candidates.tsx b/apps/admin/src/app/secure/jobs/[id]/components/job-candidates.tsx index c57383700..3ebf7ee2a 100644 --- a/apps/admin/src/app/secure/jobs/[id]/components/job-candidates.tsx +++ b/apps/admin/src/app/secure/jobs/[id]/components/job-candidates.tsx @@ -16,17 +16,56 @@ type JobApplication = { created_at: string; }; -function StatusBadge({ status }: { status: string }) { - const map: Record = { - active: 'badge-success', - submitted: 'badge-info', - reviewing: 'badge-warning', - rejected: 'badge-error', - withdrawn: 'badge-ghost', - hired: 'badge-success', - }; +const STAGE_BADGE: Record = { + draft: 'badge-ghost', + ai_review: 'badge-info', + gpt_review: 'badge-info', + ai_reviewed: 'badge-info', + ai_failed: 'badge-error', + recruiter_request: 'badge-warning', + recruiter_proposed: 'badge-warning', + recruiter_review: 'badge-warning', + screen: 'badge-warning', + submitted: 'badge-info', + company_review: 'badge-accent', + company_feedback: 'badge-accent', + interview: 'badge-warning', + offer: 'badge-primary', + hired: 'badge-success', + rejected: 'badge-error', + withdrawn: 'badge-ghost', + expired: 'badge-ghost', +}; + +const PIPELINE_GROUPS = [ + { label: 'AI Review', stages: ['ai_review', 'gpt_review', 'ai_reviewed', 'ai_failed'], color: 'bg-info' }, + { label: 'Recruiter', stages: ['recruiter_request', 'recruiter_proposed', 'recruiter_review'], color: 'bg-warning' }, + { label: 'Screening', stages: ['draft', 'screen', 'submitted'], color: 'bg-secondary' }, + { label: 'Company', stages: ['company_review', 'company_feedback'], color: 'bg-accent' }, + { label: 'Interview', stages: ['interview'], color: 'bg-warning' }, + { label: 'Offer', stages: ['offer'], color: 'bg-primary' }, + { label: 'Hired', stages: ['hired'], color: 'bg-success' }, + { label: 'Closed', stages: ['rejected', 'withdrawn', 'expired'], color: 'bg-error' }, +]; + +function StagePipeline({ stageCounts }: { stageCounts: Record }) { + const groups = PIPELINE_GROUPS.map(g => ({ + ...g, + count: g.stages.reduce((sum, s) => sum + (stageCounts[s] ?? 0), 0), + })).filter(g => g.count > 0); + + if (groups.length === 0) return null; + return ( - {status} +
+ {groups.map(g => ( +
+ + {g.label} + {g.count} +
+ ))} +
); } @@ -45,23 +84,22 @@ const COLUMNS: Column[] = [ Unknown ), }, - { - key: 'status', - label: 'Status', - render: (app) => , - }, { key: 'stage', label: 'Stage', - render: (app) => ( - - {app.stage?.replace(/_/g, ' ') ?? '—'} + sortable: true, + render: (app) => app.stage ? ( + + {app.stage.replace(/_/g, ' ')} + ) : ( + ), }, { key: 'created_at', label: 'Applied', + sortable: true, render: (app) => ( {new Date(app.created_at).toLocaleDateString()} @@ -70,10 +108,10 @@ const COLUMNS: Column[] = [ }, ]; -type Props = { jobId: string }; +type Props = { jobId: string; stageCounts: Record }; -export function JobCandidates({ jobId }: Props) { - const { data, loading, total, totalPages, page, goToPage } = useStandardList({ +export function JobCandidates({ jobId, stageCounts }: Props) { + const { data, loading, total, totalPages, page, goToPage, sortBy, sortOrder, handleSort } = useStandardList({ endpoint: '/ats/admin/applications', defaultFilters: { job_id: jobId }, defaultLimit: 25, @@ -82,8 +120,10 @@ export function JobCandidates({ jobId }: Props) { return (
+ +
-

{total} matched candidates

+

{total} total applications

@@ -91,6 +131,9 @@ export function JobCandidates({ jobId }: Props) { columns={COLUMNS} data={data} loading={loading} + sortField={sortBy} + sortDir={sortOrder} + onSort={handleSort} emptyTitle="No candidates" emptyDescription="No candidates have applied to this job." /> @@ -99,7 +142,7 @@ export function JobCandidates({ jobId }: Props) { {totalPages > 1 && (
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1).map((p) => ( diff --git a/apps/admin/src/app/secure/jobs/[id]/components/job-edit-modal.tsx b/apps/admin/src/app/secure/jobs/[id]/components/job-edit-modal.tsx new file mode 100644 index 000000000..f94caf6a1 --- /dev/null +++ b/apps/admin/src/app/secure/jobs/[id]/components/job-edit-modal.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useAuth } from '@clerk/nextjs'; +import { AdminApiClient } from '@/lib/api-client'; +import { useAdminToast } from '@/hooks/use-admin-toast'; +import type { JobDetail } from '../page'; + +const LEVEL_OPTIONS = ['', 'entry', 'mid', 'senior', 'lead', 'manager', 'director', 'vp', 'c_suite']; +const EMPLOYMENT_OPTIONS = ['', 'full_time', 'part_time', 'contract', 'temporary']; + +type Props = { + job: JobDetail; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +}; + +export function JobEditModal({ job, isOpen, onClose, onSuccess }: Props) { + const dialogRef = useRef(null); + const { getToken } = useAuth(); + const toast = useAdminToast(); + const [saving, setSaving] = useState(false); + + const [form, setForm] = useState({ + title: job.title ?? '', + description: job.description ?? '', + recruiter_description: job.recruiter_description ?? '', + location: job.location ?? '', + department: job.department ?? '', + job_level: job.job_level ?? '', + employment_type: job.employment_type ?? '', + salary_min: job.salary_min ?? '', + salary_max: job.salary_max ?? '', + fee_percentage: job.fee_percentage ?? '', + guarantee_days: job.guarantee_days ?? 90, + is_early_access: job.is_early_access, + is_priority: job.is_priority, + }); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (isOpen) dialog.showModal(); + else dialog.close(); + }, [isOpen]); + + useEffect(() => { + setForm({ + title: job.title ?? '', + description: job.description ?? '', + recruiter_description: job.recruiter_description ?? '', + location: job.location ?? '', + department: job.department ?? '', + job_level: job.job_level ?? '', + employment_type: job.employment_type ?? '', + salary_min: job.salary_min ?? '', + salary_max: job.salary_max ?? '', + fee_percentage: job.fee_percentage ?? '', + guarantee_days: job.guarantee_days ?? 90, + is_early_access: job.is_early_access, + is_priority: job.is_priority, + }); + }, [job]); + + async function handleSave() { + setSaving(true); + try { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const client = new AdminApiClient(token); + await client.patch(`/ats/admin/jobs/${job.id}`, { + title: form.title || null, + description: form.description || null, + recruiter_description: form.recruiter_description || null, + location: form.location || null, + department: form.department || null, + job_level: form.job_level || null, + employment_type: form.employment_type || null, + salary_min: form.salary_min ? Number(form.salary_min) : null, + salary_max: form.salary_max ? Number(form.salary_max) : null, + fee_percentage: form.fee_percentage ? Number(form.fee_percentage) : null, + guarantee_days: Number(form.guarantee_days), + is_early_access: form.is_early_access, + is_priority: form.is_priority, + }); + toast.success('Job updated'); + onSuccess(); + } catch { + toast.error('Failed to update job'); + } finally { + setSaving(false); + } + } + + return ( + +
+

Edit Job

+ +
+
+ Title + setForm(f => ({ ...f, title: e.target.value }))} /> +
+ +
+
+ Location + setForm(f => ({ ...f, location: e.target.value }))} /> +
+
+ Department + setForm(f => ({ ...f, department: e.target.value }))} /> +
+
+ +
+
+ Level + +
+
+ Employment Type + +
+
+ +
+
+ Salary Min + setForm(f => ({ ...f, salary_min: e.target.value }))} /> +
+
+ Salary Max + setForm(f => ({ ...f, salary_max: e.target.value }))} /> +
+
+ +
+
+ Fee % + setForm(f => ({ ...f, fee_percentage: e.target.value }))} /> +
+
+ Guarantee Days + setForm(f => ({ ...f, guarantee_days: Number(e.target.value) || 0 }))} /> +
+
+ +
+ Description +