Redesign UI: modern SaaS dashboard styling with indigo/violet accents

This commit is contained in:
Jonathan Sykes
2026-08-16 19:32:21 +08:00
parent 9ba9ae675a
commit 5e9084b64e
11 changed files with 2113 additions and 312 deletions

View File

@@ -5,43 +5,309 @@ export const dynamic = "force-dynamic";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
const STAGE_CONFIG: Record<
string,
{ label: string; description: string; color: string; badge: string; border: string; bgActive: string }
> = {
prospect: {
label: "Prospect",
description: "Saved positions",
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 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50 dark:bg-zinc-800/40",
},
reviewing: {
label: "Reviewing",
description: "Evaluating fit",
color: "bg-amber-500",
badge: "bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300",
border: "border-amber-200/80 dark:border-amber-900/60 hover:border-amber-400 dark:hover:border-amber-600",
bgActive: "bg-amber-50/50 dark:bg-amber-950/20",
},
tailoring: {
label: "Tailoring",
description: "Customizing resume",
color: "bg-purple-500",
badge: "bg-purple-100 text-purple-800 dark:bg-purple-950/80 dark:text-purple-300",
border: "border-purple-200/80 dark:border-purple-900/60 hover:border-purple-400 dark:hover:border-purple-600",
bgActive: "bg-purple-50/50 dark:bg-purple-950/20",
},
ready: {
label: "Ready",
description: "Ready to submit",
color: "bg-blue-500",
badge: "bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300",
border: "border-blue-200/80 dark:border-blue-900/60 hover:border-blue-400 dark:hover:border-blue-600",
bgActive: "bg-blue-50/50 dark:bg-blue-950/20",
},
applied: {
label: "Applied",
description: "Application sent",
color: "bg-indigo-500",
badge: "bg-indigo-100 text-indigo-800 dark:bg-indigo-950/80 dark:text-indigo-300",
border: "border-indigo-200/80 dark:border-indigo-900/60 hover:border-indigo-400 dark:hover:border-indigo-600",
bgActive: "bg-indigo-50/50 dark:bg-indigo-950/20",
},
interviewing: {
label: "Interviewing",
description: "In discussions",
color: "bg-teal-500",
badge: "bg-teal-100 text-teal-800 dark:bg-teal-950/80 dark:text-teal-300",
border: "border-teal-200/80 dark:border-teal-900/60 hover:border-teal-400 dark:hover:border-teal-600",
bgActive: "bg-teal-50/50 dark:bg-teal-950/20",
},
offer: {
label: "Offer",
description: "Offer received",
color: "bg-emerald-500",
badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-300",
border: "border-emerald-200/80 dark:border-emerald-900/60 hover:border-emerald-400 dark:hover:border-emerald-600",
bgActive: "bg-emerald-50/50 dark:bg-emerald-950/20",
},
rejected: {
label: "Rejected",
description: "Not selected",
color: "bg-rose-500",
badge: "bg-rose-100 text-rose-800 dark:bg-rose-950/80 dark:text-rose-300",
border: "border-rose-200/80 dark:border-rose-900/60 hover:border-rose-400 dark:hover:border-rose-600",
bgActive: "bg-rose-50/50 dark:bg-rose-950/20",
},
dead: {
label: "Dead",
description: "Closed / Inactive",
color: "bg-zinc-400",
badge: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
border: "border-zinc-200/80 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50 dark:bg-zinc-800/30",
},
};
export default async function Dashboard() {
const jobs = await prisma.job.findMany();
const jobs = await prisma.job.findMany({
orderBy: { updatedAt: "desc" },
take: 5,
});
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, jobs.filter((j) => j.status === s).length]));
const phYes = jobs.filter((j) => j.phEligibility === "yes").length;
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 statCards = [
{
label: "Total Jobs Tracked",
value: totalJobsCount,
sub: "All pipeline opportunities",
icon: (
<svg className="w-5 h-5 text-indigo-600 dark:text-indigo-400" 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>
),
iconBg: "bg-indigo-50 dark:bg-indigo-950/60 border-indigo-100 dark:border-indigo-900/50",
},
{
label: "PH-Eligible Roles",
value: phYes,
sub: `${totalJobsCount > 0 ? Math.round((phYes / totalJobsCount) * 100) : 0}% of all positions`,
icon: (
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" 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>
),
iconBg: "bg-emerald-50 dark:bg-emerald-950/60 border-emerald-100 dark:border-emerald-900/50",
},
{
label: "Tailored Resumes",
value: resumes,
sub: "AI-customized versions",
icon: (
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" 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>
),
iconBg: "bg-purple-50 dark:bg-purple-950/60 border-purple-100 dark:border-purple-900/50",
},
{
label: "Applications Sent",
value: apps,
sub: "Active submissions",
icon: (
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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>
),
iconBg: "bg-blue-50 dark:bg-blue-950/60 border-blue-100 dark:border-blue-900/50",
},
];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
["Total jobs", jobs.length],
["PH-eligible", phYes],
["Resumes", resumes],
["Applications", apps],
].map(([label, n]) => (
<div key={label as string} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
<div className="text-3xl font-bold">{n as number}</div>
<div className="text-sm text-zinc-500">{label as string}</div>
<div className="space-y-8">
{/* Header & Quick Action */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-2 border-b border-zinc-200/70 dark:border-zinc-800/70">
<div>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Dashboard</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Track applications, monitor your hiring pipeline, and tailor resumes with AI.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/jobs"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium text-zinc-700 dark:text-zinc-200 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/60 shadow-xs transition-all"
>
<svg className="w-4 h-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
<span>View all jobs</span>
</Link>
<Link
href="/jobs/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 active:scale-98 transition-all"
>
<svg className="w-4 h-4" 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>
<span>Add Job (AI parse)</span>
</Link>
</div>
</div>
{/* Stat Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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 p-5 shadow-xs transition-all hover:shadow-md hover:border-zinc-300 dark:hover:border-zinc-700"
>
<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.iconBg}`}>
{card.icon}
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-3xl font-bold 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>
<div>
<h2 className="font-semibold mb-2">Pipeline</h2>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Link key={s} href={`/jobs?status=${s}`} className="rounded-full border border-zinc-300 dark:border-zinc-700 px-3 py-1 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">
{s} <span className="font-bold">{byStatus[s]}</span>
</Link>
))}
{/* Visual Pipeline Section */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h2 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">Application Pipeline</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Click any stage to filter jobs by status</p>
</div>
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
{totalJobsCount} total opportunities
</div>
</div>
{/* Pipeline Stage Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-9 gap-2.5">
{STATUSES.map((s) => {
const count = byStatus[s] ?? 0;
const config = STAGE_CONFIG[s] || {
label: s,
description: "",
color: "bg-zinc-400",
badge: "bg-zinc-100 text-zinc-700",
border: "border-zinc-200",
bgActive: "bg-zinc-50",
};
const hasItems = count > 0;
return (
<Link
key={s}
href={`/jobs?status=${s}`}
className={`group flex flex-col justify-between p-3 rounded-xl border transition-all ${
config.border
} ${
hasItems
? `${config.bgActive} shadow-xs hover:shadow-sm hover:-translate-y-0.5`
: "bg-zinc-50/50 dark:bg-zinc-900/40 opacity-75 hover:opacity-100"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`w-2 h-2 rounded-full ${config.color}`} />
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold ${
hasItems ? config.badge : "text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800"
}`}
>
{count}
</span>
</div>
<div>
<div className="text-xs font-semibold text-zinc-800 dark:text-zinc-200 capitalize group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{config.label}
</div>
<div className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate mt-0.5">
{config.description}
</div>
</div>
</Link>
);
})}
</div>
</div>
<div className="flex gap-3">
<Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job (AI parse)</Link>
<Link href="/jobs" className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">View all jobs</Link>
</div>
{/* Recent Jobs Preview */}
{jobs.length > 0 && (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">Recently Updated Jobs</h2>
<Link href="/jobs" className="text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300">
View all ({totalJobsCount}) →
</Link>
</div>
<div className="divide-y divide-zinc-100 dark:divide-zinc-800/80">
{jobs.map((j) => (
<div key={j.id} className="py-3 flex items-center justify-between gap-4 first:pt-0 last:pb-0 group">
<div className="min-w-0 flex-1">
<Link
href={`/jobs/${j.id}`}
className="font-medium text-sm text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate block"
>
{j.title}
</Link>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{j.company} · {j.location ?? "Remote"}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
STAGE_CONFIG[j.status]?.badge ?? "bg-zinc-100 text-zinc-700"
}`}
>
{j.status}
</span>
<Link
href={`/jobs/${j.id}`}
className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}