218 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|