Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.");
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.");
}
});
}

Expand All @@ -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.");
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.");
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
}
});
}

Expand Down
39 changes: 30 additions & 9 deletions src/app/(admin)/admin/jobs/[id]/pipeline/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(() => {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
/>
Expand Down Expand Up @@ -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);
Expand All @@ -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() {
Expand All @@ -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 = [
Expand Down Expand Up @@ -190,11 +233,11 @@ export function StagesManager({ jobId, stages: initialStages }: { jobId: string;
<button
type="button"
onClick={handleAdd}
disabled={!newName.trim()}
disabled={!newName.trim() || adding}
className="inline-flex items-center gap-1 rounded-lg bg-slate-950 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
Add
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding…" : "Add"}
</button>
</div>
</div>
Expand Down
Loading
Loading