315 lines
16 KiB
TypeScript
315 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|