Files
ai-job-search-app/src/app/jobs/[id]/page.tsx

315 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import JobActions from "./job-actions";
export const dynamic = "force-dynamic";
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();
}
function formatCompensation(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
if (!j.payMin && !j.payMax) return null;
const cur = j.payCurrency === "PHP" ? "₱" : "$";
const per = j.payPeriod === "hour" ? "/hr" : j.payPeriod === "month" ? "/mo" : j.payPeriod === "year" ? "/yr" : "";
const fmt = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
return `${cur}${fmt(j.payMin ?? 0)}${j.payMax ? ` – ${fmt(j.payMax)}` : ""}${per}`;
}
export default async function JobDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const job = await prisma.job.findUnique({
where: { id },
include: {
resumes: { orderBy: { updatedAt: "desc" } },
coverLetters: { orderBy: { updatedAt: "desc" } },
applications: { orderBy: { updatedAt: "desc" } },
},
});
if (!job) notFound();
const comp = formatCompensation(job);
const companyColor = getCompanyColor(job.company);
const initials = getInitials(job.company);
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Breadcrumb & Top Bar */}
<div>
<Link
href="/jobs"
className="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-3"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
<span>Back to Jobs</span>
</Link>
{/* Main Header Card */}
<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 flex-col sm:flex-row sm:items-start gap-4">
<div className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl font-bold text-base border shadow-xs ${companyColor}`}>
{initials}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">
{job.title}
</h1>
<div className="flex flex-wrap items-center gap-2.5 mt-2 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-semibold text-zinc-800 dark:text-zinc-200">{job.company}</span>
<span>·</span>
<span className="inline-flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
{job.location ?? "Remote"}
</span>
{job.remoteType && (
<>
<span>·</span>
<span className="capitalize">{job.remoteType}</span>
</>
)}
</div>
{/* Badges & Meta */}
<div className="flex flex-wrap items-center gap-2 mt-3">
{job.phEligibility === "yes" ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
PH Eligible
</span>
) : job.phEligibility === "no" ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-950/50 dark:text-rose-300 dark:border-rose-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
Not Eligible
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 border border-zinc-200 dark:bg-zinc-800/60 dark:text-zinc-400 dark:border-zinc-700/60">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
PH: Unclear
</span>
)}
{comp && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200/80 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-800/60">
💰 {comp}
</span>
)}
{job.tags && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
🏷️ {job.tags}
</span>
)}
</div>
</div>
{/* External Links */}
<div className="flex sm:flex-col gap-2 shrink-0 pt-2 sm:pt-0">
{job.url && (
<a
href={job.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<span>Listing</span>
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
{job.applyUrl && (
<a
href={job.applyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/60 border border-indigo-200 dark:border-indigo-900/60 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
>
<span>Apply Page</span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
</div>
</div>
</div>
{/* Action Toolbar */}
<JobActions job={JSON.parse(JSON.stringify(job))} />
{/* Tailored Resumes Section */}
{job.resumes.length > 0 && (
<section 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-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<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>
<span>Tailored Resumes ({job.resumes.length})</span>
</h2>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{job.resumes.map((r) => (
<div
key={r.id}
className="flex items-center justify-between p-3.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/40 hover:border-purple-300 dark:hover:border-purple-800 transition-all group"
>
<div className="min-w-0 flex-1">
<Link
href={`/resumes/${r.id}`}
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors truncate block"
>
{r.name}
</Link>
<p className="text-xs text-zinc-400 mt-0.5 capitalize">
{r.kind} · v{r.version}
</p>
</div>
<Link
href={`/resumes/${r.id}`}
className="px-3 py-1 rounded-lg text-xs font-medium text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/60 border border-purple-200/80 dark:border-purple-900/60 hover:bg-purple-100 transition-colors"
>
Edit / Print →
</Link>
</div>
))}
</div>
</section>
)}
{/* Cover Letters Section */}
{job.coverLetters.length > 0 && (
<section 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-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span>Cover Letters ({job.coverLetters.length})</span>
</h2>
<div className="space-y-3">
{job.coverLetters.map((c) => (
<div
key={c.id}
className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 bg-zinc-50/50 dark:bg-zinc-950/40 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed font-sans"
>
{c.markdown}
</div>
))}
</div>
</section>
)}
{/* Description */}
{job.description && (
<section 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-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-5 h-5 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.25H12" />
</svg>
<span>Job Description</span>
</h2>
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
{job.description}
</div>
</section>
)}
{/* Requirements */}
{job.requirements && (
<section 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-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-5 h-5 text-zinc-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>
<span>Requirements</span>
</h2>
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
{job.requirements}
</div>
</section>
)}
{/* Notes */}
{job.notes && (
<section className="rounded-2xl border border-amber-200/80 dark:border-amber-900/60 bg-amber-50/30 dark:bg-amber-950/20 p-6 shadow-xs space-y-2">
<h2 className="text-sm font-bold text-amber-800 dark:text-amber-300 flex items-center gap-2">
<span>📝 Private Notes</span>
</h2>
<p className="text-sm text-amber-900/80 dark:text-amber-200/80 whitespace-pre-wrap leading-relaxed">
{job.notes}
</p>
</section>
)}
{/* Applications Log */}
{job.applications.length > 0 && (
<section 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-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<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="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>
<span>Application Submissions</span>
</h2>
<div className="space-y-2">
{job.applications.map((a) => (
<div
key={a.id}
className="flex items-center justify-between p-3 rounded-xl bg-zinc-50/70 dark:bg-zinc-800/50 text-xs text-zinc-600 dark:text-zinc-400"
>
<div className="flex items-center gap-2">
<span className="capitalize font-medium text-zinc-800 dark:text-zinc-200">
Method: {a.method}
</span>
<span>·</span>
<span>
{a.submittedAt
? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}`
: "Pending submission"}
</span>
</div>
{a.confirmation && (
<span className="font-mono text-[11px] text-zinc-500">Ref: {a.confirmation}</span>
)}
</div>
))}
</div>
</section>
)}
</div>
);
}