Files
nextcraft/apps/web/components/admin/moderation-tabs.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

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',
});
}