From f8b5dc2f42cd8369efad261f51f1e330fa68676b Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:45:45 +0000 Subject: [PATCH] In progress flow (#163) * initial commit * move from applied to in-progress * KANBAN BOARD! * backfill jobs * backfill rejected jobs * drag events :yum: * fix backfill bug * UI improvements * remove applied * gold near offer * team match meeting swim lane * formatting * Add tests for InProgressBoardPage and enhance job stage handling --- orchestrator/src/client/App.tsx | 9 + orchestrator/src/client/components/Stats.tsx | 3 +- .../src/client/components/StatusBadge.tsx | 5 + .../src/client/components/navigation.ts | 15 +- orchestrator/src/client/pages/HomePage.tsx | 2 +- .../client/pages/InProgressBoardPage.test.tsx | 163 ++++++++ .../src/client/pages/InProgressBoardPage.tsx | 366 ++++++++++++++++++ orchestrator/src/client/pages/JobPage.tsx | 24 +- .../src/client/pages/OrchestratorPage.tsx | 6 +- .../client/pages/TrackingInboxPage.test.tsx | 2 +- .../src/client/pages/TrackingInboxPage.tsx | 8 +- .../orchestrator/JobDetailPanel.test.tsx | 24 ++ .../pages/orchestrator/JobDetailPanel.tsx | 27 ++ .../client/pages/orchestrator/constants.ts | 5 + .../pages/orchestrator/useFilteredJobs.ts | 6 +- .../pages/orchestrator/useOrchestratorData.ts | 1 + .../src/client/pages/orchestrator/utils.ts | 2 + .../components/DangerZoneSection.test.tsx | 2 +- .../src/client/pages/settings/constants.ts | 4 +- orchestrator/src/server/api/routes/jobs.ts | 3 +- .../src/server/config/demo-defaults.data.ts | 2 +- orchestrator/src/server/db/migrate.ts | 170 +++++++- orchestrator/src/server/db/schema.ts | 1 + orchestrator/src/server/repositories/jobs.ts | 11 +- .../services/applicationTracking.test.ts | 30 +- .../server/services/applicationTracking.ts | 44 ++- .../post-application/ingestion/gmail-sync.ts | 6 +- shared/src/types.ts | 7 +- 28 files changed, 901 insertions(+), 47 deletions(-) create mode 100644 orchestrator/src/client/pages/InProgressBoardPage.test.tsx create mode 100644 orchestrator/src/client/pages/InProgressBoardPage.tsx diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 2ea1265..b26e8f1 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -12,6 +12,7 @@ import { OnboardingGate } from "./components/OnboardingGate"; import { useDemoInfo } from "./hooks/useDemoInfo"; import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage"; import { HomePage } from "./pages/HomePage"; +import { InProgressBoardPage } from "./pages/InProgressBoardPage"; import { JobPage } from "./pages/JobPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; @@ -28,6 +29,10 @@ const REDIRECTS: Array<{ from: string; to: string }> = [ { from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" }, { from: "/applied", to: "/jobs/applied" }, { from: "/applied/:jobId", to: "/jobs/applied/:jobId" }, + { from: "/in-progress", to: "/applications/in-progress" }, + { from: "/in-progress/:jobId", to: "/applications/in-progress" }, + { from: "/jobs/in_progress", to: "/applications/in-progress" }, + { from: "/jobs/in_progress/:jobId", to: "/applications/in-progress" }, { from: "/all", to: "/jobs/all" }, { from: "/all/:jobId", to: "/jobs/all/:jobId" }, ]; @@ -83,6 +88,10 @@ export const App: React.FC = () => { element={} /> } /> + } + /> } /> } /> } /> diff --git a/orchestrator/src/client/components/Stats.tsx b/orchestrator/src/client/components/Stats.tsx index 567b0e7..ec10024 100644 --- a/orchestrator/src/client/components/Stats.tsx +++ b/orchestrator/src/client/components/Stats.tsx @@ -27,6 +27,7 @@ const statConfig: Array<{ { key: "processing", label: "Processing", Icon: Loader2 }, { key: "ready", label: "Ready", Icon: Sparkles }, { key: "applied", label: "Applied", Icon: CheckCircle2 }, + { key: "in_progress", label: "In Progress", Icon: CheckCircle2 }, { key: "skipped", label: "Skipped", Icon: XCircle }, { key: "expired", label: "Expired", Icon: Clock }, ]; @@ -42,7 +43,7 @@ export const Stats: React.FC = ({ stats }) => { -
+
{statConfig.map(({ key, label, Icon }) => ( diff --git a/orchestrator/src/client/components/StatusBadge.tsx b/orchestrator/src/client/components/StatusBadge.tsx index 22a66f5..b5a4535 100644 --- a/orchestrator/src/client/components/StatusBadge.tsx +++ b/orchestrator/src/client/components/StatusBadge.tsx @@ -17,6 +17,7 @@ const statusLabels: Record = { processing: "Processing", ready: "Ready", applied: "Applied", + in_progress: "In Progress", skipped: "Skipped", expired: "Expired", }; @@ -35,6 +36,10 @@ const statusStyles: Record< variant: "outline", className: "text-emerald-400 border-emerald-500/30", }, + in_progress: { + variant: "outline", + className: "text-cyan-400 border-cyan-500/30", + }, skipped: { variant: "destructive" }, expired: { variant: "outline", className: "text-muted-foreground" }, }; diff --git a/orchestrator/src/client/components/navigation.ts b/orchestrator/src/client/components/navigation.ts index fa76c45..ad6ea30 100644 --- a/orchestrator/src/client/components/navigation.ts +++ b/orchestrator/src/client/components/navigation.ts @@ -1,4 +1,11 @@ -import { Home, Inbox, LayoutDashboard, Settings, Shield } from "lucide-react"; +import { + Columns3, + Home, + Inbox, + LayoutDashboard, + Settings, + Shield, +} from "lucide-react"; export type NavLink = { to: string; @@ -20,6 +27,12 @@ export const NAV_LINKS: NavLink[] = [ "/jobs/all", ], }, + { + to: "/applications/in-progress", + label: "In Progress", + icon: Columns3, + activePaths: ["/applications/in-progress"], + }, { to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox }, { to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield }, { to: "/settings", label: "Settings", icon: Settings }, diff --git a/orchestrator/src/client/pages/HomePage.tsx b/orchestrator/src/client/pages/HomePage.tsx index 79b1cd7..cc57493 100644 --- a/orchestrator/src/client/pages/HomePage.tsx +++ b/orchestrator/src/client/pages/HomePage.tsx @@ -48,7 +48,7 @@ export const HomePage: React.FC = () => { api .getJobs({ - statuses: ["applied"], + statuses: ["applied", "in_progress"], view: "list", }) .then(async (response) => { diff --git a/orchestrator/src/client/pages/InProgressBoardPage.test.tsx b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx new file mode 100644 index 0000000..1ddeb2e --- /dev/null +++ b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx @@ -0,0 +1,163 @@ +import type { JobListItem, StageEvent } from "@shared/types"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api"; +import { InProgressBoardPage } from "./InProgressBoardPage"; + +vi.mock("../api", () => ({ + getJobs: vi.fn(), + getJobStageEvents: vi.fn(), + transitionJobStage: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const makeJob = (overrides: Partial): JobListItem => ({ + id: "job-1", + source: "manual", + title: "Backend Engineer", + employer: "Acme", + jobUrl: "https://example.com/jobs/1", + applicationLink: null, + datePosted: null, + deadline: null, + salary: null, + location: null, + status: "in_progress", + outcome: null, + closedAt: null, + suitabilityScore: null, + sponsorMatchScore: null, + jobType: null, + jobFunction: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + discoveredAt: "2026-01-01T00:00:00.000Z", + appliedAt: null, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +const makeEvent = (overrides: Partial): StageEvent => ({ + id: "evt-1", + applicationId: "job-1", + title: "Recruiter Screen", + groupId: null, + fromStage: "applied", + toStage: "recruiter_screen", + occurredAt: 1_700_000_000, + metadata: null, + outcome: null, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(api.getJobs).mockResolvedValue({ + jobs: [makeJob({})], + total: 1, + byStatus: { + discovered: 0, + processing: 0, + ready: 0, + applied: 0, + in_progress: 1, + skipped: 0, + expired: 0, + }, + revision: "r1", + } as Awaited>); + vi.mocked(api.getJobStageEvents).mockResolvedValue([makeEvent({})]); + vi.mocked(api.transitionJobStage).mockResolvedValue( + makeEvent({ toStage: "offer", title: "Offer" }), + ); +}); + +describe("InProgressBoardPage", () => { + it("loads in-progress jobs and renders cards", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(api.getJobs).toHaveBeenCalledWith({ + statuses: ["in_progress"], + view: "list", + }); + }); + + expect(await screen.findByText("Backend Engineer")).toBeInTheDocument(); + }); + + it("shows cards even when no stage events are present", async () => { + vi.mocked(api.getJobStageEvents).mockResolvedValue([]); + + render( + + + , + ); + + expect(await screen.findByText("Backend Engineer")).toBeInTheDocument(); + }); + + it("transitions a job stage when dropped into another lane", async () => { + render( + + + , + ); + + const card = await screen.findByRole("link", { name: /Backend Engineer/i }); + const offerHeader = await screen.findByText("Offer"); + const offerLane = offerHeader.closest("section"); + + if (!offerLane) { + throw new Error("Offer lane section not found"); + } + + fireEvent.dragStart(card, { + dataTransfer: { + effectAllowed: "move", + }, + }); + fireEvent.dragOver(offerLane); + fireEvent.drop(offerLane); + + await waitFor(() => { + expect(api.transitionJobStage).toHaveBeenCalledWith("job-1", { + toStage: "offer", + metadata: { + actor: "user", + eventType: "status_update", + eventLabel: "Moved to Offer", + }, + }); + }); + }); + + it("surfaces load errors", async () => { + vi.mocked(api.getJobs).mockRejectedValue(new Error("Failed to load board")); + + render( + + + , + ); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Failed to load board"); + }); + }); +}); diff --git a/orchestrator/src/client/pages/InProgressBoardPage.tsx b/orchestrator/src/client/pages/InProgressBoardPage.tsx new file mode 100644 index 0000000..5e8fca2 --- /dev/null +++ b/orchestrator/src/client/pages/InProgressBoardPage.tsx @@ -0,0 +1,366 @@ +import { PageHeader, PageMain } from "@client/components/layout"; +import { + APPLICATION_STAGES, + type ApplicationStage, + type JobListItem, + STAGE_LABELS, + type StageEvent, +} from "@shared/types.js"; +import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react"; +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn, formatTimestamp } from "@/lib/utils"; +import * as api from "../api"; + +type BoardCard = { + job: JobListItem; + stage: ApplicationStage; + latestEventAt: number | null; +}; + +type BoardStage = Exclude; + +const sortByRecent = (a: BoardCard, b: BoardCard) => { + if (a.latestEventAt != null && b.latestEventAt != null) { + return b.latestEventAt - a.latestEventAt; + } + if (a.latestEventAt != null) return -1; + if (b.latestEventAt != null) return 1; + return Date.parse(b.job.discoveredAt) - Date.parse(a.job.discoveredAt); +}; + +const sortByTitle = (a: BoardCard, b: BoardCard) => + a.job.title.localeCompare(b.job.title); + +const sortByCompany = (a: BoardCard, b: BoardCard) => + a.job.employer.localeCompare(b.job.employer); + +const BOARD_STAGES = APPLICATION_STAGES.filter( + (stage) => stage !== "applied", +) as BoardStage[]; + +const toBoardStage = (stage: ApplicationStage): BoardStage => + stage === "applied" ? "recruiter_screen" : stage; + +const getCardLeftAccentClass = (stage: ApplicationStage) => { + if (stage === "technical_interview") { + return "border-l-2 border-l-amber-400/45"; + } + if (stage === "onsite") { + return "border-l-2 border-l-amber-400/65"; + } + if (stage === "offer") { + return "border-2 border-amber-300/50 shadow-[0_4px_12px_-4px_rgba(251,191,36,0.7)]"; + } + return ""; +}; + +const resolveCurrentStage = ( + events: StageEvent[] | null, +): { stage: ApplicationStage; latestEventAt: number | null } => { + const latest = events?.at(-1) ?? null; + if (latest) { + return { stage: latest.toStage, latestEventAt: latest.occurredAt }; + } + return { stage: "applied", latestEventAt: null }; +}; + +export const InProgressBoardPage: React.FC = () => { + const navigate = useNavigate(); + const [cards, setCards] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [dragging, setDragging] = React.useState<{ + jobId: string; + fromStage: ApplicationStage; + } | null>(null); + const [dropTargetStage, setDropTargetStage] = + React.useState(null); + const [movingJobId, setMovingJobId] = React.useState(null); + const [sortMode, setSortMode] = React.useState< + "updated" | "title" | "company" + >("updated"); + + const loadBoard = React.useCallback(async () => { + try { + setIsLoading(true); + const response = await api.getJobs({ + statuses: ["in_progress"], + view: "list", + }); + + const jobs = response.jobs; + const eventResults = await Promise.allSettled( + jobs.map((job) => api.getJobStageEvents(job.id)), + ); + + const nextCards = jobs.map((job, index) => { + const result = eventResults[index]; + const events = + result?.status === "fulfilled" + ? [...result.value].sort((a, b) => a.occurredAt - b.occurredAt) + : null; + const resolved = resolveCurrentStage(events); + return { + job, + stage: resolved.stage, + latestEventAt: resolved.latestEventAt, + }; + }); + + setCards(nextCards); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to load in-progress board"; + toast.error(message); + } finally { + setIsLoading(false); + } + }, []); + + React.useEffect(() => { + void loadBoard(); + }, [loadBoard]); + + const lanes = React.useMemo(() => { + const sortFn = + sortMode === "title" + ? sortByTitle + : sortMode === "company" + ? sortByCompany + : sortByRecent; + + const grouped: Record = { + recruiter_screen: [], + assessment: [], + hiring_manager_screen: [], + technical_interview: [], + onsite: [], + offer: [], + closed: [], + }; + + for (const card of cards) { + grouped[toBoardStage(card.stage)].push(card); + } + + for (const stage of BOARD_STAGES) { + grouped[stage].sort(sortFn); + } + + return grouped; + }, [cards, sortMode]); + + const handleDropToStage = React.useCallback( + async (toStage: ApplicationStage) => { + if (!dragging || dragging.fromStage === toStage) { + setDropTargetStage(null); + return; + } + + const { jobId } = dragging; + const previousCards = cards; + const nowEpoch = Math.floor(Date.now() / 1000); + + setMovingJobId(jobId); + setCards((current) => + current.map((card) => + card.job.id === jobId + ? { ...card, stage: toStage, latestEventAt: nowEpoch } + : card, + ), + ); + + try { + await api.transitionJobStage(jobId, { + toStage, + metadata: { + actor: "user", + eventType: "status_update", + eventLabel: `Moved to ${STAGE_LABELS[toStage]}`, + }, + }); + toast.success(`Moved to ${STAGE_LABELS[toStage]}`); + await loadBoard(); + } catch (error) { + setCards(previousCards); + const message = + error instanceof Error ? error.message : "Failed to move stage"; + toast.error(message); + } finally { + setMovingJobId(null); + setDragging(null); + setDropTargetStage(null); + } + }, + [cards, dragging, loadBoard], + ); + + return ( + <> + + + +
+ } + /> + + {isLoading ? ( +
+ Loading board... +
+ ) : ( +
+
+ {BOARD_STAGES.map((stage) => { + const laneCards = lanes[stage]; + return ( +
{ + event.preventDefault(); + if (!dragging || dragging.fromStage === stage) return; + setDropTargetStage(stage); + }} + onDrop={(event) => { + event.preventDefault(); + void handleDropToStage(stage); + }} + onDragLeave={() => { + if (dropTargetStage === stage) { + setDropTargetStage(null); + } + }} + className={cn( + "w-[320px] self-start rounded-xl border border-border/70 bg-muted/30 shadow-[0_10px_24px_-20px_rgba(0,0,0,0.8)] transition-colors", + dropTargetStage === stage && + "border-sky-400/70 bg-sky-500/15", + )} + > +
+

+ {STAGE_LABELS[stage]} +

+ + {laneCards.length} + +
+ +
+ {laneCards.length === 0 ? ( +
+ Drop a card here or log a stage. +
+ ) : ( + laneCards.map(({ job, latestEventAt, stage }) => ( + { + setDragging({ jobId: job.id, fromStage: stage }); + event.dataTransfer.effectAllowed = "move"; + }} + onDragEnd={() => { + setDragging(null); + setDropTargetStage(null); + }} + className={cn( + "block rounded-lg border border-border/60 bg-background/95 p-3 shadow-[0_8px_20px_-18px_rgba(0,0,0,1)] transition-colors", + "hover:border-border hover:bg-background hover:shadow-[0_12px_24px_-16px_rgba(0,0,0,1)]", + getCardLeftAccentClass(stage), + movingJobId === job.id && "opacity-70", + )} + > +
+
+ {job.title} +
+ +
+
+ {job.employer} +
+ {stage === "closed" && ( +
+ + Closed + + {job.outcome ? ( + + {job.outcome.replaceAll("_", " ")} + + ) : null} +
+ )} +
+ {latestEventAt != null + ? `Updated ${formatTimestamp(latestEventAt)}` + : "No stage events yet"} +
+ + )) + )} +
+
+ ); + })} +
+
+ )} +
+ + ); +}; diff --git a/orchestrator/src/client/pages/JobPage.tsx b/orchestrator/src/client/pages/JobPage.tsx index cf3d07e..62c371e 100644 --- a/orchestrator/src/client/pages/JobPage.tsx +++ b/orchestrator/src/client/pages/JobPage.tsx @@ -75,6 +75,10 @@ export const JobPage: React.FC = () => { eventId?: string, ) => { if (!job) return; + if (job.status !== "in_progress") { + toast.error("Move this job to In Progress to track stages."); + return; + } let toStage: ApplicationStage | "no_change" = values.stage as | ApplicationStage @@ -89,8 +93,7 @@ export const JobPage: React.FC = () => { outcome = "withdrawn"; } - const currentStage = - events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null); + const currentStage = events.at(-1)?.toStage ?? "applied"; const effectiveStage = toStage === "no_change" ? (currentStage ?? "applied") : toStage; @@ -181,8 +184,12 @@ export const JobPage: React.FC = () => { }; const currentStage = job - ? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null)) + ? (events.at(-1)?.toStage ?? + (job.status === "applied" || job.status === "in_progress" + ? "applied" + : null)) : null; + const canTrackStages = job?.status === "in_progress"; if (!id) { return null; @@ -200,7 +207,7 @@ export const JobPage: React.FC = () => { size="sm" className="bg-primary text-primary-foreground hover:bg-primary/90" onClick={() => setIsLogModalOpen(true)} - disabled={!job} + disabled={!job || !canTrackStages} > Log Event @@ -250,10 +257,15 @@ export const JobPage: React.FC = () => {
+ {!canTrackStages && ( +
+ Move this job to In Progress to track application stages. +
+ )}
diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index e91f122..52900f3 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -83,11 +83,15 @@ export const OrchestratorPage: React.FC = () => { // Effect to sync URL if it was invalid useEffect(() => { + if (tab === "in_progress") { + navigate("/applications/in-progress", { replace: true }); + return; + } const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"]; if (tab && !validTabs.includes(tab as FilterTab)) { navigateWithContext("ready", null, true); } - }, [tab, navigateWithContext]); + }, [tab, navigate, navigateWithContext]); const [navOpen, setNavOpen] = useState(false); const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false); diff --git a/orchestrator/src/client/pages/TrackingInboxPage.test.tsx b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx index 29d1ed6..5419b45 100644 --- a/orchestrator/src/client/pages/TrackingInboxPage.test.tsx +++ b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx @@ -238,7 +238,7 @@ describe("TrackingInboxPage", () => { await waitFor(() => { expect(api.getJobs).toHaveBeenCalledWith({ - statuses: ["applied"], + statuses: ["applied", "in_progress"], view: "list", }); }); diff --git a/orchestrator/src/client/pages/TrackingInboxPage.tsx b/orchestrator/src/client/pages/TrackingInboxPage.tsx index 16a83d2..700f813 100644 --- a/orchestrator/src/client/pages/TrackingInboxPage.tsx +++ b/orchestrator/src/client/pages/TrackingInboxPage.tsx @@ -118,10 +118,14 @@ export const TrackingInboxPage: React.FC = () => { setIsAppliedJobsLoading(true); try { const response = await api.getJobs({ - statuses: ["applied"], + statuses: ["applied", "in_progress"], view: "list", }); - setAppliedJobs(response.jobs.filter((job) => job.status === "applied")); + setAppliedJobs( + response.jobs.filter( + (job) => job.status === "applied" || job.status === "in_progress", + ), + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 7ed2748..f4db723 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -261,6 +261,30 @@ describe("JobDetailPanel", () => { expect(onJobUpdated).toHaveBeenCalled(); }); + it("moves an applied job to in progress from the action button", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + vi.mocked(api.updateJob).mockResolvedValue(undefined as any); + + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "applied" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }); + + fireEvent.click( + screen.getByRole("button", { name: /move to in progress/i }), + ); + + await waitFor(() => + expect(api.updateJob).toHaveBeenCalledWith("job-1", { + status: "in_progress", + }), + ); + expect(onJobUpdated).toHaveBeenCalled(); + }); + it("skips a job from the menu", async () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.skipJob).mockResolvedValue(undefined as any); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 86a25af..25e6e11 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -250,6 +250,21 @@ export const JobDetailPanel: React.FC = ({ } }; + const handleMoveToInProgress = async () => { + if (!selectedJob) return; + try { + await api.updateJob(selectedJob.id, { status: "in_progress" }); + toast.success("Moved to in progress"); + await onJobUpdated(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to move to in progress"; + toast.error(message); + } + }; + const handleCopyInfo = async () => { if (!selectedJob) return; try { @@ -280,6 +295,7 @@ export const JobDetailPanel: React.FC = ({ ? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}` : "#"; const canApply = selectedJob?.status === "ready"; + const canMoveToInProgress = selectedJob?.status === "applied"; const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; @@ -407,6 +423,17 @@ export const JobDetailPanel: React.FC = ({ )} + {canMoveToInProgress && ( + + )} +