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

218 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"] as const;
const STATUS_CONFIG: Record<string, { label: string; dot: string }> = {
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<{ type: "success" | "error"; text: string } | null>(null);
async function setStatus(status: string) {
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(null);
try {
const res = await fetch(`/api/ai/${kind}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id }),
});
const data = await res.json();
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({ type: "error", text: String(e) });
} finally {
setBusy("");
}
}
async function markApplied() {
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("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 (
<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 flex-wrap items-center justify-between gap-4">
{/* Status Dropdown */}
<div className="flex items-center gap-2.5">
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Pipeline Stage:
</span>
<div className="relative inline-flex items-center">
<select
value={job.status}
onChange={(e) => setStatus(e.target.value)}
className="appearance-none rounded-xl border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/80 py-1.5 pl-3 pr-8 text-xs font-semibold capitalize text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all cursor-pointer"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{STATUS_CONFIG[s]?.label || s}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2.5 flex items-center text-zinc-400">
<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="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
</div>
</div>
{/* Action Buttons Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* AI Tailor Resume */}
<button
onClick={() => run("tailor")}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-purple-600 via-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 shadow-sm shadow-purple-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all"
>
{busy === "tailor" ? (
<>
<svg className="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Generating…</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="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
<span>AI Tailor Resume</span>
</>
)}
</button>
{/* AI Cover Letter */}
<button
onClick={() => run("cover")}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 via-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 shadow-sm shadow-indigo-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all"
>
{busy === "cover" ? (
<>
<svg className="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Generating…</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="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>AI Cover Letter</span>
</>
)}
</button>
{/* Apply Wizard */}
<a
href={`/jobs/${job.id}/apply`}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-blue-600 via-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 shadow-sm shadow-blue-500/20 active:scale-95 transition-all"
>
<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 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<span>Apply Wizard</span>
</a>
{/* Mark Applied */}
<button
onClick={markApplied}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 via-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-sm shadow-emerald-500/20 disabled:opacity-50 active:scale-95 transition-all"
>
<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="M4.5 12.75l6 6 9-13.5" />
</svg>
<span>Mark Applied</span>
</button>
{/* Delete Button */}
<button
onClick={del}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-900/60 bg-rose-50/50 dark:bg-rose-950/30 hover:bg-rose-100 dark:hover:bg-rose-900/50 transition-all ml-auto"
>
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span>Delete</span>
</button>
</div>
</div>
{/* Alert / Toast Message */}
{msg && (
<div
className={`rounded-xl p-3 text-xs font-medium flex items-center gap-2 border ${
msg.type === "success"
? "bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800/60"
: "bg-rose-50 dark:bg-rose-950/40 text-rose-800 dark:text-rose-200 border-rose-200 dark:border-rose-800/60"
}`}
>
<span>{msg.text}</span>
</div>
)}
</div>
);
}