initial commit

This commit is contained in:
DaKheera47
2026-01-20 22:11:09 +00:00
parent 0eba05a1dd
commit 1fd6a4b4c2
25 changed files with 537 additions and 45 deletions
+7
View File
@@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
});
}
export async function checkSponsor(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/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<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',
@@ -1,6 +1,8 @@
import React from "react";
import { Calendar, DollarSign, MapPin } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } 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";
@@ -8,6 +10,8 @@ import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constant
interface JobHeaderProps {
job: Job;
className?: string;
showSponsorInfo?: boolean;
onCheckSponsor?: () => Promise<void>;
}
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
@@ -42,7 +46,112 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
interface SponsorBadgeProps {
score: number | null;
names: string | null;
onCheck?: () => Promise<void>;
}
const SponsorBadge: React.FC<SponsorBadgeProps> = ({ 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-5 px-1.5 text-[9px] font-medium text-muted-foreground hover:text-foreground"
onClick={handleCheck}
disabled={isChecking}
>
{isChecking ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<Search className="h-2.5 w-2.5" />
)}
<span className="ml-0.5">{isChecking ? "Checking..." : "Check Visa"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Check if employer is a visa sponsor</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// If no score (and no callback), show nothing
if (score == null || score < 50) {
return null;
}
// Color tokens based on score
const getScoreTokens = (s: number) => {
if (s >= 90) return {
badge: "border-emerald-500/40 bg-emerald-500/15 text-emerald-300",
label: "Visa Sponsor"
};
if (s >= 70) return {
badge: "border-amber-500/40 bg-amber-500/15 text-amber-300",
label: "Likely Sponsor"
};
return {
badge: "border-orange-500/40 bg-orange-500/15 text-orange-300",
label: "Possible Sponsor"
};
};
const tokens = getScoreTokens(score);
const tooltipContent = parsedNames.length > 0
? `${score}% match: ${parsedNames.join(", ")}`
: `${score}% match with visa sponsor list`;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide cursor-help",
tokens.badge
)}
>
<Shield className="h-2.5 w-2.5" />
{score}%
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => {
const deadline = formatDate(job.deadline);
return (
@@ -51,7 +160,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
<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">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span>
{showSponsorInfo && (
<SponsorBadge
score={job.sponsorMatchScore}
names={job.sponsorMatchNames}
onCheck={onCheckSponsor}
/>
)}
</div>
</div>
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
{sourceLabel[job.source]}
@@ -141,7 +141,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// 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<ReadyPanelProps> = ({
return (
<div className="flex flex-col h-full">
<JobHeader job={job} className="pb-4 border-b border-border/40" />
<JobHeader
job={job}
className="pb-4 border-b border-border/40"
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
{/* ─────────────────────────────────────────────────────────────────────
PRIMARY ACTION CLUSTER
@@ -14,6 +14,7 @@ interface DecideModeProps {
onTailor: () => void;
onSkip: () => void;
isSkipping: boolean;
onCheckSponsor?: () => Promise<void>;
}
export const DecideMode: React.FC<DecideModeProps> = ({
@@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onTailor,
onSkip,
isSkipping,
onCheckSponsor,
}) => {
const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl;
@@ -33,7 +35,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
return (
<div className='flex flex-col h-full'>
<div className='space-y-4 pb-4'>
<JobHeader job={job} />
<JobHeader job={job} onCheckSponsor={onCheckSponsor} />
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
<Button
@@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onTailor={() => setMode("tailor")}
onSkip={handleSkip}
isSkipping={isSkipping}
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
) : (
<TailorMode
@@ -33,6 +33,8 @@ const jobFixture: Job = {
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");
@@ -92,6 +92,9 @@ const baseSettings: AppSettings = {
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
}
const renderPage = () => {
+23 -1
View File
@@ -14,6 +14,7 @@ import * as api from "../api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
@@ -41,6 +42,7 @@ export const SettingsPage: React.FC = () => {
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState<boolean | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
@@ -69,6 +71,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
setJobspySitesDraft(data.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(data.overrideShowSponsorInfo)
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
@@ -126,6 +129,9 @@ export const SettingsPage: React.FC = () => {
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true
const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true
const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo
const profileProjects = settings?.profileProjects ?? []
const maxProjectsTotal = profileProjects.length
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
@@ -163,7 +169,8 @@ export const SettingsPage: React.FC = () => {
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null)
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null)
)
}, [
settings,
@@ -192,12 +199,14 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeedDraft,
jobspySitesDraft,
jobspyLinkedinFetchDescriptionDraft,
showSponsorInfoDraft,
overrideJobspyLocation,
overrideJobspyResultsWanted,
overrideJobspyHoursOld,
overrideJobspyCountryIndeed,
overrideJobspySites,
overrideJobspyLinkedinFetchDescription,
overrideShowSponsorInfo,
])
const handleSave = async () => {
@@ -222,6 +231,7 @@ export const SettingsPage: React.FC = () => {
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
@@ -239,6 +249,7 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeed: jobspyCountryIndeedOverride,
jobspySites: jobspySitesOverride,
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
showSponsorInfo: showSponsorInfoOverride,
})
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
@@ -257,6 +268,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
setJobspySitesDraft(updated.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(updated.overrideShowSponsorInfo)
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
@@ -340,6 +352,7 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
})
setSettings(updated)
setModelDraft("")
@@ -358,6 +371,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(null)
setJobspySitesDraft(null)
setJobspyLinkedinFetchDescriptionDraft(null)
setShowSponsorInfoDraft(null)
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
@@ -471,6 +485,14 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<DisplaySettingsSection
showSponsorInfoDraft={showSponsorInfoDraft}
setShowSponsorInfoDraft={setShowSponsorInfoDraft}
defaultShowSponsorInfo={defaultShowSponsorInfo}
effectiveShowSponsorInfo={effectiveShowSponsorInfo}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}
@@ -100,6 +100,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -269,7 +269,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
return (
<div className="space-y-3">
<JobHeader job={selectedJob} />
<JobHeader
job={selectedJob}
onCheckSponsor={async () => {
await api.checkSponsor(selectedJob.id);
await onJobUpdated();
}}
/>
<div className="flex flex-wrap items-center gap-1.5">
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
@@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -0,0 +1,77 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
type DisplaySettingsSectionProps = {
showSponsorInfoDraft: boolean | null
setShowSponsorInfoDraft: (value: boolean | null) => void
defaultShowSponsorInfo: boolean
effectiveShowSponsorInfo: boolean
isLoading: boolean
isSaving: boolean
}
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
showSponsorInfoDraft,
setShowSponsorInfoDraft,
defaultShowSponsorInfo,
effectiveShowSponsorInfo,
isLoading,
isSaving,
}) => {
const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo
return (
<AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="showSponsorInfo"
checked={isChecked}
onCheckedChange={(checked) => {
setShowSponsorInfoDraft(checked === true ? true : false)
}}
disabled={isLoading || isSaving}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="showSponsorInfo"
className="text-sm font-medium leading-none cursor-pointer"
>
Show visa sponsor information
</label>
<p className="text-xs text-muted-foreground">
Display a badge next to the employer name showing the match
percentage with the UK visa sponsor list. This helps identify
employers that are licensed to sponsor work visas.
</p>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}