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)
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import {
|
|
CheckCircle,
|
|
FileText,
|
|
Flag,
|
|
Shield,
|
|
XCircle,
|
|
Building2,
|
|
} from 'lucide-react';
|
|
import { Badge } from '@nextcraft/ui';
|
|
import {
|
|
employerVerificationQueue,
|
|
flaggedContentQueue,
|
|
jobReviewQueue,
|
|
} from '@nextcraft/mock-data';
|
|
import type {
|
|
EmployerVerificationItem,
|
|
FlaggedContentItem,
|
|
JobReviewItem,
|
|
} from '@nextcraft/mock-data';
|
|
|
|
type TabId = 'jobs' | 'employers' | 'flagged';
|
|
|
|
const TABS: Array<{ id: TabId; label: string }> = [
|
|
{ id: 'jobs', label: 'Job Posting Review' },
|
|
{ id: 'employers', label: 'Employer Verification' },
|
|
{ id: 'flagged', label: 'Flagged Content' },
|
|
];
|
|
|
|
const STATUS_VARIANT = { ok: 'success', warn: 'warning', err: 'error' } as const;
|
|
|
|
export function ModerationTabs() {
|
|
const [tab, setTab] = useState<TabId>('jobs');
|
|
|
|
const counts: Record<TabId, number> = {
|
|
jobs: jobReviewQueue.length,
|
|
employers: employerVerificationQueue.length,
|
|
flagged: flaggedContentQueue.length,
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
{/* Tab bar */}
|
|
<div className="flex flex-wrap gap-1 rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900">
|
|
{TABS.map((t) => {
|
|
const active = t.id === tab;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => setTab(t.id)}
|
|
className={`inline-flex items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors ${
|
|
active
|
|
? 'bg-primary-600 text-white shadow-sm'
|
|
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'
|
|
}`}
|
|
aria-pressed={active}
|
|
>
|
|
{t.label}
|
|
<span
|
|
className={`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold ${
|
|
active
|
|
? 'bg-white/20 text-white'
|
|
: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
|
|
}`}
|
|
>
|
|
{counts[t.id]}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{tab === 'jobs' && <JobReviewTab />}
|
|
{tab === 'employers' && <EmployerVerificationTab />}
|
|
{tab === 'flagged' && <FlaggedContentTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 1: Job Posting Review
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function JobReviewTab() {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{jobReviewQueue.map((item) => (
|
|
<JobReviewCard key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JobReviewCard({ item }: { item: JobReviewItem }) {
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
|
<FileText className="h-5 w-5" />
|
|
</span>
|
|
<div className="flex flex-col gap-0.5">
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
{item.title}
|
|
</h3>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{item.employerName} · {item.stackName}
|
|
</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{item.location} · Submitted {formatDate(item.submittedAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
>
|
|
Review
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
|
|
<ActionButton tone="success" icon={<CheckCircle className="h-3.5 w-3.5" />}>
|
|
Approve
|
|
</ActionButton>
|
|
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
|
|
Reject
|
|
</ActionButton>
|
|
<ActionButton tone="warning" icon={<Flag className="h-3.5 w-3.5" />}>
|
|
Flag
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 2: Employer Verification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function EmployerVerificationTab() {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{employerVerificationQueue.map((item) => (
|
|
<EmployerVerificationCard key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmployerVerificationCard({ item }: { item: EmployerVerificationItem }) {
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200">
|
|
{item.logoInitials}
|
|
</span>
|
|
<div className="flex flex-col gap-0.5">
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
{item.name}
|
|
</h3>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{item.industry} · {item.location}
|
|
</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
Submitted {formatDate(item.submittedAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
>
|
|
Review Documents
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
|
|
<ActionButton tone="success" icon={<CheckCircle className="h-3.5 w-3.5" />}>
|
|
Verify
|
|
</ActionButton>
|
|
<ActionButton tone="info" icon={<Building2 className="h-3.5 w-3.5" />}>
|
|
Request More Info
|
|
</ActionButton>
|
|
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
|
|
Reject
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 3: Flagged Content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function FlaggedContentTab() {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{flaggedContentQueue.map((item) => (
|
|
<FlaggedContentCard key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FlaggedContentCard({ item }: { item: FlaggedContentItem }) {
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">
|
|
<Shield className="h-5 w-5" />
|
|
</span>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
{item.title}
|
|
</h3>
|
|
<Badge variant="warning">{item.contentType}</Badge>
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
Flagged by {item.flaggedBy} · {item.reason}
|
|
</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{formatDate(item.flaggedAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
|
|
<ActionButton tone="neutral" icon={<XCircle className="h-3.5 w-3.5" />}>
|
|
Dismiss Flag
|
|
</ActionButton>
|
|
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
|
|
Remove Content
|
|
</ActionButton>
|
|
<ActionButton tone="warning" icon={<Flag className="h-3.5 w-3.5" />}>
|
|
Warn User
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared action button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ActionTone = 'success' | 'error' | 'warning' | 'info' | 'neutral';
|
|
|
|
const ACTION_CLASSES: Record<ActionTone, string> = {
|
|
success: 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 dark:hover:bg-emerald-900/50',
|
|
error: 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-800 dark:bg-rose-900/30 dark:text-rose-300 dark:hover:bg-rose-900/50',
|
|
warning: 'border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50',
|
|
info: 'border-primary-300 bg-primary-50 text-primary-700 hover:bg-primary-100 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50',
|
|
neutral: 'border-slate-300 bg-slate-50 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
|
|
};
|
|
|
|
function ActionButton({
|
|
tone,
|
|
icon,
|
|
children,
|
|
}: {
|
|
tone: ActionTone;
|
|
icon: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors ${ACTION_CLASSES[tone]}`}
|
|
>
|
|
{icon}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
} |