From 6e771ce728b071f2a1820eb7c8ff2e97aebefa63 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:49:11 +0000 Subject: [PATCH] Timeline introduced (#38) * initial implementation * onboarding doesn't pop until invalid values are present * link to job page * proactive inputs working slightly * onboarding gate reinstated * better proactive buttons * fully manual tracking for now. * edit and delete timeline events * status showing correctly * tests update * tests * Update orchestrator/src/server/services/applicationTracking.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/job/Timeline.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/JobPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add tests for application tracking routes and remove unused actionId from client API * remove unnecessary await from synchronous transitionStage calls and improve test isolation * relax externalUrl validation to allow non-URL metadata * add toast notifications for data loading and event logging in JobPage * comments * fix: resolve type error in sponsor-matching.test.ts * fix ci * tests fix for github * lint * github comments * build fix * dedupe * format * types fix * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting * title and group id are discrete fields * backfill * hide view button on page * show relevant dropdown options * confetti! * remove redundant * confirm delete is a custom element now * formatting * fix styling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/package-lock.json | 20 + orchestrator/package.json | 3 + orchestrator/src/client/App.tsx | 4 +- orchestrator/src/client/api/client.ts | 73 +++- .../src/client/components/ConfirmDelete.tsx | 50 +++ .../src/client/components/JobHeader.test.tsx | 50 ++- .../src/client/components/JobHeader.tsx | 46 ++- .../client/components/LogEventModal.test.tsx | 113 ++++++ .../src/client/components/LogEventModal.tsx | 241 +++++++++++ .../client/components/OnboardingGate.test.tsx | 126 ++++++ .../src/client/components/OnboardingGate.tsx | 5 + .../src/client/components/ReadyPanel.test.tsx | 11 +- .../discovered-panel/DiscoveredPanel.test.tsx | 15 +- orchestrator/src/client/pages/JobPage.tsx | 375 ++++++++++++++++++ .../client/pages/OrchestratorPage.test.tsx | 2 + .../src/client/pages/job/Timeline.test.tsx | 45 +++ .../src/client/pages/job/Timeline.tsx | 334 ++++++++++++++++ .../orchestrator/JobDetailPanel.test.tsx | 2 + .../pages/orchestrator/JobListPanel.test.tsx | 2 + orchestrator/src/lib/utils.ts | 20 + .../src/server/api/routes/jobs.test.ts | 133 +++++++ orchestrator/src/server/api/routes/jobs.ts | 175 +++++++- orchestrator/src/server/db/clear.ts | 4 +- orchestrator/src/server/db/migrate.ts | 68 +++- orchestrator/src/server/db/schema.ts | 54 +++ .../server/pipeline/sponsor-matching.test.ts | 2 + orchestrator/src/server/repositories/jobs.ts | 2 + .../src/server/services/ai-resilience.test.ts | 2 + .../services/applicationTracking.test.ts | 268 +++++++++++++ .../server/services/applicationTracking.ts | 359 +++++++++++++++++ orchestrator/src/shared/types.ts | 112 ++++++ 31 files changed, 2682 insertions(+), 34 deletions(-) create mode 100644 orchestrator/src/client/components/ConfirmDelete.tsx create mode 100644 orchestrator/src/client/components/LogEventModal.test.tsx create mode 100644 orchestrator/src/client/components/LogEventModal.tsx create mode 100644 orchestrator/src/client/components/OnboardingGate.test.tsx create mode 100644 orchestrator/src/client/pages/JobPage.tsx create mode 100644 orchestrator/src/client/pages/job/Timeline.test.tsx create mode 100644 orchestrator/src/client/pages/job/Timeline.tsx create mode 100644 orchestrator/src/server/services/applicationTracking.test.ts create mode 100644 orchestrator/src/server/services/applicationTracking.ts diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 0e8e7f0..ef42ff4 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.8", @@ -24,7 +25,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", + "@types/canvas-confetti": "^1.9.0", "better-sqlite3": "^11.6.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -1857,6 +1860,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -3533,6 +3537,12 @@ "@types/node": "*" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4326,6 +4336,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index 9a48c4b..9aaf88f 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.8", @@ -42,7 +43,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", + "@types/canvas-confetti": "^1.9.0", "better-sqlite3": "^11.6.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 152e650..6056102 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -1,4 +1,4 @@ -/** +/** * Main App component. */ @@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group"; import { Toaster } from "@/components/ui/sonner"; import { OnboardingGate } from "./components/OnboardingGate"; +import { JobPage } from "./pages/JobPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; @@ -40,6 +41,7 @@ export const App: React.FC = () => {
} /> + } /> } /> } /> } /> diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 875404d..1ef8e19 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -5,9 +5,12 @@ import { trackEvent } from "@/lib/analytics"; import type { ApiResponse, + ApplicationStage, + ApplicationTask, AppSettings, CreateJobInput, Job, + JobOutcome, JobSource, JobsListResponse, ManualJobDraft, @@ -18,6 +21,9 @@ import type { ResumeProfile, ResumeProjectCatalogItem, ResumeProjectsSettings, + StageEvent, + StageEventMetadata, + StageTransitionTarget, UkVisaJobsImportResponse, UkVisaJobsSearchResponse, ValidationResult, @@ -67,7 +73,7 @@ export async function getJobs(statuses?: string[]): Promise { } export async function getJob(id: string): Promise { - return fetchApi(`/jobs/${id}`); + return fetchApi(`/jobs/${id}?t=${Date.now()}`); } export async function updateJob( @@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise { }); } +export async function getJobStageEvents(id: string): Promise { + return fetchApi(`/jobs/${id}/events?t=${Date.now()}`); +} + +export async function getJobTasks( + id: string, + options?: { includeCompleted?: boolean }, +): Promise { + const params = new URLSearchParams(); + if (options?.includeCompleted) params.set("includeCompleted", "1"); + params.set("t", Date.now().toString()); + const query = params.toString(); + return fetchApi(`/jobs/${id}/tasks?${query}`); +} + +export async function transitionJobStage( + id: string, + input: { + toStage: StageTransitionTarget; + occurredAt?: number | null; + metadata?: StageEventMetadata | null; + outcome?: JobOutcome | null; + }, +): Promise { + return fetchApi(`/jobs/${id}/stages`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateJobStageEvent( + id: string, + eventId: string, + input: { + toStage?: ApplicationStage; + occurredAt?: number | null; + metadata?: StageEventMetadata | null; + outcome?: JobOutcome | null; + }, +): Promise { + return fetchApi(`/jobs/${id}/events/${eventId}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export async function deleteJobStageEvent( + id: string, + eventId: string, +): Promise { + return fetchApi(`/jobs/${id}/events/${eventId}`, { + method: "DELETE", + }); +} + +export async function updateJobOutcome( + id: string, + input: { outcome: JobOutcome | null; closedAt?: number | null }, +): Promise { + return fetchApi(`/jobs/${id}/outcome`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + // Pipeline API export async function getPipelineStatus(): Promise { return fetchApi("/pipeline/status"); diff --git a/orchestrator/src/client/components/ConfirmDelete.tsx b/orchestrator/src/client/components/ConfirmDelete.tsx new file mode 100644 index 0000000..b7bb31b --- /dev/null +++ b/orchestrator/src/client/components/ConfirmDelete.tsx @@ -0,0 +1,50 @@ +import type React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface ConfirmDeleteProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title?: string; + description?: string; +} + +export const ConfirmDelete: React.FC = ({ + isOpen, + onClose, + onConfirm, + title = "Are you sure?", + description = "This action cannot be undone. This will permanently delete this event from the timeline.", +}) => { + return ( + !open && onClose()}> + + + {title} + {description} + + + Cancel + { + onConfirm(); + onClose(); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + ); +}; diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index 90180e0..94581fa 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -1,5 +1,6 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import type React from "react"; +import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Job } from "../../shared/types"; import { useSettings } from "../hooks/useSettings"; @@ -37,6 +38,8 @@ const mockJob: Job = { salary: "£60,000", deadline: "2025-12-31", status: "discovered", + outcome: null, + closedAt: null, source: "linkedin", suitabilityScore: 85, suitabilityReason: "Strong match", @@ -46,6 +49,9 @@ const mockJob: Job = { } as Job; describe("JobHeader", () => { + const renderWithRouter = (ui: React.ReactElement) => + render({ui}); + beforeEach(() => { vi.clearAllMocks(); (useSettings as any).mockReturnValue({ @@ -54,21 +60,37 @@ describe("JobHeader", () => { }); it("renders basic job information", () => { - render(); + renderWithRouter(); expect(screen.getByText("Software Engineer")).toBeInTheDocument(); expect(screen.getByText("Tech Corp")).toBeInTheDocument(); expect(screen.getByText("London")).toBeInTheDocument(); expect(screen.getByText("£60,000")).toBeInTheDocument(); }); + it("links the title and view button to the job page", () => { + renderWithRouter(); + + expect( + screen.getByRole("link", { name: "Software Engineer" }), + ).toHaveAttribute("href", "/job/job-1"); + expect(screen.getByRole("link", { name: /view/i })).toHaveAttribute( + "href", + "/job/job-1", + ); + }); + it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { const onCheckSponsor = vi.fn().mockResolvedValue(undefined); - render(); + renderWithRouter( + , + ); const button = screen.getByText("Check Sponsorship Status"); expect(button).toBeInTheDocument(); - fireEvent.click(button); + await act(async () => { + fireEvent.click(button); + }); expect(onCheckSponsor).toHaveBeenCalled(); }); @@ -79,7 +101,7 @@ describe("JobHeader", () => { sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]', }; - render(); + renderWithRouter(); expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); }); @@ -90,7 +112,7 @@ describe("JobHeader", () => { sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]', }; - render(); + renderWithRouter(); expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); }); @@ -101,7 +123,7 @@ describe("JobHeader", () => { sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]', }; - render(); + renderWithRouter(); expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); }); @@ -112,11 +134,23 @@ describe("JobHeader", () => { }); const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; - render(); + renderWithRouter(); expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); expect( screen.queryByText("Check Sponsorship Status"), ).not.toBeInTheDocument(); }); + + it("hides the view button when already on a job page", () => { + render( + + + , + ); + + expect( + screen.queryByRole("link", { name: /view/i }), + ).not.toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 0c8f803..2ee8ac8 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,6 +1,14 @@ -import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; +import { + ArrowUpRight, + Calendar, + DollarSign, + Loader2, + MapPin, + Search, +} from "lucide-react"; import type React from "react"; import { useMemo, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -172,6 +180,8 @@ export const JobHeader: React.FC = ({ onCheckSponsor, }) => { const { showSponsorInfo } = useSettings(); + const { pathname } = useLocation(); + const isJobPage = pathname.startsWith("/job/"); const deadline = formatDate(job.deadline); return ( @@ -179,19 +189,37 @@ export const JobHeader: React.FC = ({ {/* Detail header: lighter weight than list items */}
-
+ {job.title} -
+
{job.employer}
- - {sourceLabel[job.source]} - +
+ + {sourceLabel[job.source]} + + {!isJobPage && ( + + )} +
{/* Tertiary metadata - subdued */} diff --git a/orchestrator/src/client/components/LogEventModal.test.tsx b/orchestrator/src/client/components/LogEventModal.test.tsx new file mode 100644 index 0000000..50c3b62 --- /dev/null +++ b/orchestrator/src/client/components/LogEventModal.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { LogEventModal } from "./LogEventModal"; + +vi.mock("@/components/ui/alert-dialog", () => ({ + AlertDialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogCancel: ({ + children, + ...props + }: { + children: React.ReactNode; + }) => ( + + ), +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value?: string; + onValueChange?: (value: string) => void; + }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => , + SelectTrigger: () => null, + SelectValue: () => null, +})); + +describe("LogEventModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows the rejection reason selector and submits the form", async () => { + const onLog = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + + render(); + + const stageSelect = screen.getAllByTestId("select")[0]; + fireEvent.change(stageSelect, { target: { value: "rejected" } }); + + expect(screen.getByText("Reason")).toBeInTheDocument(); + + const reasonSelect = screen.getAllByTestId("select")[1]; + fireEvent.change(reasonSelect, { target: { value: "Visa" } }); + + fireEvent.click(screen.getByRole("button", { name: /log event/i })); + + await waitFor(() => + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ stage: "rejected", reasonCode: "Visa" }), + undefined, + ), + ); + }); + + it("blocks submit when the title is cleared", async () => { + const onLog = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + + render(); + + const titleInput = screen.getByPlaceholderText("e.g. Recruiter Screen"); + fireEvent.change(titleInput, { target: { value: "" } }); + + fireEvent.click(screen.getByRole("button", { name: /log event/i })); + + expect(await screen.findByText("Title is required")).toBeInTheDocument(); + expect(onLog).not.toHaveBeenCalled(); + }); +}); diff --git a/orchestrator/src/client/components/LogEventModal.tsx b/orchestrator/src/client/components/LogEventModal.tsx new file mode 100644 index 0000000..ebeab8c --- /dev/null +++ b/orchestrator/src/client/components/LogEventModal.tsx @@ -0,0 +1,241 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import * as z from "zod"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { StageEvent } from "../../shared/types"; +import { STAGE_LABELS } from "../../shared/types"; + +const logEventSchema = z.object({ + stage: z.string(), + title: z.string().min(1, "Title is required"), + date: z.string().min(1, "Date is required"), + notes: z.string().optional(), + reasonCode: z.string().optional(), + salary: z.string().optional(), +}); + +export type LogEventFormValues = z.infer; + +interface LogEventModalProps { + isOpen: boolean; + onClose: () => void; + onLog: (values: LogEventFormValues, eventId?: string) => Promise; + editingEvent?: StageEvent | null; +} + +const STAGE_OPTIONS = [ + { label: "No Stage Change (Keep current status)", value: "no_change" }, + { label: STAGE_LABELS.applied, value: "applied" }, + { label: STAGE_LABELS.recruiter_screen, value: "recruiter_screen" }, + { label: STAGE_LABELS.assessment, value: "assessment" }, + { label: STAGE_LABELS.hiring_manager_screen, value: "hiring_manager_screen" }, + { label: STAGE_LABELS.technical_interview, value: "technical_interview" }, + { label: STAGE_LABELS.onsite, value: "onsite" }, + { label: STAGE_LABELS.offer, value: "offer" }, + { label: "Rejected", value: "rejected" }, + { label: "Withdrawn", value: "withdrawn" }, + { label: STAGE_LABELS.closed, value: "closed" }, +]; + +const REASON_CODES = ["Skills", "Visa", "Timing", "Culture", "Unknown"]; + +const toDateTimeLocal = (value: Date) => { + const pad = (num: number) => String(num).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad( + value.getMinutes(), + )}`; +}; + +export const LogEventModal: React.FC = ({ + isOpen, + onClose, + onLog, + editingEvent, +}) => { + const { + register, + handleSubmit, + control, + watch, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(logEventSchema), + defaultValues: { + stage: "no_change", + title: "Update", + date: toDateTimeLocal(new Date()), + notes: "", + }, + }); + + const selectedStage = watch("stage"); + + React.useEffect(() => { + if (isOpen) { + if (editingEvent) { + reset({ + stage: editingEvent.toStage, + title: editingEvent.metadata?.eventLabel || "", + date: toDateTimeLocal(new Date(editingEvent.occurredAt * 1000)), + notes: editingEvent.metadata?.note || "", + reasonCode: editingEvent.metadata?.reasonCode || undefined, + salary: editingEvent.metadata?.externalUrl?.startsWith("Salary: ") + ? editingEvent.metadata.externalUrl.replace("Salary: ", "") + : undefined, + }); + } else { + reset({ + stage: "no_change", + title: "Update", + date: toDateTimeLocal(new Date()), + notes: "", + }); + } + } + }, [isOpen, reset, editingEvent]); + + React.useEffect(() => { + // Only auto-update title if we're not editing or if the title is empty/default + if (!editingEvent) { + if (selectedStage === "no_change") { + setValue("title", "Update"); + } else { + const option = STAGE_OPTIONS.find((o) => o.value === selectedStage); + if (option) { + setValue("title", option.label); + } + } + } + }, [selectedStage, setValue, editingEvent]); + + const onSubmit = async (values: LogEventFormValues) => { + await onLog(values, editingEvent?.id); + onClose(); + }; + + return ( + !open && onClose()}> + + + + {editingEvent ? "Edit Event" : "Log Event"} + + + {editingEvent + ? "Update the details of this event." + : "Record a new update or stage change for this application."} + + + +
+ + New Stage + ( + + )} + /> + + + + + Event Title + + + + + + Date + + + + + + Notes (Optional) +