diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 5322da3..2af6a58 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2231,6 +2232,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2359,6 +2412,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index 1130faf..d810575 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 9b3cae5..6558b78 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise { }); } +export async function checkSponsor(id: string): Promise { + return fetchApi(`/jobs/${id}/check-sponsor`, { + method: 'POST', + }); +} + export async function markAsApplied(id: string): Promise { return fetchApi(`/jobs/${id}/apply`, { method: 'POST', @@ -186,6 +192,7 @@ export async function updateSettings(update: { jobspyCountryIndeed?: string | null jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null + showSponsorInfo?: boolean | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx new file mode 100644 index 0000000..a96407c --- /dev/null +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { JobHeader } from "./JobHeader"; +import { useSettings } from "../hooks/useSettings"; +import type { Job } from "../../shared/types"; + +// Mock useSettings +vi.mock("../hooks/useSettings", () => ({ + useSettings: vi.fn(), +})); + +// Mock api +vi.mock("../api", () => ({ + checkSponsor: vi.fn(), +})); + +// Mock Tooltip components to simplify testing +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const mockJob: Job = { + id: "job-1", + title: "Software Engineer", + employer: "Tech Corp", + location: "London", + salary: "£60,000", + deadline: "2025-12-31", + status: "discovered", + source: "linkedin", + suitabilityScore: 85, + suitabilityReason: "Strong match", + sponsorMatchScore: null, + sponsorMatchNames: null, + // Other fields... +} as Job; + +describe("JobHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + (useSettings as any).mockReturnValue({ + showSponsorInfo: true, + }); + }); + + it("renders basic job information", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Tech Corp")).toBeInTheDocument(); + expect(screen.getByText("London")).toBeInTheDocument(); + expect(screen.getByText("£60,000")).toBeInTheDocument(); + }); + + it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { + const onCheckSponsor = vi.fn().mockResolvedValue(undefined); + render(); + + const button = screen.getByText("Check Sponsorship Status"); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + expect(onCheckSponsor).toHaveBeenCalled(); + }); + + it("shows 'Confirmed Sponsor' when score >= 95", () => { + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' }; + render(); + + expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Potential Sponsor' when score is between 80 and 94", () => { + const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' }; + render(); + + expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Sponsor Not Found' when score < 80", () => { + const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' }; + render(); + + expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); + }); + + it("hides sponsor info when showSponsorInfo is false", () => { + (useSettings as any).mockReturnValue({ + showSponsorInfo: false, + }); + + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; + render(); + + expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); + expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 3d1f9be..2498049 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,13 +1,18 @@ -import React from "react"; -import { Calendar, DollarSign, MapPin } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, formatDate, sourceLabel } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants"; +import { useSettings } from "../hooks/useSettings"; + interface JobHeaderProps { job: Job; className?: string; + onCheckSponsor?: () => Promise; } const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { @@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { ); }; -export const JobHeader: React.FC = ({ job, className }) => { +interface SponsorPillProps { + score: number | null; + names: string | null; + onCheck?: () => Promise; +} + +const SponsorPill: React.FC = ({ score, names, onCheck }) => { + const [isChecking, setIsChecking] = useState(false); + + const parsedNames = useMemo(() => { + if (!names) return []; + try { + return JSON.parse(names) as string[]; + } catch { + return []; + } + }, [names]); + + const handleCheck = async () => { + if (!onCheck) return; + setIsChecking(true); + try { + await onCheck(); + } finally { + setIsChecking(false); + } + }; + + // Show "Check" button if no score and callback provided + if (score == null && onCheck) { + return ( + + + + + + +

Check if employer is a visa sponsor

+
+
+
+ ); + } + + if (score == null) { + return null; + } + + const getStatus = (s: number) => { + if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" }; + if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" }; + return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" }; + }; + + const status = getStatus(score); + const tooltipContent = `${score}% match`; + + return ( + + + + + + {status.label} + + + + {parsedNames.length > 0 && ( +

+ Matched + {parsedNames.join(", ")} +

+ )} +

{tooltipContent}

+
+
+
+ ); +}; + +export const JobHeader: React.FC = ({ job, className, onCheckSponsor }) => { + const { showSponsorInfo } = useSettings(); const deadline = formatDate(job.deadline); return ( @@ -51,7 +150,9 @@ export const JobHeader: React.FC = ({ job, className }) => {
{job.title}
-
{job.employer}
+
+ {job.employer} +
{sourceLabel[job.source]} @@ -82,7 +183,16 @@ export const JobHeader: React.FC = ({ job, className }) => { {/* Status and score: single line, subdued */}
- +
+ + {showSponsorInfo && ( + + )} +
diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index c3f0de6..acc2ade 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -141,7 +141,7 @@ export const ReadyPanel: React.FC = ({ // Revert to ready status await api.updateJob(jobId, { status: "ready" }); toast.success("Reverted to Ready"); - + if (recentlyApplied?.timeoutId) { clearTimeout(recentlyApplied.timeoutId); } @@ -215,7 +215,14 @@ export const ReadyPanel: React.FC = ({ return (
- + { + await api.checkSponsor(job.id); + await onJobUpdated(); + }} + /> {/* ───────────────────────────────────────────────────────────────────── PRIMARY ACTION CLUSTER diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index df9aced..777ea89 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -14,6 +14,7 @@ interface DecideModeProps { onTailor: () => void; onSkip: () => void; isSkipping: boolean; + onCheckSponsor?: () => Promise; } export const DecideMode: React.FC = ({ @@ -21,6 +22,7 @@ export const DecideMode: React.FC = ({ onTailor, onSkip, isSkipping, + onCheckSponsor, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -33,7 +35,10 @@ export const DecideMode: React.FC = ({ return (
- +