Files
nextcraft/apps/web/components/employer/posting-manager.tsx
T
CIAgent 810485ecc8 docs(milestone): complete v0.1-nextcraft-ui-prototype
---ci---
phase: 7
milestone: v0.1
status: complete
requirements:
  covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028]
  partial: []
---/ci---

Milestone v0.1 (nextcraft-ui-prototype) complete.

Summary:
- 7 phases (P0 pre-execution + P1-P6 execution + P7 final review)
- 28 requirements covered (all complete)
- 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4)
- 19 routes, 104 TypeScript files
- Shared component library (5 primitives + design tokens)
- Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers
- Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font
- Storybook with 6 stories
- Dark mode, breadcrumbs, role switcher, responsive design
- Build passes, typecheck passes, Storybook build passes

Phases:
  P0 pre-execution     → v0.0.1
  P1 project-scaffolding → v0.0.2
  P2 learner-surface   → v0.0.3
  P3 marketplace-surface → v0.0.4
  P4 employer-dashboard → v0.0.5
  P5 admin-surface     → v0.0.6
  P6 polish-integration → v0.0.7
  P7 final-review-ship → v0.1.0 (milestone release)
2026-09-10 22:47:56 +00:00

343 lines
14 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Plus, Trash2, Save } from 'lucide-react';
import { Badge, Button } from '@nextcraft/ui';
import type { Job, Seniority } from '@nextcraft/types';
import { formatSalaryK, relativeTime, seniorityLabel } from '../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock posting list (status + applicant count derived from the Job) */
/* -------------------------------------------------------------------------- */
type PostingStatus = 'active' | 'draft' | 'expired';
interface PostingRow {
job: Job;
status: PostingStatus;
applicants: number;
}
interface ApplicantRow {
name: string;
appliedAt: string;
stage: 'Screening' | 'Interview' | 'Offer';
matchScore: number;
}
/* Deterministically derive a posting status per job id. */
function deriveStatus(job: Job, idx: number): PostingStatus {
if (idx < 8) return 'active';
if (idx < 11) return 'draft';
return 'expired';
}
function deriveApplicants(job: Job, idx: number): number {
return 4 + ((job.matchScore + idx * 7) % 18);
}
function deriveApplicantList(job: Job): ApplicantRow[] {
const names = [
'Maya Okonkwo',
'Devon Park',
'Priya Iyer',
'Tomás Vega',
'Liam Chen',
'Yuki Tanaka',
'Sofia Marchetti',
];
const stages: ApplicantRow['stage'][] = ['Screening', 'Interview', 'Offer'];
const count = 5 + (job.matchScore % 2);
const rows: ApplicantRow[] = [];
for (let i = 0; i < count; i++) {
const stage = stages[i % stages.length];
rows.push({
name: names[(job.matchScore + i) % names.length],
appliedAt: new Date(Date.UTC(2026, 7, 28 - i * 2)).toISOString(),
stage,
matchScore: 78 + ((job.matchScore + i * 3) % 18),
});
}
return rows;
}
/* -------------------------------------------------------------------------- */
/* Status badge */
/* -------------------------------------------------------------------------- */
function StatusBadge({ status }: { status: PostingStatus }) {
if (status === 'active') return <Badge variant="success">Active</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Expired</Badge>;
}
function StageBadge({ stage }: { stage: ApplicantRow['stage'] }) {
if (stage === 'Offer') return <Badge variant="success">Offer</Badge>;
if (stage === 'Interview') return <Badge variant="info">Interview</Badge>;
return <Badge variant="warning">Screening</Badge>;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface PostingManagerProps {
jobs: Job[];
}
export function PostingManager({ jobs }: PostingManagerProps) {
const postings: PostingRow[] = jobs.map((job, idx) => ({
job,
status: deriveStatus(job, idx),
applicants: deriveApplicants(job, idx),
}));
const [selectedId, setSelectedId] = useState<string>(postings[0]?.job.id ?? '');
const selected = postings.find((p) => p.job.id === selectedId) ?? null;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
<Button size="sm" variant="primary" icon={<Plus className="h-3.5 w-3.5" />} type="button">
New
</Button>
</div>
<div className="flex flex-col gap-2">
{postings.map((p) => {
const active = p.job.id === selectedId;
return (
<button
key={p.job.id}
type="button"
onClick={() => setSelectedId(p.job.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.job.title}
</span>
<StatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{relativeTime(p.job.postedAt)}</span>
<span>·</span>
<span>{p.applicants} applicants</span>
</div>
</button>
);
})}
</div>
</div>
{/* Right panel — detail */}
<div>
{selected ? (
<PostingDetail posting={selected} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
);
}
function PostingDetail({ posting }: { posting: PostingRow }) {
const { job, applicants } = posting;
const applicantList = deriveApplicantList(job);
const seniorityOptions: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
const inputClass =
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
return (
<div className="flex flex-col gap-6">
{/* Posting form */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Edit Posting
</h2>
<StatusBadge status={posting.status} />
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Title</label>
<input className={inputClass} defaultValue={job.title} aria-label="Posting title" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Description</label>
<textarea
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
defaultValue={job.description}
aria-label="Posting description"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Required Competencies
</label>
<div className="flex flex-wrap gap-2">
{job.requiredCompetencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Seniority</label>
<select className={inputClass} defaultValue={job.seniority} aria-label="Seniority">
{seniorityOptions.map((s) => (
<option key={s} value={s}>
{seniorityLabel(s)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Location</label>
<input className={inputClass} defaultValue={job.location} aria-label="Location" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Min
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMin)}
aria-label="Salary minimum"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Max
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMax)}
aria-label="Salary maximum"
/>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
<span>Remote eligible</span>
<button
type="button"
role="switch"
aria-checked={job.remote}
className={[
'relative h-6 w-11 rounded-full transition-colors',
job.remote ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
job.remote ? 'translate-x-5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</label>
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="primary" size="md" icon={<Save className="h-4 w-4" />} type="button">
Save Changes
</Button>
<Button variant="destructive" size="md" icon={<Trash2 className="h-4 w-4" />} type="button">
Delete Posting
</Button>
</div>
</div>
</div>
{/* Applicant list */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicants
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{applicants} total · {applicantList.length} shown
</span>
</div>
{/* Table on desktop, cards on mobile */}
<div className="overflow-x-auto">
<table className="hidden w-full md:table">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="pb-2 pr-4 font-medium">Applicant</th>
<th className="pb-2 pr-4 font-medium">Applied</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Match</th>
</tr>
</thead>
<tbody>
{applicantList.map((a, i) => (
<tr key={i} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="py-2.5 pr-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{a.name}
</td>
<td className="py-2.5 pr-4 text-sm text-slate-600 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</td>
<td className="py-2.5 pr-4"><StageBadge stage={a.stage} /></td>
<td className="py-2.5 pr-4 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</td>
</tr>
))}
</tbody>
</table>
{/* Mobile cards */}
<div className="flex flex-col gap-2 md:hidden">
{applicantList.map((a, i) => (
<div
key={i}
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{a.name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</span>
</div>
<div className="flex items-center gap-2">
<StageBadge stage={a.stage} />
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}