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>
This commit is contained in:
co-authored by
Copilot
parent
ad6a79e74f
commit
6e771ce728
@@ -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 = () => {
|
||||
<div ref={nodeRef}>
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/ready" replace />} />
|
||||
<Route path="/job/:id" element={<JobPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||
|
||||
@@ -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<JobsListResponse> {
|
||||
}
|
||||
|
||||
export async function getJob(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}`);
|
||||
return fetchApi<Job>(`/jobs/${id}?t=${Date.now()}`);
|
||||
}
|
||||
|
||||
export async function updateJob(
|
||||
@@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
|
||||
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
|
||||
}
|
||||
|
||||
export async function getJobTasks(
|
||||
id: string,
|
||||
options?: { includeCompleted?: boolean },
|
||||
): Promise<ApplicationTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.includeCompleted) params.set("includeCompleted", "1");
|
||||
params.set("t", Date.now().toString());
|
||||
const query = params.toString();
|
||||
return fetchApi<ApplicationTask[]>(`/jobs/${id}/tasks?${query}`);
|
||||
}
|
||||
|
||||
export async function transitionJobStage(
|
||||
id: string,
|
||||
input: {
|
||||
toStage: StageTransitionTarget;
|
||||
occurredAt?: number | null;
|
||||
metadata?: StageEventMetadata | null;
|
||||
outcome?: JobOutcome | null;
|
||||
},
|
||||
): Promise<StageEvent> {
|
||||
return fetchApi<StageEvent>(`/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<void> {
|
||||
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteJobStageEvent(
|
||||
id: string,
|
||||
eventId: string,
|
||||
): Promise<void> {
|
||||
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateJobOutcome(
|
||||
id: string,
|
||||
input: { outcome: JobOutcome | null; closedAt?: number | null },
|
||||
): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/outcome`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
// Pipeline API
|
||||
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
|
||||
return fetchApi<PipelineStatusResponse>("/pipeline/status");
|
||||
|
||||
@@ -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<ConfirmDeleteProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = "Are you sure?",
|
||||
description = "This action cannot be undone. This will permanently delete this event from the timeline.",
|
||||
}) => {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -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(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useSettings as any).mockReturnValue({
|
||||
@@ -54,21 +60,37 @@ describe("JobHeader", () => {
|
||||
});
|
||||
|
||||
it("renders basic job information", () => {
|
||||
render(<JobHeader job={mockJob} />);
|
||||
renderWithRouter(<JobHeader job={mockJob} />);
|
||||
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(<JobHeader job={mockJob} />);
|
||||
|
||||
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(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
||||
renderWithRouter(
|
||||
<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />,
|
||||
);
|
||||
|
||||
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(<JobHeader job={jobWithSponsor} />);
|
||||
renderWithRouter(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
@@ -90,7 +112,7 @@ describe("JobHeader", () => {
|
||||
sponsorMatchScore: 85,
|
||||
sponsorMatchNames: '["Techy Corp"]',
|
||||
};
|
||||
render(<JobHeader job={jobWithPotential} />);
|
||||
renderWithRouter(<JobHeader job={jobWithPotential} />);
|
||||
|
||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,7 +123,7 @@ describe("JobHeader", () => {
|
||||
sponsorMatchScore: 40,
|
||||
sponsorMatchNames: '["Other Corp"]',
|
||||
};
|
||||
render(<JobHeader job={jobNoSponsor} />);
|
||||
renderWithRouter(<JobHeader job={jobNoSponsor} />);
|
||||
|
||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||
});
|
||||
@@ -112,11 +134,23 @@ describe("JobHeader", () => {
|
||||
});
|
||||
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
renderWithRouter(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/job/job-1"]}>
|
||||
<JobHeader job={mockJob} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /view/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<JobHeaderProps> = ({
|
||||
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<JobHeaderProps> = ({
|
||||
{/* Detail header: lighter weight than list items */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground/90">
|
||||
<Link
|
||||
to={`/job/${job.id}`}
|
||||
className="flex items-center gap-2 text-base font-semibold underline-offset-2 text-foreground/90 hover:underline"
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{job.employer}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
||||
>
|
||||
{sourceLabel[job.source]}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
||||
>
|
||||
{sourceLabel[job.source]}
|
||||
</Badge>
|
||||
{!isJobPage && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
<Link to={`/job/${job.id}`}>
|
||||
View
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tertiary metadata - subdued */}
|
||||
|
||||
@@ -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 }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogCancel: ({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => (
|
||||
<select
|
||||
data-testid="select"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange?.(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
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(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
|
||||
|
||||
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(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof logEventSchema>;
|
||||
|
||||
interface LogEventModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLog: (values: LogEventFormValues, eventId?: string) => Promise<void>;
|
||||
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<LogEventModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onLog,
|
||||
editingEvent,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LogEventFormValues>({
|
||||
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 (
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{editingEvent ? "Edit Event" : "Log Event"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{editingEvent
|
||||
? "Update the details of this event."
|
||||
: "Record a new update or stage change for this application."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>New Stage</FieldLabel>
|
||||
<Controller
|
||||
name="stage"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STAGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.stage]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Event Title</FieldLabel>
|
||||
<Input {...register("title")} placeholder="e.g. Recruiter Screen" />
|
||||
<FieldError errors={[errors.title]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Date</FieldLabel>
|
||||
<Input type="datetime-local" {...register("date")} />
|
||||
<FieldError errors={[errors.date]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Notes (Optional)</FieldLabel>
|
||||
<Textarea {...register("notes")} placeholder="Add details..." />
|
||||
<FieldError errors={[errors.notes]} />
|
||||
</Field>
|
||||
|
||||
{selectedStage === "rejected" && (
|
||||
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<FieldLabel>Reason</FieldLabel>
|
||||
<Controller
|
||||
name="reasonCode"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASON_CODES.map((code) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selectedStage === "offer" && (
|
||||
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<FieldLabel>Salary / Details</FieldLabel>
|
||||
<Input {...register("salary")} placeholder="e.g. £50k + bonus" />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter className="pt-4">
|
||||
<AlertDialogCancel type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: editingEvent
|
||||
? "Save Changes"
|
||||
: "Log Event"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as api from "@client/api";
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OnboardingGate } from "./OnboardingGate";
|
||||
|
||||
vi.mock("@client/api", () => ({
|
||||
validateOpenrouter: vi.fn(),
|
||||
validateRxresume: vi.fn(),
|
||||
validateResumeConfig: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@client/hooks/useSettings", () => ({
|
||||
useSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
|
||||
SettingsInput: ({ label }: { label: string }) => <div>{label}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
|
||||
BaseResumeSelection: () => <div>Base resume selection</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/alert-dialog", () => ({
|
||||
AlertDialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TabsContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
TabsList: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/progress", () => ({
|
||||
Progress: () => <div>Progress</div>,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const settingsResponse = {
|
||||
settings: {
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
},
|
||||
isLoading: false,
|
||||
refreshSettings: vi.fn(),
|
||||
};
|
||||
|
||||
describe("OnboardingGate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
|
||||
});
|
||||
|
||||
it("renders the gate once validations complete and any fail", async () => {
|
||||
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
||||
valid: false,
|
||||
message: "Invalid",
|
||||
});
|
||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
||||
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the gate when all validations succeed", async () => {
|
||||
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
||||
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -126,8 +126,13 @@ export const OnboardingGate: React.FC = () => {
|
||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||
const hasCheckedValidations =
|
||||
openrouterValidation.checked &&
|
||||
rxresumeValidation.checked &&
|
||||
baseResumeValidation.checked;
|
||||
const shouldOpen =
|
||||
Boolean(settings && !settingsLoading) &&
|
||||
hasCheckedValidations &&
|
||||
!(
|
||||
openrouterValidation.valid &&
|
||||
rxresumeValidation.valid &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "../../shared/types";
|
||||
@@ -122,6 +123,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -136,7 +139,13 @@ describe("ReadyPanel", () => {
|
||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||
|
||||
render(
|
||||
<ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />,
|
||||
<MemoryRouter>
|
||||
<ReadyPanel
|
||||
job={job}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "../../../shared/types";
|
||||
@@ -115,6 +116,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -129,11 +132,13 @@ describe("DiscoveredPanel", () => {
|
||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||
|
||||
render(
|
||||
<DiscoveredPanel
|
||||
job={job}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={vi.fn()}
|
||||
/>,
|
||||
<MemoryRouter>
|
||||
<DiscoveredPanel
|
||||
job={job}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import confetti from "canvas-confetti";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
ClipboardList,
|
||||
DollarSign,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import {
|
||||
type ApplicationStage,
|
||||
type ApplicationTask,
|
||||
type Job,
|
||||
type JobOutcome,
|
||||
STAGE_LABELS,
|
||||
type StageEvent,
|
||||
} from "../../shared/types";
|
||||
import * as api from "../api";
|
||||
import { ConfirmDelete } from "../components/ConfirmDelete";
|
||||
import { JobHeader } from "../components/JobHeader";
|
||||
import {
|
||||
type LogEventFormValues,
|
||||
LogEventModal,
|
||||
} from "../components/LogEventModal";
|
||||
import { JobTimeline } from "./job/Timeline";
|
||||
|
||||
export const JobPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [job, setJob] = React.useState<Job | null>(null);
|
||||
const [events, setEvents] = React.useState<StageEvent[]>([]);
|
||||
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
|
||||
const [eventToDelete, setEventToDelete] = React.useState<string | null>(null);
|
||||
const [editingEvent, setEditingEvent] = React.useState<StageEvent | null>(
|
||||
null,
|
||||
);
|
||||
const pendingEventRef = React.useRef<StageEvent | null>(null);
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const jobData = await api.getJob(id);
|
||||
setJob(jobData);
|
||||
|
||||
api
|
||||
.getJobStageEvents(id)
|
||||
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
|
||||
.catch(() => toast.error("Failed to load stage events"));
|
||||
|
||||
api
|
||||
.getJobTasks(id)
|
||||
.then((data) => setTasks(data))
|
||||
.catch(() => toast.error("Failed to load tasks"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleLogEvent = async (
|
||||
values: LogEventFormValues,
|
||||
eventId?: string,
|
||||
) => {
|
||||
if (!job) return;
|
||||
|
||||
let toStage: ApplicationStage | "no_change" = values.stage as
|
||||
| ApplicationStage
|
||||
| "no_change";
|
||||
let outcome: JobOutcome | null = null;
|
||||
|
||||
if (values.stage === "rejected") {
|
||||
toStage = "closed";
|
||||
outcome = "rejected";
|
||||
} else if (values.stage === "withdrawn") {
|
||||
toStage = "closed";
|
||||
outcome = "withdrawn";
|
||||
}
|
||||
|
||||
const currentStage =
|
||||
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
|
||||
const effectiveStage =
|
||||
toStage === "no_change" ? (currentStage ?? "applied") : toStage;
|
||||
|
||||
try {
|
||||
if (eventId) {
|
||||
await api.updateJobStageEvent(job.id, eventId, {
|
||||
toStage: toStage === "no_change" ? undefined : toStage,
|
||||
occurredAt: toTimestamp(values.date) ?? undefined,
|
||||
metadata: {
|
||||
note: values.notes?.trim() || undefined,
|
||||
eventLabel: values.title.trim() || undefined,
|
||||
reasonCode: values.reasonCode || undefined,
|
||||
actor: "user",
|
||||
eventType: values.stage === "no_change" ? "note" : "status_update",
|
||||
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
|
||||
},
|
||||
outcome,
|
||||
});
|
||||
} else {
|
||||
const newEvent = await api.transitionJobStage(job.id, {
|
||||
toStage: effectiveStage,
|
||||
occurredAt: toTimestamp(values.date),
|
||||
metadata: {
|
||||
note: values.notes?.trim() || undefined,
|
||||
eventLabel: values.title.trim() || undefined,
|
||||
reasonCode: values.reasonCode || undefined,
|
||||
actor: "user",
|
||||
eventType: values.stage === "no_change" ? "note" : "status_update",
|
||||
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
|
||||
},
|
||||
outcome,
|
||||
});
|
||||
pendingEventRef.current = newEvent;
|
||||
}
|
||||
|
||||
const [jobData, eventData] = await Promise.all([
|
||||
api.getJob(job.id),
|
||||
api.getJobStageEvents(job.id),
|
||||
]);
|
||||
setJob(jobData);
|
||||
setEvents(eventData);
|
||||
pendingEventRef.current = null;
|
||||
setEditingEvent(null);
|
||||
toast.success(eventId ? "Event updated" : "Event logged");
|
||||
|
||||
if (effectiveStage === "offer") {
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
colors: ["#10b981", "#34d399", "#6ee7b7", "#ffffff"],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log event:", error);
|
||||
toast.error("Failed to log event");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteEvent = (eventId: string) => {
|
||||
setEventToDelete(eventId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async () => {
|
||||
if (!job || !eventToDelete) return;
|
||||
try {
|
||||
await api.deleteJobStageEvent(job.id, eventToDelete);
|
||||
const [jobData, eventData] = await Promise.all([
|
||||
api.getJob(job.id),
|
||||
api.getJobStageEvents(job.id),
|
||||
]);
|
||||
setJob(jobData);
|
||||
setEvents(eventData);
|
||||
toast.success("Event deleted");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event:", error);
|
||||
toast.error("Failed to delete event");
|
||||
} finally {
|
||||
setIsDeleteModalOpen(false);
|
||||
setEventToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: StageEvent) => {
|
||||
setEditingEvent(event);
|
||||
setIsLogModalOpen(true);
|
||||
};
|
||||
|
||||
const currentStage = job
|
||||
? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null))
|
||||
: null;
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-6xl space-y-6 px-4 py-6 pb-12">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setIsLogModalOpen(true)}
|
||||
disabled={!job}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Log Event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job ? (
|
||||
<JobHeader
|
||||
job={job}
|
||||
className="rounded-lg border border-border/40 bg-muted/5 p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/40 p-6 text-sm text-muted-foreground">
|
||||
{isLoading ? "Loading application..." : "Application not found."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Stage timeline
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{job?.salary && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
<DollarSign className="mr-1 h-3.5 w-3.5" />
|
||||
{job.salary}
|
||||
</Badge>
|
||||
)}
|
||||
{currentStage && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-xs font-medium uppercase tracking-wider"
|
||||
>
|
||||
{STAGE_LABELS[currentStage as ApplicationStage] ||
|
||||
currentStage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobTimeline
|
||||
events={events}
|
||||
onEdit={handleEditEvent}
|
||||
onDelete={confirmDeleteEvent}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Application details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Current Stage
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{currentStage
|
||||
? STAGE_LABELS[currentStage as ApplicationStage] ||
|
||||
currentStage
|
||||
: job?.status}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Outcome
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{job?.outcome ? job.outcome.replace(/_/g, " ") : "Open"}
|
||||
</div>
|
||||
</div>
|
||||
{job?.closedAt && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Closed On
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{formatTimestamp(job.closedAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Upcoming tasks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground/90">
|
||||
{task.title}
|
||||
</div>
|
||||
{task.notes && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{task.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
{formatTimestamp(task.dueDate)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogEventModal
|
||||
isOpen={isLogModalOpen}
|
||||
onClose={() => {
|
||||
setIsLogModalOpen(false);
|
||||
setEditingEvent(null);
|
||||
}}
|
||||
onLog={handleLogEvent}
|
||||
editingEvent={editingEvent}
|
||||
/>
|
||||
|
||||
<ConfirmDelete
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setEventToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteEvent}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const toTimestamp = (value: string) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
};
|
||||
|
||||
const mergeEvents = (events: StageEvent[], pending: StageEvent | null) => {
|
||||
if (!pending) return events;
|
||||
if (events.some((event) => event.id === pending.id)) return events;
|
||||
return [...events, pending].sort((a, b) => a.occurredAt - b.occurredAt);
|
||||
};
|
||||
@@ -24,6 +24,8 @@ const jobFixture: Job = {
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { StageEvent } from "../../../shared/types";
|
||||
import { JobTimeline } from "./Timeline";
|
||||
|
||||
const baseEvent: StageEvent = {
|
||||
id: "event-1",
|
||||
applicationId: "app-1",
|
||||
fromStage: null,
|
||||
toStage: "applied",
|
||||
title: "Applied",
|
||||
groupId: null,
|
||||
occurredAt: 1735689600,
|
||||
metadata: {
|
||||
eventLabel: "Applied",
|
||||
},
|
||||
outcome: null,
|
||||
};
|
||||
|
||||
describe("JobTimeline", () => {
|
||||
it("renders edit and delete controls when callbacks are provided", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<JobTimeline events={[baseEvent]} onEdit={onEdit} onDelete={onDelete} />,
|
||||
);
|
||||
|
||||
const editButton = screen.getByTitle("Edit event");
|
||||
const deleteButton = screen.getByTitle("Delete event");
|
||||
|
||||
fireEvent.click(editButton);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(baseEvent);
|
||||
expect(onDelete).toHaveBeenCalledWith("event-1");
|
||||
});
|
||||
|
||||
it("omits edit and delete controls when callbacks are missing", () => {
|
||||
render(<JobTimeline events={[baseEvent]} />);
|
||||
|
||||
expect(screen.queryByTitle("Edit event")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Delete event")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Edit2,
|
||||
FileText,
|
||||
MailCheck,
|
||||
PhoneCall,
|
||||
Presentation,
|
||||
Trash2,
|
||||
UserRound,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils";
|
||||
import {
|
||||
type ApplicationStage,
|
||||
STAGE_LABELS,
|
||||
type StageEvent,
|
||||
} from "../../../shared/types";
|
||||
import { CollapsibleSection } from "../../components/discovered-panel/CollapsibleSection";
|
||||
|
||||
const stageIcons: Record<ApplicationStage, React.ReactNode> = {
|
||||
applied: <CheckCircle2 className="h-4 w-4" />,
|
||||
recruiter_screen: <PhoneCall className="h-4 w-4" />,
|
||||
assessment: <FileText className="h-4 w-4" />,
|
||||
hiring_manager_screen: <UserRound className="h-4 w-4" />,
|
||||
technical_interview: <Video className="h-4 w-4" />,
|
||||
onsite: <Presentation className="h-4 w-4" />,
|
||||
offer: <MailCheck className="h-4 w-4" />,
|
||||
closed: <ClipboardList className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const formatRange = (start: number, end: number) => {
|
||||
const startLabel = formatTimestamp(start);
|
||||
const endLabel = formatTimestamp(end);
|
||||
return startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
|
||||
};
|
||||
|
||||
type TimelineEntry =
|
||||
| { kind: "event"; event: StageEvent }
|
||||
| {
|
||||
kind: "group";
|
||||
id: string;
|
||||
label: string;
|
||||
events: StageEvent[];
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
interface JobTimelineProps {
|
||||
events: StageEvent[];
|
||||
onEdit?: (event: StageEvent) => void;
|
||||
onDelete?: (eventId: string) => void;
|
||||
}
|
||||
|
||||
export const JobTimeline: React.FC<JobTimelineProps> = ({
|
||||
events,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const lastEvent = events.at(-1);
|
||||
const currentStage = lastEvent?.toStage ?? null;
|
||||
|
||||
const entries = React.useMemo(() => {
|
||||
const groups = new Map<string, { label: string; events: StageEvent[] }>();
|
||||
const standalone: StageEvent[] = [];
|
||||
|
||||
events.forEach((event) => {
|
||||
const groupId = event.groupId;
|
||||
if (!groupId) {
|
||||
standalone.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = event.metadata?.groupLabel || "Grouped events";
|
||||
const group = groups.get(groupId) ?? { label, events: [] };
|
||||
group.events.push(event);
|
||||
groups.set(groupId, group);
|
||||
});
|
||||
|
||||
const mapped: TimelineEntry[] = standalone.map((event) => ({
|
||||
kind: "event",
|
||||
event,
|
||||
}));
|
||||
|
||||
groups.forEach((value, id) => {
|
||||
const sorted = [...value.events].sort(
|
||||
(a, b) => a.occurredAt - b.occurredAt,
|
||||
);
|
||||
mapped.push({
|
||||
kind: "group",
|
||||
id,
|
||||
label: value.label,
|
||||
events: sorted,
|
||||
occurredAt: sorted[0]?.occurredAt ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
return mapped.sort((a, b) => {
|
||||
const timeA = a.kind === "event" ? a.event.occurredAt : a.occurredAt;
|
||||
const timeB = b.kind === "event" ? b.event.occurredAt : b.occurredAt;
|
||||
return timeA - timeB;
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border/50 p-6 text-sm text-muted-foreground">
|
||||
No stage events yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{entries.map((entry, entryIndex) => {
|
||||
if (entry.kind === "event") {
|
||||
const title = entry.event.title || STAGE_LABELS[entry.event.toStage];
|
||||
const note = entry.event.metadata?.note;
|
||||
const reason = entry.event.metadata?.reasonCode;
|
||||
const isCurrent =
|
||||
currentStage === entry.event.toStage &&
|
||||
entryIndex === entries.length - 1 &&
|
||||
entry.event.toStage !== "applied";
|
||||
const isOffer = entry.event.toStage === "offer";
|
||||
const salary = entry.event.metadata?.externalUrl?.startsWith(
|
||||
"Salary: ",
|
||||
)
|
||||
? entry.event.metadata.externalUrl.replace("Salary: ", "")
|
||||
: null;
|
||||
return (
|
||||
<TimelineRow
|
||||
key={entry.event.id}
|
||||
date={formatTimestampWithTime(entry.event.occurredAt)}
|
||||
title={title}
|
||||
icon={stageIcons[entry.event.toStage]}
|
||||
isCurrent={isCurrent}
|
||||
isOffer={isOffer}
|
||||
isLast={entryIndex === entries.length - 1}
|
||||
onEdit={onEdit ? () => onEdit(entry.event) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(entry.event.id) : undefined}
|
||||
>
|
||||
{note && (
|
||||
<div className="text-sm text-muted-foreground">{note}</div>
|
||||
)}
|
||||
{salary && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{salary}
|
||||
</div>
|
||||
)}
|
||||
{reason && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mt-2 text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
{reason}
|
||||
</Badge>
|
||||
)}
|
||||
</TimelineRow>
|
||||
);
|
||||
}
|
||||
|
||||
const groupOpen = Boolean(openGroups[entry.id]);
|
||||
const toggleGroup = () =>
|
||||
setOpenGroups((prev) => ({ ...prev, [entry.id]: !prev[entry.id] }));
|
||||
const groupStart = entry.events[0]?.occurredAt ?? entry.occurredAt;
|
||||
const groupEnd = entry.events.at(-1)?.occurredAt ?? entry.occurredAt;
|
||||
const groupCompleted = entry.events.some((event) =>
|
||||
/submitted|completed|finished/i.test(event.title || ""),
|
||||
);
|
||||
const isCurrentGroup =
|
||||
currentStage === entry.events.at(-1)?.toStage &&
|
||||
entryIndex === entries.length - 1;
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
<TimelineRow
|
||||
date={formatRange(groupStart, groupEnd)}
|
||||
title={entry.label}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
isCurrent={isCurrentGroup && !groupCompleted}
|
||||
isCompleted={groupCompleted}
|
||||
isLast={entryIndex === entries.length - 1}
|
||||
>
|
||||
<CollapsibleSection
|
||||
isOpen={groupOpen}
|
||||
label={groupOpen ? "Hide details" : "View details"}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{entry.events.map((event) => (
|
||||
<TimelineRow
|
||||
key={event.id}
|
||||
date={formatTimestampWithTime(event.occurredAt)}
|
||||
title={event.title || STAGE_LABELS[event.toStage]}
|
||||
icon={stageIcons[event.toStage]}
|
||||
isCompact
|
||||
isLast={false}
|
||||
onEdit={onEdit ? () => onEdit(event) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(event.id) : undefined}
|
||||
>
|
||||
{event.metadata?.note && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.metadata.note}
|
||||
</div>
|
||||
)}
|
||||
</TimelineRow>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</TimelineRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TimelineRowProps {
|
||||
date: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isCurrent?: boolean;
|
||||
isOffer?: boolean;
|
||||
isCompleted?: boolean;
|
||||
isLast?: boolean;
|
||||
isCompact?: boolean;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TimelineRow: React.FC<TimelineRowProps> = ({
|
||||
date,
|
||||
title,
|
||||
icon,
|
||||
isCurrent,
|
||||
isOffer,
|
||||
isCompleted,
|
||||
isLast,
|
||||
isCompact,
|
||||
onEdit,
|
||||
onDelete,
|
||||
children,
|
||||
}) => {
|
||||
const isHollow = Boolean(isCurrent) && !isCompleted;
|
||||
const isFilled = !isHollow;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative",
|
||||
isCompact ? "pl-8" : "",
|
||||
isOffer && "rounded-lg border border-amber-500/20 bg-amber-500/5 p-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isCompact
|
||||
? "grid grid-cols-[80px_20px_1fr] gap-4"
|
||||
: "grid grid-cols-[100px_24px_1fr] gap-4"
|
||||
}
|
||||
>
|
||||
<div className="text-right text-xs font-medium text-muted-foreground">
|
||||
{date}
|
||||
</div>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<span className="absolute inset-y-0 w-px bg-border" />
|
||||
<div
|
||||
className={
|
||||
isCompact
|
||||
? "relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500 text-white"
|
||||
: isHollow
|
||||
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-background text-emerald-600 animate-pulse"
|
||||
: isOffer
|
||||
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]"
|
||||
: "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white"
|
||||
}
|
||||
>
|
||||
{isFilled && icon}
|
||||
</div>
|
||||
{isLast && (
|
||||
<span className="absolute bottom-0 h-4 w-px bg-background" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
isCompact ? "text-xs font-semibold" : "text-sm font-semibold"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="p-2 cursor-pointer rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit event"
|
||||
>
|
||||
<Edit2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-2 cursor-pointer rounded-md hover:bg-muted text-destructive/70 hover:text-destructive transition-colors"
|
||||
title="Delete event"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -106,6 +106,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 82,
|
||||
suitabilityReason: "Strong fit",
|
||||
tailoredSummary: null,
|
||||
|
||||
@@ -22,6 +22,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 72,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
|
||||
Reference in New Issue
Block a user