feat: add undo toast for skip, apply, and status moves
Accidental skips and status changes can be reversed within a few seconds via toast Undo, including bulk actions and keyboard shortcuts.
This commit is contained in:
@@ -645,6 +645,15 @@ export async function updateJob(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreJobStatuses(
|
||||||
|
restores: Array<{ jobId: string; status: Job["status"] }>,
|
||||||
|
): Promise<{ restored: number; jobs: Job[] }> {
|
||||||
|
return fetchApi<{ restored: number; jobs: Job[] }>("/jobs/restore-status", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ restores }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTracerAnalytics(options?: {
|
export async function getTracerAnalytics(options?: {
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
from?: number;
|
from?: number;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -135,8 +136,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const handleUndoApplied = useCallback(
|
const handleUndoApplied = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
try {
|
try {
|
||||||
// Revert to ready status
|
await api.restoreJobStatuses([{ jobId, status: "ready" }]);
|
||||||
await api.updateJob(jobId, { status: "ready" });
|
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "move_to_ready",
|
action: "move_to_ready",
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -199,9 +199,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
description: `${job.title} at ${job.employer}`,
|
description: `${job.title} at ${job.employer}`,
|
||||||
action: {
|
action: {
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
onClick: () => handleUndoApplied(job.id),
|
onClick: () => void handleUndoApplied(job.id),
|
||||||
},
|
},
|
||||||
duration: 6000,
|
duration: 8000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@@ -254,14 +254,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: job.status,
|
from_status: job.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Job } from "@shared/types.js";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { trackProductEvent } from "@/lib/analytics";
|
import { trackProductEvent } from "@/lib/analytics";
|
||||||
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
||||||
import { DecideMode } from "./DecideMode";
|
import { DecideMode } from "./DecideMode";
|
||||||
@@ -63,14 +64,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
try {
|
try {
|
||||||
setIsSkipping(true);
|
setIsSkipping(true);
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: job.status,
|
from_status: job.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -100,8 +106,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
to_status: "ready",
|
to_status: "ready",
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Job moved to Ready", {
|
toastMoveToReadyUndo({
|
||||||
|
jobId: job.id,
|
||||||
description: "Your tailored PDF has been generated.",
|
description: "Your tailored PDF has been generated.",
|
||||||
|
onRestored: onJobUpdated,
|
||||||
});
|
});
|
||||||
|
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
|
|||||||
@@ -46,7 +46,28 @@ export function useMarkAsAppliedMutation() {
|
|||||||
export function useSkipJobMutation() {
|
export function useSkipJobMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.skipJob(id),
|
mutationFn: async (id: string) => {
|
||||||
|
const response = await api.runJobAction({
|
||||||
|
action: "skip",
|
||||||
|
jobIds: [id],
|
||||||
|
});
|
||||||
|
const result = response.results.find((entry) => entry.jobId === id);
|
||||||
|
if (!result?.ok) {
|
||||||
|
throw new Error(
|
||||||
|
result && !result.ok
|
||||||
|
? result.error.message
|
||||||
|
: "Job action did not return a result for the job",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const restores: Array<{ jobId: string; status: Job["status"] }> = [
|
||||||
|
{
|
||||||
|
jobId: id,
|
||||||
|
status: result.undo?.previousStatus ?? "discovered",
|
||||||
|
},
|
||||||
|
...(result.undo?.alsoSkipped ?? []),
|
||||||
|
];
|
||||||
|
return { job: result.job, restores };
|
||||||
|
},
|
||||||
onMutate: async (id) => {
|
onMutate: async (id) => {
|
||||||
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
||||||
const previousJob = queryClient.getQueryData<Job>(
|
const previousJob = queryClient.getQueryData<Job>(
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
message: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/api", () => ({
|
||||||
|
restoreJobStatuses: vi.fn(async () => ({ restored: 1, jobs: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as api from "@client/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
restoresFromSkipResults,
|
||||||
|
toastWithUndo,
|
||||||
|
} from "./jobActionUndo";
|
||||||
|
|
||||||
|
describe("restoresFromSkipResults", () => {
|
||||||
|
it("includes primary and also-skipped restores", () => {
|
||||||
|
const restores = restoresFromSkipResults([
|
||||||
|
{
|
||||||
|
jobId: "a",
|
||||||
|
ok: true,
|
||||||
|
job: { id: "a" } as never,
|
||||||
|
undo: {
|
||||||
|
previousStatus: "ready",
|
||||||
|
alsoSkipped: [{ jobId: "b", status: "discovered" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ jobId: "c", ok: false, error: { code: "X", message: "nope" } },
|
||||||
|
]);
|
||||||
|
expect(restores).toEqual([
|
||||||
|
{ jobId: "a", status: "ready" },
|
||||||
|
{ jobId: "b", status: "discovered" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toastWithUndo", () => {
|
||||||
|
it("wires Undo action to restoreJobStatuses", async () => {
|
||||||
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores: [{ jobId: "j1", status: "discovered" }],
|
||||||
|
});
|
||||||
|
expect(toast.message).toHaveBeenCalled();
|
||||||
|
const call = vi.mocked(toast.message).mock.calls[0];
|
||||||
|
const opts = call?.[1] as {
|
||||||
|
action?: { onClick: () => void };
|
||||||
|
};
|
||||||
|
expect(opts?.action?.label ?? opts?.action).toBeTruthy();
|
||||||
|
opts?.action?.onClick();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(api.restoreJobStatuses).toHaveBeenCalledWith([
|
||||||
|
{ jobId: "j1", status: "discovered" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
|
import type { JobActionResult, JobStatus, JobStatusRestore } from "@shared/types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const DEFAULT_UNDO_MS = 8_000;
|
||||||
|
|
||||||
|
export type StatusRestore = JobStatusRestore;
|
||||||
|
|
||||||
|
/** Collect restore snapshots from skip action results (includes auto-skipped dupes). */
|
||||||
|
export function restoresFromSkipResults(
|
||||||
|
results: JobActionResult[],
|
||||||
|
): StatusRestore[] {
|
||||||
|
const byId = new Map<string, StatusRestore>();
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result.ok || !result.undo) continue;
|
||||||
|
byId.set(result.jobId, {
|
||||||
|
jobId: result.jobId,
|
||||||
|
status: result.undo.previousStatus,
|
||||||
|
});
|
||||||
|
for (const also of result.undo.alsoSkipped ?? []) {
|
||||||
|
if (!byId.has(also.jobId)) {
|
||||||
|
byId.set(also.jobId, also);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreJobStatuses(
|
||||||
|
restores: StatusRestore[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (restores.length === 0) return;
|
||||||
|
await api.restoreJobStatuses(restores);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a success/message toast with an Undo action that restores prior statuses.
|
||||||
|
*/
|
||||||
|
export function toastWithUndo(input: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
restores: StatusRestore[];
|
||||||
|
variant?: "success" | "message";
|
||||||
|
durationMs?: number;
|
||||||
|
onRestored?: () => void | Promise<void>;
|
||||||
|
successMessage?: string;
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
restores,
|
||||||
|
variant = "message",
|
||||||
|
durationMs = DEFAULT_UNDO_MS,
|
||||||
|
onRestored,
|
||||||
|
successMessage = restores.length === 1
|
||||||
|
? "Undone"
|
||||||
|
: `Restored ${restores.length} jobs`,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
if (restores.length === 0) {
|
||||||
|
if (variant === "success") {
|
||||||
|
toast.success(title, { description, duration: durationMs });
|
||||||
|
} else {
|
||||||
|
toast.message(title, { description, duration: durationMs });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await restoreJobStatuses(restores);
|
||||||
|
toast.success(successMessage);
|
||||||
|
await onRestored?.();
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to undo";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === "success") {
|
||||||
|
toast.success(title, { description, action, duration: durationMs });
|
||||||
|
} else {
|
||||||
|
toast.message(title, { description, action, duration: durationMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for a single skip with known previous status (when action result undo is unavailable). */
|
||||||
|
export function toastSkipUndo(input: {
|
||||||
|
jobId: string;
|
||||||
|
previousStatus: JobStatus;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
alsoSkipped?: StatusRestore[];
|
||||||
|
onRestored?: () => void | Promise<void>;
|
||||||
|
}): void {
|
||||||
|
const restores: StatusRestore[] = [
|
||||||
|
{ jobId: input.jobId, status: input.previousStatus },
|
||||||
|
...(input.alsoSkipped ?? []),
|
||||||
|
];
|
||||||
|
toastWithUndo({
|
||||||
|
title: input.title ?? "Job skipped",
|
||||||
|
description: input.description,
|
||||||
|
restores,
|
||||||
|
variant: "message",
|
||||||
|
onRestored: input.onRestored,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toastAppliedUndo(input: {
|
||||||
|
jobId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onRestored?: () => void | Promise<void>;
|
||||||
|
}): void {
|
||||||
|
toastWithUndo({
|
||||||
|
title: input.title ?? "Marked as applied",
|
||||||
|
description: input.description,
|
||||||
|
restores: [{ jobId: input.jobId, status: "ready" }],
|
||||||
|
variant: "success",
|
||||||
|
onRestored: input.onRestored,
|
||||||
|
successMessage: "Reverted to Ready",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toastMoveToReadyUndo(input: {
|
||||||
|
jobId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onRestored?: () => void | Promise<void>;
|
||||||
|
}): void {
|
||||||
|
toastWithUndo({
|
||||||
|
title: input.title ?? "Job moved to Ready",
|
||||||
|
description: input.description,
|
||||||
|
restores: [{ jobId: input.jobId, status: "discovered" }],
|
||||||
|
variant: "success",
|
||||||
|
onRestored: input.onRestored,
|
||||||
|
successMessage: "Moved back to Discovered",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
|
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
|
||||||
import {
|
import {
|
||||||
useCheckSponsorMutation,
|
useCheckSponsorMutation,
|
||||||
@@ -257,7 +258,11 @@ export const JobPage: React.FC = () => {
|
|||||||
await runAction("mark-applied", async () => {
|
await runAction("mark-applied", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await markAsAppliedMutation.mutateAsync(job.id);
|
await markAsAppliedMutation.mutateAsync(job.id);
|
||||||
toast.success("Marked as applied");
|
toastAppliedUndo({
|
||||||
|
jobId: job.id,
|
||||||
|
description: `${job.title} at ${job.employer}`,
|
||||||
|
onRestored: loadData,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,15 +273,26 @@ export const JobPage: React.FC = () => {
|
|||||||
id: job.id,
|
id: job.id,
|
||||||
update: { status: "in_progress" },
|
update: { status: "in_progress" },
|
||||||
});
|
});
|
||||||
toast.success("Moved to in progress");
|
toastWithUndo({
|
||||||
|
title: "Moved to in progress",
|
||||||
|
restores: [{ jobId: job.id, status: "applied" }],
|
||||||
|
variant: "success",
|
||||||
|
onRestored: loadData,
|
||||||
|
successMessage: "Reverted to Applied",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
await runAction("skip", async () => {
|
await runAction("skip", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: loadData,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ vi.mock("../api", () => ({
|
|||||||
}),
|
}),
|
||||||
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
|
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
|
||||||
skipJob: vi.fn().mockResolvedValue({}),
|
skipJob: vi.fn().mockResolvedValue({}),
|
||||||
|
runJobAction: vi.fn().mockImplementation(async (input: { action: string; jobIds: string[] }) => {
|
||||||
|
const jobId = input.jobIds[0] ?? "job-1";
|
||||||
|
return {
|
||||||
|
action: input.action,
|
||||||
|
requested: 1,
|
||||||
|
succeeded: 1,
|
||||||
|
failed: 0,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
jobId,
|
||||||
|
ok: true,
|
||||||
|
job: { id: jobId, status: "skipped" },
|
||||||
|
undo: { previousStatus: "ready", alsoSkipped: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
restoreJobStatuses: vi.fn().mockResolvedValue({ restored: 1, jobs: [] }),
|
||||||
markAsApplied: vi.fn().mockResolvedValue({}),
|
markAsApplied: vi.fn().mockResolvedValue({}),
|
||||||
processJob: vi.fn().mockResolvedValue({}),
|
processJob: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@@ -1096,8 +1114,16 @@ describe("OrchestratorPage", () => {
|
|||||||
|
|
||||||
pressKey("s");
|
pressKey("s");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(api.skipJob).toHaveBeenCalledWith("job-1");
|
expect(api.runJobAction).toHaveBeenCalledWith({
|
||||||
expect(toast.message).toHaveBeenCalledWith("Job skipped");
|
action: "skip",
|
||||||
|
jobIds: ["job-1"],
|
||||||
|
});
|
||||||
|
expect(toast.message).toHaveBeenCalledWith(
|
||||||
|
"Job skipped",
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
pressKey("a");
|
pressKey("a");
|
||||||
@@ -1105,7 +1131,9 @@ describe("OrchestratorPage", () => {
|
|||||||
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
|
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
|
||||||
expect(toast.success).toHaveBeenCalledWith(
|
expect(toast.success).toHaveBeenCalledWith(
|
||||||
"Marked as applied",
|
"Marked as applied",
|
||||||
expect.anything(),
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1127,6 +1155,12 @@ describe("OrchestratorPage", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
|
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
|
||||||
expect(api.processJob).toHaveBeenCalledWith("job-2");
|
expect(api.processJob).toHaveBeenCalledWith("job-2");
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(
|
||||||
|
"Job moved to Ready",
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||||
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -261,7 +262,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
from_status: selectedJob.status,
|
from_status: selectedJob.status,
|
||||||
to_status: "applied",
|
to_status: "applied",
|
||||||
});
|
});
|
||||||
toast.success("Marked as applied");
|
toastAppliedUndo({
|
||||||
|
jobId: selectedJob.id,
|
||||||
|
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
});
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@@ -279,14 +284,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
await skipJobMutation.mutateAsync(selectedJob.id);
|
const { restores } = await skipJobMutation.mutateAsync(selectedJob.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: selectedJob.status,
|
from_status: selectedJob.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@@ -304,14 +314,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const handleMoveToInProgress = async () => {
|
const handleMoveToInProgress = async () => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
|
const fromStatus = selectedJob.status;
|
||||||
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "move_in_progress",
|
action: "move_in_progress",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: selectedJob.status,
|
from_status: fromStatus,
|
||||||
to_status: "in_progress",
|
to_status: "in_progress",
|
||||||
});
|
});
|
||||||
toast.success("Moved to in progress");
|
toastWithUndo({
|
||||||
|
title: "Moved to in progress",
|
||||||
|
restores: [{ jobId: selectedJob.id, status: fromStatus === "applied" ? "applied" : "applied" }],
|
||||||
|
variant: "success",
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Reverted to Applied",
|
||||||
|
});
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { restoresFromSkipResults, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { trackProductEvent } from "@/lib/analytics";
|
import { trackProductEvent } from "@/lib/analytics";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
import { JobActionProgressToast } from "./JobActionProgressToast";
|
import { JobActionProgressToast } from "./JobActionProgressToast";
|
||||||
@@ -250,7 +251,30 @@ export function useJobSelectionActions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.failed === 0) {
|
if (result.failed === 0) {
|
||||||
toast.success(`${result.succeeded} ${successLabel}`);
|
if (action === "skip") {
|
||||||
|
const restores = restoresFromSkipResults(result.results);
|
||||||
|
toastWithUndo({
|
||||||
|
title: `${result.succeeded} ${successLabel}`,
|
||||||
|
restores,
|
||||||
|
variant: "success",
|
||||||
|
onRestored: loadJobs,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
|
} else if (action === "move_to_ready") {
|
||||||
|
const restores = selectedAtStart.map((jobId) => ({
|
||||||
|
jobId,
|
||||||
|
status: "discovered" as const,
|
||||||
|
}));
|
||||||
|
toastWithUndo({
|
||||||
|
title: `${result.succeeded} ${successLabel}`,
|
||||||
|
restores,
|
||||||
|
variant: "success",
|
||||||
|
onRestored: loadJobs,
|
||||||
|
successMessage: "Moved back to Discovered",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`${result.succeeded} ${successLabel}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${result.succeeded} succeeded, ${result.failed} failed.`,
|
`${result.succeeded} succeeded, ${result.failed} failed.`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SHORTCUTS } from "@client/lib/shortcut-map";
|
|||||||
import type { JobAction, JobListItem } from "@shared/types.js";
|
import type { JobAction, JobListItem } from "@shared/types.js";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { safeFilenamePart } from "@/lib/utils";
|
import { safeFilenamePart } from "@/lib/utils";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
import { tabs } from "./constants";
|
import { tabs } from "./constants";
|
||||||
@@ -145,10 +146,18 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
shortcutActionInFlight.current = true;
|
shortcutActionInFlight.current = true;
|
||||||
const jobId = selectedJob.id;
|
const jobId = selectedJob.id;
|
||||||
|
const previousStatus = selectedJob.status;
|
||||||
skipJobMutation
|
skipJobMutation
|
||||||
.mutateAsync(jobId)
|
.mutateAsync(jobId)
|
||||||
.then(async () => {
|
.then(async ({ restores }) => {
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores: restores.length
|
||||||
|
? restores
|
||||||
|
: [{ jobId, status: previousStatus }],
|
||||||
|
onRestored: loadJobs,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
})
|
})
|
||||||
@@ -171,8 +180,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
markAsAppliedMutation
|
markAsAppliedMutation
|
||||||
.mutateAsync(jobId)
|
.mutateAsync(jobId)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Marked as applied", {
|
toastAppliedUndo({
|
||||||
|
jobId,
|
||||||
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
||||||
|
onRestored: loadJobs,
|
||||||
});
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
@@ -205,8 +216,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
api
|
api
|
||||||
.processJob(jobId)
|
.processJob(jobId)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Job moved to Ready", {
|
toastMoveToReadyUndo({
|
||||||
|
jobId,
|
||||||
description: "Your tailored PDF has been generated.",
|
description: "Your tailored PDF has been generated.",
|
||||||
|
onRestored: loadJobs,
|
||||||
});
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getTasks,
|
getTasks,
|
||||||
stageEventMetadataSchema,
|
stageEventMetadataSchema,
|
||||||
transitionStage,
|
transitionStage,
|
||||||
|
unapplyJob,
|
||||||
updateStageEvent,
|
updateStageEvent,
|
||||||
} from "@server/services/applicationTracking";
|
} from "@server/services/applicationTracking";
|
||||||
import {
|
import {
|
||||||
@@ -178,6 +179,7 @@ const updateJobSchema = z.object({
|
|||||||
selectedProjectIds: z.string().optional(),
|
selectedProjectIds: z.string().optional(),
|
||||||
pdfPath: z.string().optional(),
|
pdfPath: z.string().optional(),
|
||||||
tracerLinksEnabled: z.boolean().optional(),
|
tracerLinksEnabled: z.boolean().optional(),
|
||||||
|
appliedAt: z.string().nullable().optional(),
|
||||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||||
sponsorMatchNames: z.string().optional(),
|
sponsorMatchNames: z.string().optional(),
|
||||||
notes: z.string().max(10000).nullable().optional(),
|
notes: z.string().max(10000).nullable().optional(),
|
||||||
@@ -380,6 +382,7 @@ async function executeJobActionForJob(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousStatus = job.status;
|
||||||
const updated = await jobsRepo.updateJob(
|
const updated = await jobsRepo.updateJob(
|
||||||
jobId,
|
jobId,
|
||||||
{ status: "skipped" },
|
{ status: "skipped" },
|
||||||
@@ -402,14 +405,25 @@ async function executeJobActionForJob(
|
|||||||
updated.ownerProfileId,
|
updated.ownerProfileId,
|
||||||
updated.id,
|
updated.id,
|
||||||
);
|
);
|
||||||
if (alsoSkipped > 0) {
|
if (alsoSkipped.length > 0) {
|
||||||
logger.info("Auto-skipped duplicate open jobs", {
|
logger.info("Auto-skipped duplicate open jobs", {
|
||||||
jobId: updated.id,
|
jobId: updated.id,
|
||||||
alsoSkipped,
|
alsoSkipped: alsoSkipped.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { jobId, ok: true, job: updated };
|
return {
|
||||||
|
jobId,
|
||||||
|
ok: true,
|
||||||
|
job: updated,
|
||||||
|
undo: {
|
||||||
|
previousStatus,
|
||||||
|
alsoSkipped: alsoSkipped.map((entry) => ({
|
||||||
|
jobId: entry.id,
|
||||||
|
status: entry.previousStatus,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "move_to_ready") {
|
if (action === "move_to_ready") {
|
||||||
@@ -964,6 +978,87 @@ jobsRouter.post("/actions/stream", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const restoreStatusSchema = z.object({
|
||||||
|
restores: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
jobId: z.string().min(1),
|
||||||
|
status: z.enum([
|
||||||
|
"discovered",
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"applied",
|
||||||
|
"in_progress",
|
||||||
|
"skipped",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(2500),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/restore-status - Undo skip / apply / move by restoring prior statuses.
|
||||||
|
*/
|
||||||
|
jobsRouter.post("/restore-status", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = restoreStatusSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(res, badRequest("Invalid restore payload", {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored: Job[] = [];
|
||||||
|
for (const entry of parsed.data.restores) {
|
||||||
|
const job = await jobsRepo.getJobById(entry.jobId);
|
||||||
|
if (!job) {
|
||||||
|
return fail(res, notFound(`Job not found: ${entry.jobId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = job.status;
|
||||||
|
const to = entry.status;
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
(from === "skipped" && (to === "discovered" || to === "ready")) ||
|
||||||
|
(from === "applied" && to === "ready") ||
|
||||||
|
(from === "ready" && to === "discovered") ||
|
||||||
|
(from === "in_progress" && to === "applied") ||
|
||||||
|
(from === "processing" && to === "discovered") ||
|
||||||
|
from === to;
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return fail(
|
||||||
|
res,
|
||||||
|
badRequest(
|
||||||
|
`Cannot restore job ${entry.jobId} from "${from}" to "${to}"`,
|
||||||
|
{ jobId: entry.jobId, from, to },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === "applied" && to === "ready") {
|
||||||
|
await unapplyJob(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await jobsRepo.updateJob(
|
||||||
|
job.id,
|
||||||
|
{
|
||||||
|
status: to,
|
||||||
|
...(to === "ready" || to === "discovered" ? { appliedAt: null } : {}),
|
||||||
|
},
|
||||||
|
job.ownerProfileId,
|
||||||
|
);
|
||||||
|
if (updated) restored.push(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(res, { restored: restored.length, jobs: restored });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
|
jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
|
||||||
const forceRaw = req.query.force as string | undefined;
|
const forceRaw = req.query.force as string | undefined;
|
||||||
const force = forceRaw === "1" || forceRaw === "true";
|
const force = forceRaw === "1" || forceRaw === "true";
|
||||||
@@ -1375,6 +1470,7 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/jobs/:id/apply - Mark a job as applied
|
* POST /api/jobs/:id/apply - Mark a job as applied
|
||||||
*/
|
*/
|
||||||
@@ -1433,10 +1529,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
|||||||
updatedJob.ownerProfileId,
|
updatedJob.ownerProfileId,
|
||||||
updatedJob.id,
|
updatedJob.id,
|
||||||
);
|
);
|
||||||
if (alsoSkipped > 0) {
|
if (alsoSkipped.length > 0) {
|
||||||
logger.info("Auto-skipped duplicate open jobs after apply", {
|
logger.info("Auto-skipped duplicate open jobs after apply", {
|
||||||
jobId: updatedJob.id,
|
jobId: updatedJob.id,
|
||||||
alsoSkipped,
|
alsoSkipped: alsoSkipped.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
|||||||
},
|
},
|
||||||
ownerProfileId: string,
|
ownerProfileId: string,
|
||||||
excludeJobId: string,
|
excludeJobId: string,
|
||||||
): Promise<number> {
|
): Promise<Array<{ id: string; previousStatus: JobStatus }>> {
|
||||||
const anchorKeys = collectJobDedupKeys(anchor);
|
const anchorKeys = collectJobDedupKeys(anchor);
|
||||||
if (anchorKeys.length === 0) return 0;
|
if (anchorKeys.length === 0) return [];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: jobs.id,
|
id: jobs.id,
|
||||||
|
status: jobs.status,
|
||||||
employer: jobs.employer,
|
employer: jobs.employer,
|
||||||
title: jobs.title,
|
title: jobs.title,
|
||||||
jobDescription: jobs.jobDescription,
|
jobDescription: jobs.jobDescription,
|
||||||
@@ -190,7 +191,7 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let skipped = 0;
|
const skipped: Array<{ id: string; previousStatus: JobStatus }> = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const rowKeys = collectJobDedupKeys({
|
const rowKeys = collectJobDedupKeys({
|
||||||
employer: row.employer,
|
employer: row.employer,
|
||||||
@@ -198,12 +199,15 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
|||||||
jobDescription: row.jobDescription,
|
jobDescription: row.jobDescription,
|
||||||
});
|
});
|
||||||
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
|
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
|
||||||
|
const previousStatus = row.status as JobStatus;
|
||||||
const updated = await updateJob(
|
const updated = await updateJob(
|
||||||
row.id,
|
row.id,
|
||||||
{ status: "skipped" },
|
{ status: "skipped" },
|
||||||
ownerProfileId,
|
ownerProfileId,
|
||||||
);
|
);
|
||||||
if (updated) skipped += 1;
|
if (updated) {
|
||||||
|
skipped.push({ id: row.id, previousStatus });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return skipped;
|
return skipped;
|
||||||
}
|
}
|
||||||
@@ -771,15 +775,26 @@ export async function updateJob(
|
|||||||
): Promise<Job | null> {
|
): Promise<Job | null> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Leaving applied / in_progress (e.g. undo mark-as-applied) clears appliedAt
|
||||||
|
// unless the caller sets it explicitly.
|
||||||
|
const leavingAppliedTrack =
|
||||||
|
input.status !== undefined &&
|
||||||
|
input.status !== "applied" &&
|
||||||
|
input.status !== "in_progress" &&
|
||||||
|
input.appliedAt === undefined;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(jobs)
|
.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
...input,
|
...input,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
...(input.status === "processing" ? { processedAt: now } : {}),
|
...(input.status === "processing" ? { processedAt: now } : {}),
|
||||||
...(input.status === "applied" && !input.appliedAt
|
...(input.status === "applied" && input.appliedAt === undefined
|
||||||
? { appliedAt: now }
|
? { appliedAt: now }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(leavingAppliedTrack || input.appliedAt === null
|
||||||
|
? { appliedAt: null }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
|
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
|
||||||
|
|
||||||
|
|||||||
@@ -310,11 +310,11 @@ export function deleteStageEvent(eventId: string): void {
|
|||||||
.where(eq(jobs.id, event.applicationId))
|
.where(eq(jobs.id, event.applicationId))
|
||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
// If no events left, maybe revert to discovered?
|
// No stage events left — undo apply should land on Ready (PDF may exist),
|
||||||
// For now just keep it as is or set to discovered if it was applied
|
// not Discovered.
|
||||||
tx.update(jobs)
|
tx.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
status: "discovered",
|
status: "ready",
|
||||||
appliedAt: null,
|
appliedAt: null,
|
||||||
outcome: null,
|
outcome: null,
|
||||||
closedAt: null,
|
closedAt: null,
|
||||||
@@ -326,6 +326,29 @@ export function deleteStageEvent(eventId: string): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo mark-as-applied: clear the Applied stage event(s) and return the job to Ready.
|
||||||
|
* If later stage events exist, keeps them and sets status from the latest remaining event.
|
||||||
|
*/
|
||||||
|
export async function unapplyJob(applicationId: string): Promise<void> {
|
||||||
|
const events = await getStageEvents(applicationId);
|
||||||
|
if (events.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appliedOnly = events.every((event) => event.toStage === "applied");
|
||||||
|
if (appliedOnly) {
|
||||||
|
for (const event of events) {
|
||||||
|
deleteStageEvent(event.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed timeline: remove only the initial Applied event(s), keep later stages.
|
||||||
|
for (const event of events.filter((e) => e.toStage === "applied")) {
|
||||||
|
deleteStageEvent(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseMetadata(raw: unknown): StageEventMetadata | null {
|
function parseMetadata(raw: unknown): StageEventMetadata | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === "string") {
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ export interface UpdateJobInput {
|
|||||||
selectedProjectIds?: string;
|
selectedProjectIds?: string;
|
||||||
pdfPath?: string;
|
pdfPath?: string;
|
||||||
tracerLinksEnabled?: boolean;
|
tracerLinksEnabled?: boolean;
|
||||||
appliedAt?: string;
|
/** Pass `null` to clear (e.g. undo mark-as-applied). */
|
||||||
|
appliedAt?: string | null;
|
||||||
coverLetter?: string | null;
|
coverLetter?: string | null;
|
||||||
sponsorMatchScore?: number;
|
sponsorMatchScore?: number;
|
||||||
sponsorMatchNames?: string;
|
sponsorMatchNames?: string;
|
||||||
|
|||||||
@@ -66,11 +66,22 @@ export type JobActionRequest =
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Snapshot used by client Undo toasts after skip / apply / move. */
|
||||||
|
export type JobStatusRestore = {
|
||||||
|
jobId: string;
|
||||||
|
status: Job["status"];
|
||||||
|
};
|
||||||
|
|
||||||
export type JobActionResult =
|
export type JobActionResult =
|
||||||
| {
|
| {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
job: Job;
|
job: Job;
|
||||||
|
/** Present when the action can be reversed (e.g. skip). */
|
||||||
|
undo?: {
|
||||||
|
previousStatus: Job["status"];
|
||||||
|
alsoSkipped?: JobStatusRestore[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user