diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index c16b727..9c15cad 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -3,25 +3,212 @@ import Link from "next/link"; export const dynamic = "force-dynamic"; +const STATUS_CONFIG: Record< + string, + { label: string; bg: string; text: string; border: string; dot: string } +> = { + prospect: { + label: "Prospect", + bg: "bg-zinc-100 dark:bg-zinc-800/80", + text: "text-zinc-700 dark:text-zinc-300", + border: "border-zinc-200 dark:border-zinc-700", + dot: "bg-zinc-400 dark:bg-zinc-500", + }, + reviewing: { + label: "Reviewing", + bg: "bg-amber-50 dark:bg-amber-950/40", + text: "text-amber-700 dark:text-amber-300", + border: "border-amber-200 dark:border-amber-800/60", + dot: "bg-amber-500", + }, + tailoring: { + label: "Tailoring", + bg: "bg-purple-50 dark:bg-purple-950/40", + text: "text-purple-700 dark:text-purple-300", + border: "border-purple-200 dark:border-purple-800/60", + dot: "bg-purple-500", + }, + ready: { + label: "Ready", + bg: "bg-blue-50 dark:bg-blue-950/40", + text: "text-blue-700 dark:text-blue-300", + border: "border-blue-200 dark:border-blue-800/60", + dot: "bg-blue-500", + }, + applied: { + label: "Applied", + bg: "bg-indigo-50 dark:bg-indigo-950/40", + text: "text-indigo-700 dark:text-indigo-300", + border: "border-indigo-200 dark:border-indigo-800/60", + dot: "bg-indigo-500", + }, + interviewing: { + label: "Interviewing", + bg: "bg-teal-50 dark:bg-teal-950/40", + text: "text-teal-700 dark:text-teal-300", + border: "border-teal-200 dark:border-teal-800/60", + dot: "bg-teal-500", + }, + offer: { + label: "Offer", + bg: "bg-emerald-50 dark:bg-emerald-950/40", + text: "text-emerald-700 dark:text-emerald-300", + border: "border-emerald-200 dark:border-emerald-800/60", + dot: "bg-emerald-500", + }, + rejected: { + label: "Rejected", + bg: "bg-rose-50 dark:bg-rose-950/40", + text: "text-rose-700 dark:text-rose-300", + border: "border-rose-200 dark:border-rose-800/60", + dot: "bg-rose-500", + }, + dead: { + label: "Dead", + bg: "bg-zinc-100 dark:bg-zinc-800/60", + text: "text-zinc-500 dark:text-zinc-400", + border: "border-zinc-200 dark:border-zinc-700/60", + dot: "bg-zinc-400", + }, +}; + +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 ApplicationsPage() { const apps = await prisma.application.findMany({ orderBy: { updatedAt: "desc" }, include: { job: { select: { id: true, title: true, company: true, status: true } } }, }); + return ( -
-

Applications ({apps.length})

- {apps.length === 0 &&

No applications yet. Open a job and click “Mark Applied”.

} - +
+ {/* Header */} +
+
+
+

Applications

+ + {apps.length} {apps.length === 1 ? "application" : "applications"} + +
+

+ Track submitted job applications and their current recruitment pipeline status. +

+
+
+ + {/* Applications List */} + {apps.length === 0 ? ( +
+
+ + + +
+

No applications recorded yet

+

+ Open any job in your list and click “Mark Applied” to log an application submission here. +

+
+ + Browse Jobs + +
+
+ ) : ( +
+ {apps.map((a) => { + const statusInfo = STATUS_CONFIG[a.job.status] || STATUS_CONFIG.applied; + const companyColor = getCompanyColor(a.job.company); + const initials = getInitials(a.job.company); + + return ( +
+
+
+ {initials} +
+
+ + {a.job.title} + +
+ {a.job.company} + · + {a.method} submission + · + + {a.submittedAt + ? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })}` + : "Not submitted"} + +
+
+
+ +
+ + + {a.job.status} + + + + View Job + + + + +
+
+ ); + })} +
+ )}
); } diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..79f8eb3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,147 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8fafc; + --foreground: #0f172a; + --card-bg: #ffffff; + --card-border: #e2e8f0; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); } @media (prefers-color-scheme: dark) { :root { - --background: #0a0a0a; - --foreground: #ededed; + --background: #09090b; + --foreground: #f4f4f5; + --card-bg: #121215; + --card-border: #27272a; } } body { - background: var(--background); + background-color: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::selection { + background-color: rgba(99, 102, 241, 0.25); + color: inherit; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.4); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.6); +} + +/* Resume & Markdown Preview Styling */ +.resume-preview { + font-family: "Charter", "Bitstream Vera Serif", "Cambria", "Times New Roman", serif; + color: #1e293b; + line-height: 1.6; +} + +.dark .resume-preview { + color: #e2e8f0; +} + +.resume-preview h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.25rem; + color: #0f172a; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 0.5rem; +} + +.dark .resume-preview h1 { + color: #f8fafc; + border-bottom-color: #334155; +} + +.resume-preview h2 { + font-size: 1.2rem; + font-weight: 600; + letter-spacing: -0.01em; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + color: #1e293b; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.25rem; + text-transform: uppercase; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 0.85rem; + letter-spacing: 0.08em; +} + +.dark .resume-preview h2 { + color: #94a3b8; + border-bottom-color: #334155; +} + +.resume-preview h3 { + font-size: 1.05rem; + font-weight: 600; + margin-top: 0.75rem; + margin-bottom: 0.25rem; + color: #0f172a; +} + +.dark .resume-preview h3 { + color: #f1f5f9; +} + +.resume-preview p { + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.resume-preview ul { + list-style-type: disc; + padding-left: 1.25rem; + margin-top: 0.25rem; + margin-bottom: 0.75rem; +} + +.resume-preview li { + margin-bottom: 0.25rem; + font-size: 0.925rem; +} + +.resume-preview strong { + font-weight: 600; + color: #0f172a; +} + +.dark .resume-preview strong { + color: #f8fafc; +} + +.resume-preview a { + color: #4f46e5; + text-decoration: underline; +} + +.dark .resume-preview a { + color: #818cf8; } diff --git a/src/app/jobs/[id]/job-actions.tsx b/src/app/jobs/[id]/job-actions.tsx index 9d65600..9afd43f 100644 --- a/src/app/jobs/[id]/job-actions.tsx +++ b/src/app/jobs/[id]/job-actions.tsx @@ -4,22 +4,39 @@ import { useRouter } from "next/navigation"; const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"]; +const STATUS_CONFIG: Record = { + prospect: { label: "Prospect", dot: "bg-zinc-400" }, + reviewing: { label: "Reviewing", dot: "bg-amber-500" }, + tailoring: { label: "Tailoring", dot: "bg-purple-500" }, + ready: { label: "Ready", dot: "bg-blue-500" }, + applied: { label: "Applied", dot: "bg-indigo-500" }, + interviewing: { label: "Interviewing", dot: "bg-teal-500" }, + offer: { label: "Offer", dot: "bg-emerald-500" }, + rejected: { label: "Rejected", dot: "bg-rose-500" }, + dead: { label: "Dead", dot: "bg-zinc-400" }, +}; + export default function JobActions({ job }: { job: { id: string; status: string } }) { const router = useRouter(); const [busy, setBusy] = useState(""); - const [msg, setMsg] = useState(""); + const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); async function setStatus(status: string) { - await fetch(`/api/jobs/${job.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }); - router.refresh(); + try { + await fetch(`/api/jobs/${job.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + router.refresh(); + } catch { + setMsg({ type: "error", text: "Failed to update status." }); + } } async function run(kind: "tailor" | "cover") { - setBusy(kind); setMsg(""); + setBusy(kind); + setMsg(null); try { const res = await fetch(`/api/ai/${kind}`, { method: "POST", @@ -27,53 +44,163 @@ export default function JobActions({ job }: { job: { id: string; status: string body: JSON.stringify({ jobId: job.id }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "failed"); - setMsg(kind === "tailor" ? "Tailored resume created ✅" : "Cover letter created ✅"); + if (!res.ok) throw new Error(data.error ?? "Generation failed"); + setMsg({ + type: "success", + text: kind === "tailor" ? "Tailored resume created successfully! ✨" : "Cover letter created successfully! 📄", + }); router.refresh(); - } catch (e) { setMsg(String(e)); } finally { setBusy(""); } + } catch (e) { + setMsg({ type: "error", text: String(e) }); + } finally { + setBusy(""); + } } async function markApplied() { - await fetch("/api/applications", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }), - }); - router.refresh(); + setBusy("applied"); + setMsg(null); + try { + await fetch("/api/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }), + }); + setMsg({ type: "success", text: "Marked as applied! Application logged. ✅" }); + router.refresh(); + } catch (e) { + setMsg({ type: "error", text: String(e) }); + } finally { + setBusy(""); + } } async function del() { - if (!confirm("Delete this job?")) return; - await fetch(`/api/jobs/${job.id}`, { method: "DELETE" }); - router.push("/jobs"); + if (!confirm("Are you sure you want to delete this job and its associated data?")) return; + try { + await fetch(`/api/jobs/${job.id}`, { method: "DELETE" }); + router.push("/jobs"); + } catch (e) { + setMsg({ type: "error", text: String(e) }); + } } return ( -
-
- Status: - +
+
+ {/* Status Dropdown */} +
+ + Status: + +
+ +
+ + + +
+
+
+ + {/* Action Buttons */} +
+ {/* AI Tailor Resume */} + + + {/* AI Cover Letter */} + + + {/* Mark Applied */} + + + {/* Delete Button */} + +
-
- - - - -
- {msg &&
{msg}
} + + {/* Alert / Toast Message */} + {msg && ( +
+ {msg.text} +
+ )}
); } diff --git a/src/app/jobs/[id]/page.tsx b/src/app/jobs/[id]/page.tsx index 0ea01bd..54ea618 100644 --- a/src/app/jobs/[id]/page.tsx +++ b/src/app/jobs/[id]/page.tsx @@ -5,65 +5,308 @@ 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: true, coverLetters: true, applications: true }, + 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 ( -
+
+ {/* Breadcrumb & Top Bar */}
- ← Jobs -

{job.title}

-
{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}
-
- {job.url && Listing ↗} - {job.applyUrl && Apply page ↗} + + + + + Back to Jobs + + + {/* Main Header Card */} +
+
+
+ {initials} +
+
+

+ {job.title} +

+
+ {job.company} + · + + + + + + {job.location ?? "Remote"} + + {job.remoteType && ( + <> + · + {job.remoteType} + + )} +
+ + {/* Badges & Meta */} +
+ {job.phEligibility === "yes" ? ( + + + PH Eligible + + ) : job.phEligibility === "no" ? ( + + + Not Eligible + + ) : ( + + + PH: Unclear + + )} + + {comp && ( + + 💰 {comp} + + )} + + {job.tags && ( + + 🏷️ {job.tags} + + )} +
+
+ + {/* External Links */} +
+ {job.url && ( + + Listing + + + + + )} + {job.applyUrl && ( + + Apply Page + + + + + )} +
+
+ {/* Action Toolbar */} - {job.description && ( -
-

Description

-

{job.description}

-
- )} - {job.requirements && ( -
-

Requirements

-

{job.requirements}

-
- )} - {job.notes && ( -
-

Notes

-

{job.notes}

+ {/* Tailored Resumes Section */} + {job.resumes.length > 0 && ( +
+
+

+ + + + Tailored Resumes ({job.resumes.length}) +

+
+
+ {job.resumes.map((r) => ( +
+
+ + {r.name} + +

+ {r.kind} · v{r.version} +

+
+ + Edit / Print → + +
+ ))} +
)} - {job.resumes.length > 0 && ( -
-

Tailored resumes

-
    - {job.resumes.map((r) => ( -
  • {r.name}
  • + {/* Cover Letters Section */} + {job.coverLetters.length > 0 && ( +
    +

    + + + + Cover Letters ({job.coverLetters.length}) +

    +
    + {job.coverLetters.map((c) => ( +
    + {c.markdown} +
    ))} -
+
)} - {job.coverLetters.length > 0 && ( -
-

Cover letters

-
    - {job.coverLetters.map((c) => ( -
  • {c.markdown}
  • + + {/* Description */} + {job.description && ( +
    +

    + + + + Job Description +

    +
    + {job.description} +
    +
    + )} + + {/* Requirements */} + {job.requirements && ( +
    +

    + + + + Requirements +

    +
    + {job.requirements} +
    +
    + )} + + {/* Notes */} + {job.notes && ( +
    +

    + 📝 Private Notes +

    +

    + {job.notes} +

    +
    + )} + + {/* Applications Log */} + {job.applications.length > 0 && ( +
    +

    + + + + Application Submissions +

    +
    + {job.applications.map((a) => ( +
    +
    + + Method: {a.method} + + · + + {a.submittedAt + ? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })}` + : "Pending submission"} + +
    + {a.confirmation && ( + Ref: {a.confirmation} + )} +
    ))} -
+
)}
diff --git a/src/app/jobs/new/page.tsx b/src/app/jobs/new/page.tsx index 518d983..c8b9391 100644 --- a/src/app/jobs/new/page.tsx +++ b/src/app/jobs/new/page.tsx @@ -1,9 +1,25 @@ "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; type Parsed = Record; +const FIELD_LABELS: Record = { + title: "Job Title", + company: "Company Name", + url: "Listing URL", + applyUrl: "Application / Portal URL", + location: "Location", + remoteType: "Remote Type (e.g. remote, hybrid, onsite)", + phEligibility: "PH Eligibility (yes / no / unclear)", + payMin: "Minimum Pay", + payMax: "Maximum Pay", + payCurrency: "Currency (USD, PHP, etc.)", + payPeriod: "Pay Period (hour, month, year)", + tags: "Tags (comma-separated)", +}; + export default function NewJobPage() { const router = useRouter(); const [url, setUrl] = useState(""); @@ -13,7 +29,8 @@ export default function NewJobPage() { const [err, setErr] = useState(""); async function parse() { - setBusy(true); setErr(""); + setBusy(true); + setErr(""); try { const res = await fetch("/api/ai/parse", { method: "POST", @@ -21,62 +38,222 @@ export default function NewJobPage() { body: JSON.stringify({ url: url || undefined, text: text || undefined }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "parse failed"); + if (!res.ok) throw new Error(data.error ?? "Parse failed"); setParsed(data); - } catch (e) { setErr(String(e)); } finally { setBusy(false); } + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } } async function save() { if (!parsed) return; setBusy(true); - const res = await fetch("/api/jobs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(parsed), - }); - const job = await res.json(); - router.push(`/jobs/${job.id}`); + try { + const res = await fetch("/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed), + }); + const job = await res.json(); + router.push(`/jobs/${job.id}`); + } catch (e) { + setErr(String(e)); + setBusy(false); + } } - function set(k: string, v: string) { setParsed((p) => ({ ...p!, [k]: v || null })); } + function set(k: string, v: string) { + setParsed((p) => ({ ...p!, [k]: v || null })); + } return ( -
-

Add Job

- {!parsed && ( - <> - setUrl(e.target.value)} placeholder="Job URL (may fail on Cloudflare sites)" - className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" /> -
— or paste job text (more reliable) —
-