From dfeb4a14e9044e0bea8a7c43d24575ed8cf5d46a Mon Sep 17 00:00:00 2001 From: Richard Pillaca Date: Thu, 11 Jun 2026 17:11:23 -0700 Subject: [PATCH] feat(ui): toast feedback for pipeline, stages, notes, integrations, and template actions (#98) Every admin mutation that previously failed silently now reports success and failure via sonner toasts: - Pipeline board: moving an applicant toasts the destination stage on success; failures toast the server error and re-fetch so the card snaps back to its real column. Board load errors are surfaced too. - Stages manager: add/rename/delete/reorder wrapped in try/catch with toasts; Add button gets a pending spinner; failed reorder reverts the optimistic order. Also fixes two rename bugs: Enter triggered rename twice (keydown + blur), and Escape saved anyway because unmounting the input fired blur. - Notes: add/save/delete toast on success and failure. - Integrations: create/delete/test/retry check res.ok and toast. - Resume data editor: client-side JSON validation before the server roundtrip (prod masks server-action error messages), plus toasts. - New template button: failure toast instead of silently doing nothing. --- .../_components/ApplicantResumeDataEditor.tsx | 16 +++- .../_components/NotesSection.tsx | 32 ++++++-- .../_components/IntegrationForm.tsx | 29 ++++--- .../_components/IntegrationList.tsx | 33 +++++++- .../(admin)/admin/jobs/[id]/pipeline/page.tsx | 39 ++++++--- .../[id]/stages/_components/StagesManager.tsx | 81 ++++++++++++++----- .../_components/NewTemplateButton.tsx | 11 ++- 7 files changed, 187 insertions(+), 54 deletions(-) diff --git a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/ApplicantResumeDataEditor.tsx b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/ApplicantResumeDataEditor.tsx index f6e4bf4..a655312 100644 --- a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/ApplicantResumeDataEditor.tsx +++ b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/ApplicantResumeDataEditor.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; import { saveApplicantResumeDataJson } from "@/server/services/applicants/update"; interface ApplicantResumeDataEditorProps { @@ -20,9 +21,20 @@ export function ApplicantResumeDataEditor({ applicantId, initialData }: Applican }, [initialData]); function handleSave() { + try { + JSON.parse(text); + } catch { + toast.error("Invalid JSON. Fix the syntax and try again."); + return; + } start(async () => { - await saveApplicantResumeDataJson(applicantId, text); - router.refresh(); + try { + await saveApplicantResumeDataJson(applicantId, text); + toast.success("Applicant data saved."); + router.refresh(); + } catch { + toast.error("Could not save the applicant data. Try again."); + } }); } diff --git a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/NotesSection.tsx b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/NotesSection.tsx index 3060bbb..268bed5 100644 --- a/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/NotesSection.tsx +++ b/src/app/(admin)/admin/jobs/[id]/applicants/[applicantId]/_components/NotesSection.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Loader2, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { createApplicantNote, deleteApplicantNote, updateApplicantNote } from "@/server/services/applicants/notes"; interface NoteRow { @@ -29,9 +30,14 @@ export function NotesSection({ applicantId, notes }: NotesSectionProps) { const text = draft.trim(); if (!text) return; startTransition(async () => { - await createApplicantNote(applicantId, text); - setDraft(""); - router.refresh(); + try { + await createApplicantNote(applicantId, text); + setDraft(""); + toast.success("Note added."); + router.refresh(); + } catch { + toast.error("Could not add the note. Try again."); + } }); } @@ -42,17 +48,27 @@ export function NotesSection({ applicantId, notes }: NotesSectionProps) { function handleSave(noteId: string) { startTransition(async () => { - await updateApplicantNote(noteId, editBody); - setEditingId(null); - router.refresh(); + try { + await updateApplicantNote(noteId, editBody); + setEditingId(null); + toast.success("Note saved."); + router.refresh(); + } catch { + toast.error("Could not save the note. Try again."); + } }); } function handleDelete(noteId: string) { if (!confirm("Delete this note?")) return; startTransition(async () => { - await deleteApplicantNote(noteId); - router.refresh(); + try { + await deleteApplicantNote(noteId); + toast.success("Note deleted."); + router.refresh(); + } catch { + toast.error("Could not delete the note. Try again."); + } }); } diff --git a/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationForm.tsx b/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationForm.tsx index 0e72f87..b1a094b 100644 --- a/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationForm.tsx +++ b/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationForm.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Plus, Loader2 } from "lucide-react"; +import { toast } from "sonner"; interface Stage { id: string; @@ -27,15 +28,25 @@ export function IntegrationForm({ jobId, stages }: IntegrationFormProps) { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); startTransition(async () => { - await fetch(`/api/admin/jobs/${jobId}/integrations`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stageId, endpointUrl, apiKey, includeQuestions }), - }); - setShowForm(false); - setEndpointUrl(""); - setApiKey(""); - router.refresh(); + try { + const res = await fetch(`/api/admin/jobs/${jobId}/integrations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stageId, endpointUrl, apiKey, includeQuestions }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + toast.error(body?.error ?? "Could not save the integration."); + return; + } + toast.success("Integration added."); + setShowForm(false); + setEndpointUrl(""); + setApiKey(""); + router.refresh(); + } catch { + toast.error("Could not save the integration. Check your connection."); + } }); } diff --git a/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationList.tsx b/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationList.tsx index 8b205ca..a3af4c6 100644 --- a/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationList.tsx +++ b/src/app/(admin)/admin/jobs/[id]/integrations/_components/IntegrationList.tsx @@ -3,6 +3,7 @@ import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; interface Log { id: string; @@ -43,15 +44,39 @@ export function IntegrationList({ integrations }: IntegrationListProps) { async function handleDelete(id: string) { startTransition(async () => { - await fetch(`/api/admin/jobs/integrations/${id}`, { method: "DELETE" }); - router.refresh(); + try { + const res = await fetch(`/api/admin/jobs/integrations/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Could not delete the integration."); + return; + } + toast.success("Integration deleted."); + router.refresh(); + } catch { + toast.error("Could not delete the integration. Check your connection."); + } }); } async function postAction(integrationId: string, action: "test" | "retry") { startTransition(async () => { - await fetch(`/api/admin/jobs/integrations/${integrationId}?action=${action}`, { method: "POST" }); - router.refresh(); + try { + const res = await fetch(`/api/admin/jobs/integrations/${integrationId}?action=${action}`, { + method: "POST", + }); + if (!res.ok) { + toast.error(action === "test" ? "Test call failed to send." : "Retry failed to send."); + return; + } + toast.success( + action === "test" + ? "Test call sent. Check the log below for the response." + : "Retry sent. Check the log below for the response.", + ); + router.refresh(); + } catch { + toast.error("Request failed. Check your connection."); + } }); } diff --git a/src/app/(admin)/admin/jobs/[id]/pipeline/page.tsx b/src/app/(admin)/admin/jobs/[id]/pipeline/page.tsx index b1818ad..cf2efc8 100644 --- a/src/app/(admin)/admin/jobs/[id]/pipeline/page.tsx +++ b/src/app/(admin)/admin/jobs/[id]/pipeline/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; import { moveApplicant } from "@/server/services/pipeline/update"; import { KanbanBoard, type Stage } from "@/components/pipeline/KanbanBoard"; import { KanbanColumn } from "@/components/pipeline/KanbanColumn"; @@ -25,12 +26,19 @@ export default function PipelinePage({ params }: PipelinePageProps) { const fetchStages = useCallback(async (id: string) => { setLoading(true); - const res = await fetch(`/api/admin/jobs/${id}/pipeline`); - if (res.ok) { - const data = await res.json(); - setStages(data); + try { + const res = await fetch(`/api/admin/jobs/${id}/pipeline`); + if (res.ok) { + const data = await res.json(); + setStages(data); + } else { + toast.error("Failed to load the pipeline. Try refreshing."); + } + } catch { + toast.error("Failed to load the pipeline. Check your connection."); + } finally { + setLoading(false); } - setLoading(false); }, []); useEffect(() => { @@ -49,13 +57,26 @@ export default function PipelinePage({ params }: PipelinePageProps) { const handleMoveApplicant = useCallback( async (applicantId: string, _fromStageId: string, toStageId: string) => { if (!jobId) return; - const result = await moveApplicant(applicantId, toStageId); - if (result.success) { + try { + const result = await moveApplicant(applicantId, toStageId); + if (result.success) { + const stageName = stages.find((s) => s.id === toStageId)?.name; + if (!("unchanged" in result && result.unchanged)) { + toast.success(stageName ? `Moved to ${stageName}.` : "Applicant moved."); + } + fetchStages(jobId); + router.refresh(); + } else { + toast.error("error" in result && result.error ? result.error : "Could not move the applicant."); + // Re-fetch so the dropped card snaps back to its real column. + fetchStages(jobId); + } + } catch { + toast.error("Could not move the applicant. Check your connection and try again."); fetchStages(jobId); - router.refresh(); } }, - [jobId, router, fetchStages], + [jobId, router, fetchStages, stages], ); if (loading) { diff --git a/src/app/(admin)/admin/jobs/[id]/stages/_components/StagesManager.tsx b/src/app/(admin)/admin/jobs/[id]/stages/_components/StagesManager.tsx index 9bc97ae..eec9384 100644 --- a/src/app/(admin)/admin/jobs/[id]/stages/_components/StagesManager.tsx +++ b/src/app/(admin)/admin/jobs/[id]/stages/_components/StagesManager.tsx @@ -19,8 +19,9 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { toast } from "sonner"; import { createStage, updateStage, deleteStage, reorderStages } from "@/server/services/pipeline/stages"; -import { Plus, Pencil, Trash2, GripVertical } from "lucide-react"; +import { Loader2, Plus, Pencil, Trash2, GripVertical } from "lucide-react"; interface Stage { id: string; @@ -70,10 +71,18 @@ function SortableStageItem({ type="text" defaultValue={stage.name} className="flex-1 rounded-lg border border-border/70 bg-white px-2 py-1 text-sm outline-none focus:border-sky-500" - onBlur={(e) => onRename(stage.id, e.target.value)} + onBlur={(e) => { + // Escape marks the input cancelled; the blur fired by unmounting must not save. + if (e.currentTarget.dataset.cancelled === "1") return; + onRename(stage.id, e.currentTarget.value); + }} onKeyDown={(e) => { - if (e.key === "Enter") onRename(stage.id, (e.target as HTMLInputElement).value); - if (e.key === "Escape") onCancelEdit(); + // Enter delegates to blur so rename runs exactly once. + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") { + e.currentTarget.dataset.cancelled = "1"; + onCancelEdit(); + } }} autoFocus /> @@ -107,17 +116,39 @@ export function StagesManager({ jobId, stages: initialStages }: { jobId: string; useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + const [adding, setAdding] = useState(false); + async function handleAdd() { - if (!newName.trim()) return; - await createStage(jobId, newName.trim(), newColor); - setNewName(""); - router.refresh(); + const name = newName.trim(); + if (!name || adding) return; + setAdding(true); + try { + await createStage(jobId, name, newColor); + setNewName(""); + toast.success(`Stage "${name}" added.`); + router.refresh(); + } catch { + toast.error("Could not add the stage. Try again."); + } finally { + setAdding(false); + } } async function handleRename(stageId: string, name: string) { - await updateStage(stageId, { name }); - setEditingId(null); - router.refresh(); + const trimmed = name.trim(); + if (!trimmed || trimmed === stages.find((s) => s.id === stageId)?.name) { + setEditingId(null); + return; + } + try { + await updateStage(stageId, { name: trimmed }); + toast.success("Stage renamed."); + router.refresh(); + } catch { + toast.error("Could not rename the stage. Try again."); + } finally { + setEditingId(null); + } } const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null); @@ -129,9 +160,15 @@ export function StagesManager({ jobId, stages: initialStages }: { jobId: string; async function handleConfirmDelete(reassignToStageName: string) { if (!pendingDelete) return; const reassignToStatus = reassignToStageName.toUpperCase(); - await deleteStage(pendingDelete.id, reassignToStatus); - setPendingDelete(null); - router.refresh(); + try { + await deleteStage(pendingDelete.id, reassignToStatus); + toast.success(`Stage "${pendingDelete.name}" deleted.`); + router.refresh(); + } catch { + toast.error("Could not delete the stage. Try again."); + } finally { + setPendingDelete(null); + } } function handleCancelDelete() { @@ -146,10 +183,16 @@ export function StagesManager({ jobId, stages: initialStages }: { jobId: string; const newIndex = stages.findIndex((s) => s.id === over.id); if (oldIndex === -1 || newIndex === -1) return; + const previous = stages; const reordered = arrayMove(stages, oldIndex, newIndex).map((s, i) => ({ ...s, order: i })); setStages(reordered); - await reorderStages(reordered.map((s) => ({ id: s.id, order: s.order }))); - router.refresh(); + try { + await reorderStages(reordered.map((s) => ({ id: s.id, order: s.order }))); + router.refresh(); + } catch { + setStages(previous); + toast.error("Could not reorder stages. Try again."); + } } const presetColors = [ @@ -190,11 +233,11 @@ export function StagesManager({ jobId, stages: initialStages }: { jobId: string; diff --git a/src/app/(admin)/admin/templates/_components/NewTemplateButton.tsx b/src/app/(admin)/admin/templates/_components/NewTemplateButton.tsx index e20a9da..0d735ab 100644 --- a/src/app/(admin)/admin/templates/_components/NewTemplateButton.tsx +++ b/src/app/(admin)/admin/templates/_components/NewTemplateButton.tsx @@ -3,6 +3,7 @@ import { Loader2, Plus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { createTemplate } from "@/server/services/templates"; @@ -20,9 +21,13 @@ export function NewTemplateButton({ function handleClick() { startTransition(async () => { - const { id } = await createTemplate(); - router.push(`/admin/templates/${id}`); - router.refresh(); + try { + const { id } = await createTemplate(); + router.push(`/admin/templates/${id}`); + router.refresh(); + } catch { + toast.error("Could not create the template. Try again."); + } }); }