810485ecc8
---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)
343 lines
14 KiB
TypeScript
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>
|
|
);
|
|
} |