Files
ai-job-search-app/src/app/page.tsx

450 lines
23 KiB
TypeScript

import { prisma } from "@/lib/db";
import Link from "next/link";
export const dynamic = "force-dynamic";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"] as const;
const STAGE_CONFIG: Record<
string,
{ label: string; description: string; color: string; badge: string; border: string; bgActive: string }
> = {
prospect: {
label: "Prospect",
description: "Saved roles",
color: "bg-zinc-400 dark:bg-zinc-500",
badge: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
border: "border-zinc-200/90 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50/90 dark:bg-zinc-800/50",
},
reviewing: {
label: "Reviewing",
description: "Evaluating fit",
color: "bg-amber-500",
badge: "bg-amber-50 text-amber-700 dark:bg-amber-950/70 dark:text-amber-300 border border-amber-200/60 dark:border-amber-900/60",
border: "border-amber-200/80 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-600",
bgActive: "bg-amber-50/60 dark:bg-amber-950/30",
},
tailoring: {
label: "Tailoring",
description: "Customizing CV",
color: "bg-purple-500",
badge: "bg-purple-50 text-purple-700 dark:bg-purple-950/70 dark:text-purple-300 border border-purple-200/60 dark:border-purple-900/60",
border: "border-purple-200/80 dark:border-purple-900/50 hover:border-purple-400 dark:hover:border-purple-600",
bgActive: "bg-purple-50/60 dark:bg-purple-950/30",
},
ready: {
label: "Ready",
description: "Ready to submit",
color: "bg-blue-500",
badge: "bg-blue-50 text-blue-700 dark:bg-blue-950/70 dark:text-blue-300 border border-blue-200/60 dark:border-blue-900/60",
border: "border-blue-200/80 dark:border-blue-900/50 hover:border-blue-400 dark:hover:border-blue-600",
bgActive: "bg-blue-50/60 dark:bg-blue-950/30",
},
applied: {
label: "Applied",
description: "Submitted",
color: "bg-indigo-500",
badge: "bg-indigo-50 text-indigo-700 dark:bg-indigo-950/70 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-900/60",
border: "border-indigo-200/80 dark:border-indigo-900/50 hover:border-indigo-400 dark:hover:border-indigo-600",
bgActive: "bg-indigo-50/60 dark:bg-indigo-950/30",
},
interviewing: {
label: "Interviewing",
description: "In discussions",
color: "bg-teal-500",
badge: "bg-teal-50 text-teal-700 dark:bg-teal-950/70 dark:text-teal-300 border border-teal-200/60 dark:border-teal-900/60",
border: "border-teal-200/80 dark:border-teal-900/50 hover:border-teal-400 dark:hover:border-teal-600",
bgActive: "bg-teal-50/60 dark:bg-teal-950/30",
},
offer: {
label: "Offer",
description: "Offer received",
color: "bg-emerald-500",
badge: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border border-emerald-200/60 dark:border-emerald-900/60",
border: "border-emerald-200/80 dark:border-emerald-900/50 hover:border-emerald-400 dark:hover:border-emerald-600",
bgActive: "bg-emerald-50/60 dark:bg-emerald-950/30",
},
rejected: {
label: "Rejected",
description: "Not selected",
color: "bg-rose-500",
badge: "bg-rose-50 text-rose-700 dark:bg-rose-950/70 dark:text-rose-300 border border-rose-200/60 dark:border-rose-900/60",
border: "border-rose-200/80 dark:border-rose-900/50 hover:border-rose-400 dark:hover:border-rose-600",
bgActive: "bg-rose-50/60 dark:bg-rose-950/30",
},
dead: {
label: "Dead",
description: "Inactive / Stale",
color: "bg-zinc-400",
badge: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200/60 dark:border-zinc-700/60",
border: "border-zinc-200/80 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50/70 dark:bg-zinc-800/40",
},
};
function getCompanyColor(name: string) {
const colors = [
"bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/80 dark:text-indigo-300 dark:border-indigo-800",
"bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950/80 dark:text-violet-300 dark:border-violet-800",
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800",
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800",
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800",
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/80 dark:text-rose-300 dark:border-rose-800",
"bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950/80 dark:text-teal-300 dark:border-teal-800",
"bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-950/80 dark:text-cyan-300 dark:border-cyan-800",
];
let hash = 0;
for (let i = 0; i < (name || "").length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function getInitials(name: string) {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export default async function Dashboard() {
const jobs = await prisma.job.findMany({
orderBy: { updatedAt: "desc" },
take: 6,
});
const totalJobsCount = await prisma.job.count();
const allJobs = await prisma.job.findMany({ select: { status: true, phEligibility: true } });
const apps = await prisma.application.count();
const resumes = await prisma.resume.count();
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, allJobs.filter((j) => j.status === s).length]));
const phYes = allJobs.filter((j) => j.phEligibility === "yes").length;
const activeCount =
(byStatus.reviewing ?? 0) +
(byStatus.tailoring ?? 0) +
(byStatus.ready ?? 0) +
(byStatus.applied ?? 0) +
(byStatus.interviewing ?? 0) +
(byStatus.offer ?? 0);
const activePercent = totalJobsCount > 0 ? Math.round((activeCount / totalJobsCount) * 100) : 0;
const statCards = [
{
label: "Total Jobs Tracked",
value: totalJobsCount,
sub: "Pipeline opportunities",
gradient: "from-indigo-500/[0.08] via-indigo-500/[0.02] to-transparent dark:from-indigo-500/[0.14] dark:to-transparent",
iconBorder: "border-indigo-200/80 dark:border-indigo-800/60 bg-indigo-50/80 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-400",
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
</svg>
),
},
{
label: "PH-Eligible Roles",
value: phYes,
sub: `${totalJobsCount > 0 ? Math.round((phYes / totalJobsCount) * 100) : 0}% of all tracked positions`,
gradient: "from-emerald-500/[0.08] via-emerald-500/[0.02] to-transparent dark:from-emerald-500/[0.14] dark:to-transparent",
iconBorder: "border-emerald-200/80 dark:border-emerald-800/60 bg-emerald-50/80 dark:bg-emerald-950/60 text-emerald-600 dark:text-emerald-400",
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
label: "Tailored Resumes",
value: resumes,
sub: "AI-customized CV documents",
gradient: "from-purple-500/[0.08] via-purple-500/[0.02] to-transparent dark:from-purple-500/[0.14] dark:to-transparent",
iconBorder: "border-purple-200/80 dark:border-purple-800/60 bg-purple-50/80 dark:bg-purple-950/60 text-purple-600 dark:text-purple-400",
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
),
},
{
label: "Applications Sent",
value: apps,
sub: "Logged submissions",
gradient: "from-blue-500/[0.08] via-blue-500/[0.02] to-transparent dark:from-blue-500/[0.14] dark:to-transparent",
iconBorder: "border-blue-200/80 dark:border-blue-800/60 bg-blue-50/80 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400",
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
),
},
];
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
{/* LEFT COLUMN (2/3 width) */}
<div className="lg:col-span-2 space-y-8">
{/* Large Bold Greeting Heading */}
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border border-emerald-200/60 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Pipeline Live
</span>
<span className="text-xs text-zinc-400 dark:text-zinc-500">·</span>
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
{activeCount} active opportunities
</span>
</div>
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-zinc-900 dark:text-white">
Good morning
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 max-w-xl leading-relaxed">
Monitor active hiring stages, manage candidate positioning, and tailor resumes with AI across your job search pipeline.
</p>
</div>
{/* 2x2 Stat Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{statCards.map((card) => (
<div
key={card.label}
className={`relative overflow-hidden rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 bg-gradient-to-b ${card.gradient} p-5 shadow-2xs hover:shadow-md hover:border-zinc-300 dark:hover:border-zinc-700 transition-all duration-200 group`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{card.label}
</span>
<div className={`flex h-9 w-9 items-center justify-center rounded-xl border ${card.iconBorder} group-hover:scale-105 transition-transform`}>
{card.icon}
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-3xl font-extrabold tracking-tight text-zinc-900 dark:text-white">
{card.value}
</span>
</div>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{card.sub}
</p>
</div>
))}
</div>
{/* Horizontal Pipeline Section with Stepper-Style Funnel Bar */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-2xs space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">Application Pipeline</h2>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold bg-indigo-50 text-indigo-700 dark:bg-indigo-950/70 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-800/60">
{activePercent}% In Funnel
</span>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
Horizontal funnel flow across active stages
</p>
</div>
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
<span>{totalJobsCount} Total</span>
<span>·</span>
<span className="text-indigo-600 dark:text-indigo-400 font-semibold">{activeCount} Active</span>
</div>
</div>
{/* Stepper-Style Horizontal Funnel Segment Bar */}
<div className="space-y-3">
<div className="flex items-center gap-1.5 w-full bg-zinc-100 dark:bg-zinc-800/80 p-1.5 rounded-2xl">
{STATUSES.map((s) => {
const count = byStatus[s] ?? 0;
const config = STAGE_CONFIG[s];
const widthPercent = totalJobsCount > 0 ? (count / totalJobsCount) * 100 : 100 / STATUSES.length;
const minWidth = count > 0 ? Math.max(widthPercent, 7) : 4;
return (
<Link
key={s}
href={`/jobs?status=${s}`}
title={`${config.label}: ${count} jobs`}
style={{ flexGrow: count > 0 ? minWidth : 1 }}
className={`group/bar relative h-7 rounded-lg transition-all duration-200 flex items-center justify-center text-[11px] font-bold ${
count > 0
? `${config.color} text-white shadow-xs hover:brightness-110`
: "bg-zinc-200/70 dark:bg-zinc-700/50 text-zinc-400 dark:text-zinc-500 hover:bg-zinc-300 dark:hover:bg-zinc-700"
}`}
>
{count > 0 && (
<span className="drop-shadow-xs truncate px-1">
{count}
</span>
)}
</Link>
);
})}
</div>
{/* Funnel Phase Labels */}
<div className="flex items-center justify-between text-[11px] text-zinc-400 dark:text-zinc-500 px-1 font-medium">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
<span>Sourcing & Prep ({ (byStatus.prospect ?? 0) + (byStatus.reviewing ?? 0) + (byStatus.tailoring ?? 0) })</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-indigo-500" />
<span>Active Funnel ({ (byStatus.ready ?? 0) + (byStatus.applied ?? 0) + (byStatus.interviewing ?? 0) })</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span>Outcomes ({ (byStatus.offer ?? 0) + (byStatus.rejected ?? 0) + (byStatus.dead ?? 0) })</span>
</div>
</div>
</div>
{/* Interactive Stage Overview Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5 pt-2">
{STATUSES.map((s) => {
const count = byStatus[s] ?? 0;
const config = STAGE_CONFIG[s];
const hasItems = count > 0;
return (
<Link
key={s}
href={`/jobs?status=${s}`}
className={`group relative flex items-center justify-between p-3 rounded-xl border transition-all duration-150 ${
config.border
} ${
hasItems
? `${config.bgActive} shadow-2xs hover:shadow-sm hover:-translate-y-0.5`
: "bg-zinc-50/50 dark:bg-zinc-900/40 opacity-70 hover:opacity-100 hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${config.color}`} />
<div className="min-w-0">
<div className="text-xs font-bold text-zinc-900 dark:text-zinc-100 capitalize group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate">
{config.label}
</div>
<div className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate">
{config.description}
</div>
</div>
</div>
<span
className={`inline-flex items-center justify-center min-w-[22px] h-5 px-1.5 rounded-full text-xs font-bold shrink-0 ${
hasItems ? config.badge : "text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800"
}`}
>
{count}
</span>
</Link>
);
})}
</div>
</div>
</div>
{/* RIGHT COLUMN (1/3 width, Sticky Sidebar) */}
<div className="lg:col-span-1 space-y-6 lg:sticky lg:top-8 self-start">
{/* "Quick Add" Card */}
<div className="relative overflow-hidden rounded-2xl border border-indigo-200/80 dark:border-indigo-900/60 bg-gradient-to-b from-indigo-50/90 via-white to-white dark:from-indigo-950/40 dark:via-zinc-900 dark:to-zinc-900 p-6 shadow-2xs space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-md shadow-indigo-500/25 shrink-0">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">Quick Add Job</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">AI-powered parser</p>
</div>
</div>
<p className="text-xs text-zinc-600 dark:text-zinc-300 leading-relaxed">
Paste a listing URL or raw job description. JobFlow AI extracts titles, compensation, PH eligibility, and requirements automatically.
</p>
<Link
href="/jobs/new"
className="flex items-center justify-center gap-2 w-full py-3 px-4 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 via-indigo-600 to-violet-600 hover:from-indigo-500 hover:via-indigo-500 hover:to-violet-500 shadow-md shadow-indigo-500/25 active:scale-98 transition-all"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>+ Add New Position</span>
</Link>
</div>
{/* Compact Activity Feed */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 shadow-2xs space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-zinc-100 dark:border-zinc-800/80">
<div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-white">Recent Activity</h3>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">Latest pipeline updates</p>
</div>
<Link
href="/jobs"
className="text-xs font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 transition-colors"
>
View all ({totalJobsCount}) →
</Link>
</div>
{jobs.length === 0 ? (
<div className="py-8 text-center">
<p className="text-xs text-zinc-500 dark:text-zinc-400">No jobs tracked yet.</p>
<Link
href="/jobs/new"
className="text-xs font-semibold text-indigo-600 dark:text-indigo-400 hover:underline mt-1 inline-block"
>
Add your first job →
</Link>
</div>
) : (
<div className="divide-y divide-zinc-100 dark:divide-zinc-800/80">
{jobs.map((j) => {
const companyColor = getCompanyColor(j.company);
const initials = getInitials(j.company);
const stage = STAGE_CONFIG[j.status] || STAGE_CONFIG.prospect;
return (
<Link
key={j.id}
href={`/jobs/${j.id}`}
className="py-3 first:pt-1 last:pb-1 flex items-center justify-between gap-3 group hover:bg-zinc-50/80 dark:hover:bg-zinc-800/40 px-2 -mx-2 rounded-xl transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl font-bold text-[11px] border shadow-2xs ${companyColor}`}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-xs text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate">
{j.title}
</div>
<div className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate mt-0.5">
{j.company} {j.location ? `· ${j.location}` : ""}
</div>
</div>
</div>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold capitalize shrink-0 ${stage.badge}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${stage.color}`} />
{j.status}
</span>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
);
}