ran check:fix in orchestrator

This commit is contained in:
DaKheera47
2026-01-25 12:41:44 +00:00
parent ac02a5fd1d
commit 5c2eef2fc8
178 changed files with 12428 additions and 9311 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}
+1 -1
View File
@@ -7,11 +7,11 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group"; import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate";
import { OrchestratorPage } from "./pages/OrchestratorPage"; import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
import { OnboardingGate } from "./components/OnboardingGate";
export const App: React.FC = () => { export const App: React.FC = () => {
const location = useLocation(); const location = useLocation();
+132 -107
View File
@@ -2,40 +2,40 @@
* API client for the orchestrator backend. * API client for the orchestrator backend.
*/ */
import { trackEvent } from "@/lib/analytics";
import type { import type {
Job,
ApiResponse, ApiResponse,
JobsListResponse,
PipelineStatusResponse,
JobSource,
AppSettings, AppSettings,
ResumeProjectsSettings,
ResumeProjectCatalogItem,
UkVisaJobsSearchResponse,
UkVisaJobsImportResponse,
CreateJobInput, CreateJobInput,
Job,
JobSource,
JobsListResponse,
ManualJobDraft, ManualJobDraft,
ManualJobInferenceResponse,
ManualJobFetchResponse, ManualJobFetchResponse,
ManualJobInferenceResponse,
PipelineStatusResponse,
ProfileStatusResponse,
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
UkVisaJobsImportResponse,
UkVisaJobsSearchResponse,
ValidationResult,
VisaSponsor,
VisaSponsorSearchResponse, VisaSponsorSearchResponse,
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
VisaSponsor, } from "../../shared/types";
ResumeProfile,
ProfileStatusResponse,
ValidationResult,
} from '../../shared/types';
import { trackEvent } from "@/lib/analytics";
const API_BASE = '/api'; const API_BASE = "/api";
async function fetchApi<T>( async function fetchApi<T>(
endpoint: string, endpoint: string,
options?: RequestInit options?: RequestInit,
): Promise<T> { ): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
...options?.headers, ...options?.headers,
}, },
}); });
@@ -47,12 +47,14 @@ async function fetchApi<T>(
data = JSON.parse(text); data = JSON.parse(text);
} catch { } catch {
// If the response is not JSON, it's likely an HTML error page // If the response is not JSON, it's likely an HTML error page
console.error('API returned non-JSON response:', text.substring(0, 500)); console.error("API returned non-JSON response:", text.substring(0, 500));
throw new Error(`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`); throw new Error(
`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`,
);
} }
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'API request failed'); throw new Error(data.error || "API request failed");
} }
return data.data as T; return data.data as T;
@@ -60,7 +62,7 @@ async function fetchApi<T>(
// Jobs API // Jobs API
export async function getJobs(statuses?: string[]): Promise<JobsListResponse> { export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
const query = statuses?.length ? `?status=${statuses.join(',')}` : ''; const query = statuses?.length ? `?status=${statuses.join(",")}` : "";
return fetchApi<JobsListResponse>(`/jobs${query}`); return fetchApi<JobsListResponse>(`/jobs${query}`);
} }
@@ -70,61 +72,67 @@ export async function getJob(id: string): Promise<Job> {
export async function updateJob( export async function updateJob(
id: string, id: string,
update: Partial<Job> update: Partial<Job>,
): Promise<Job> { ): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`, { return fetchApi<Job>(`/jobs/${id}`, {
method: 'PATCH', method: "PATCH",
body: JSON.stringify(update), body: JSON.stringify(update),
}); });
} }
export async function processJob(id: string, options?: { force?: boolean }): Promise<Job> { export async function processJob(
const query = options?.force ? '?force=1' : ''; id: string,
options?: { force?: boolean },
): Promise<Job> {
const query = options?.force ? "?force=1" : "";
return fetchApi<Job>(`/jobs/${id}/process${query}`, { return fetchApi<Job>(`/jobs/${id}/process${query}`, {
method: 'POST', method: "POST",
}); });
} }
export async function rescoreJob(id: string): Promise<Job> { export async function rescoreJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/rescore`, { return fetchApi<Job>(`/jobs/${id}/rescore`, {
method: 'POST', method: "POST",
}); });
} }
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> { export async function summarizeJob(
const query = options?.force ? '?force=1' : ''; id: string,
options?: { force?: boolean },
): Promise<Job> {
const query = options?.force ? "?force=1" : "";
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, { return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
method: 'POST', method: "POST",
}); });
} }
export async function generateJobPdf(id: string): Promise<Job> { export async function generateJobPdf(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/generate-pdf`, { return fetchApi<Job>(`/jobs/${id}/generate-pdf`, {
method: 'POST', method: "POST",
}); });
} }
export async function checkSponsor(id: string): Promise<Job> { export async function checkSponsor(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, { return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
method: 'POST', method: "POST",
}); });
} }
export async function markAsApplied(id: string): Promise<Job> { export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, { return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST', method: "POST",
}); });
} }
export async function skipJob(id: string): Promise<Job> { export async function skipJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/skip`, { return fetchApi<Job>(`/jobs/${id}/skip`, {
method: 'POST', method: "POST",
}); });
} }
// Pipeline API // Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> { export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>('/pipeline/status'); return fetchApi<PipelineStatusResponse>("/pipeline/status");
} }
export async function runPipeline(config?: { export async function runPipeline(config?: {
@@ -132,8 +140,8 @@ export async function runPipeline(config?: {
minSuitabilityScore?: number; minSuitabilityScore?: number;
sources?: JobSource[]; sources?: JobSource[];
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
return fetchApi<{ message: string }>('/pipeline/run', { return fetchApi<{ message: string }>("/pipeline/run", {
method: 'POST', method: "POST",
body: JSON.stringify(config || {}), body: JSON.stringify(config || {}),
}); });
} }
@@ -144,13 +152,13 @@ export async function searchUkVisaJobs(input: {
page?: number; page?: number;
}): Promise<UkVisaJobsSearchResponse> { }): Promise<UkVisaJobsSearchResponse> {
if (input.searchTerm?.trim()) { if (input.searchTerm?.trim()) {
trackEvent('ukvisajobs_search', { trackEvent("ukvisajobs_search", {
searchTerm: input.searchTerm.trim(), searchTerm: input.searchTerm.trim(),
page: input.page ?? 1, page: input.page ?? 1,
}); });
} }
return fetchApi<UkVisaJobsSearchResponse>('/ukvisajobs/search', { return fetchApi<UkVisaJobsSearchResponse>("/ukvisajobs/search", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -158,8 +166,8 @@ export async function searchUkVisaJobs(input: {
export async function importUkVisaJobs(input: { export async function importUkVisaJobs(input: {
jobs: CreateJobInput[]; jobs: CreateJobInput[];
}): Promise<UkVisaJobsImportResponse> { }): Promise<UkVisaJobsImportResponse> {
return fetchApi<UkVisaJobsImportResponse>('/ukvisajobs/import', { return fetchApi<UkVisaJobsImportResponse>("/ukvisajobs/import", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -168,8 +176,8 @@ export async function importUkVisaJobs(input: {
export async function fetchJobFromUrl(input: { export async function fetchJobFromUrl(input: {
url: string; url: string;
}): Promise<ManualJobFetchResponse> { }): Promise<ManualJobFetchResponse> {
return fetchApi<ManualJobFetchResponse>('/manual-jobs/fetch', { return fetchApi<ManualJobFetchResponse>("/manual-jobs/fetch", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -177,8 +185,8 @@ export async function fetchJobFromUrl(input: {
export async function inferManualJob(input: { export async function inferManualJob(input: {
jobDescription: string; jobDescription: string;
}): Promise<ManualJobInferenceResponse> { }): Promise<ManualJobInferenceResponse> {
return fetchApi<ManualJobInferenceResponse>('/manual-jobs/infer', { return fetchApi<ManualJobInferenceResponse>("/manual-jobs/infer", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -186,8 +194,8 @@ export async function inferManualJob(input: {
export async function importManualJob(input: { export async function importManualJob(input: {
job: ManualJobDraft; job: ManualJobDraft;
}): Promise<Job> { }): Promise<Job> {
return fetchApi<Job>('/manual-jobs/import', { return fetchApi<Job>("/manual-jobs/import", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -198,7 +206,7 @@ let settingsPromise: Promise<AppSettings> | null = null;
export async function getSettings(): Promise<AppSettings> { export async function getSettings(): Promise<AppSettings> {
if (settingsPromise) return settingsPromise; if (settingsPromise) return settingsPromise;
settingsPromise = fetchApi<AppSettings>('/settings').finally(() => { settingsPromise = fetchApi<AppSettings>("/settings").finally(() => {
// Clear the promise after a short delay to allow subsequent fresh fetches // Clear the promise after a short delay to allow subsequent fresh fetches
// but coalesce simultaneous requests. // but coalesce simultaneous requests.
setTimeout(() => { setTimeout(() => {
@@ -209,11 +217,15 @@ export async function getSettings(): Promise<AppSettings> {
return settingsPromise; return settingsPromise;
} }
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> { export async function getProfileProjects(): Promise<
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects'); ResumeProjectCatalogItem[]
> {
return fetchApi<ResumeProjectCatalogItem[]>("/profile/projects");
} }
export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogItem[]> { export async function getResumeProjectsCatalog(): Promise<
ResumeProjectCatalogItem[]
> {
try { try {
const settings = await getSettings(); const settings = await getSettings();
if (settings.rxresumeBaseResumeId) { if (settings.rxresumeBaseResumeId) {
@@ -227,85 +239,94 @@ export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogIt
} }
export async function getProfile(): Promise<ResumeProfile> { export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile'); return fetchApi<ResumeProfile>("/profile");
} }
export async function getProfileStatus(): Promise<ProfileStatusResponse> { export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status'); return fetchApi<ProfileStatusResponse>("/profile/status");
} }
export async function refreshProfile(): Promise<ResumeProfile> { export async function refreshProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile/refresh', { return fetchApi<ResumeProfile>("/profile/refresh", {
method: 'POST', method: "POST",
}); });
} }
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> { export async function validateOpenrouter(
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', { apiKey?: string,
method: 'POST', ): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/openrouter", {
method: "POST",
body: JSON.stringify({ apiKey }), body: JSON.stringify({ apiKey }),
}); });
} }
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> { export async function validateRxresume(
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', { email?: string,
method: 'POST', password?: string,
): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
method: "POST",
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
} }
export async function validateResumeConfig(): Promise<ValidationResult> { export async function validateResumeConfig(): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/resume'); return fetchApi<ValidationResult>("/onboarding/validate/resume");
} }
export async function updateSettings(update: { export async function updateSettings(update: {
model?: string | null model?: string | null;
modelScorer?: string | null modelScorer?: string | null;
modelTailoring?: string | null modelTailoring?: string | null;
modelProjectSelection?: string | null modelProjectSelection?: string | null;
pipelineWebhookUrl?: string | null pipelineWebhookUrl?: string | null;
jobCompleteWebhookUrl?: string | null jobCompleteWebhookUrl?: string | null;
resumeProjects?: ResumeProjectsSettings | null resumeProjects?: ResumeProjectsSettings | null;
ukvisajobsMaxJobs?: number | null ukvisajobsMaxJobs?: number | null;
gradcrackerMaxJobsPerTerm?: number | null gradcrackerMaxJobsPerTerm?: number | null;
searchTerms?: string[] | null searchTerms?: string[] | null;
jobspyLocation?: string | null jobspyLocation?: string | null;
jobspyResultsWanted?: number | null jobspyResultsWanted?: number | null;
jobspyHoursOld?: number | null jobspyHoursOld?: number | null;
jobspyCountryIndeed?: string | null jobspyCountryIndeed?: string | null;
jobspySites?: string[] | null jobspySites?: string[] | null;
jobspyLinkedinFetchDescription?: boolean | null jobspyLinkedinFetchDescription?: boolean | null;
showSponsorInfo?: boolean | null showSponsorInfo?: boolean | null;
openrouterApiKey?: string | null openrouterApiKey?: string | null;
rxresumeEmail?: string | null rxresumeEmail?: string | null;
rxresumePassword?: string | null rxresumePassword?: string | null;
basicAuthUser?: string | null basicAuthUser?: string | null;
basicAuthPassword?: string | null basicAuthPassword?: string | null;
ukvisajobsEmail?: string | null ukvisajobsEmail?: string | null;
ukvisajobsPassword?: string | null ukvisajobsPassword?: string | null;
webhookSecret?: string | null webhookSecret?: string | null;
rxresumeBaseResumeId?: string | null rxresumeBaseResumeId?: string | null;
}): Promise<AppSettings> { }): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', { return fetchApi<AppSettings>("/settings", {
method: 'PATCH', method: "PATCH",
body: JSON.stringify(update), body: JSON.stringify(update),
}); });
} }
export async function getRxResumes(): Promise<{ id: string; name: string }[]> { export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>('/settings/rx-resumes'); const data = await fetchApi<{ resumes: { id: string; name: string }[] }>(
"/settings/rx-resumes",
);
return data.resumes; return data.resumes;
} }
export async function getRxResumeProjects(resumeId: string, signal?: AbortSignal): Promise<ResumeProjectCatalogItem[]> { export async function getRxResumeProjects(
resumeId: string,
signal?: AbortSignal,
): Promise<ResumeProjectCatalogItem[]> {
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`, `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
{ signal } { signal },
); );
return data.projects; return data.projects;
} }
// Database API // Database API
export async function clearDatabase(): Promise<{ export async function clearDatabase(): Promise<{
message: string; message: string;
@@ -316,8 +337,8 @@ export async function clearDatabase(): Promise<{
message: string; message: string;
jobsDeleted: number; jobsDeleted: number;
runsDeleted: number; runsDeleted: number;
}>('/database', { }>("/database", {
method: 'DELETE', method: "DELETE",
}); });
} }
@@ -329,13 +350,13 @@ export async function deleteJobsByStatus(status: string): Promise<{
message: string; message: string;
count: number; count: number;
}>(`/jobs/status/${status}`, { }>(`/jobs/status/${status}`, {
method: 'DELETE', method: "DELETE",
}); });
} }
// Visa Sponsors API // Visa Sponsors API
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> { export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
return fetchApi<VisaSponsorStatusResponse>('/visa-sponsors/status'); return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");
} }
export async function searchVisaSponsors(input: { export async function searchVisaSponsors(input: {
@@ -344,20 +365,24 @@ export async function searchVisaSponsors(input: {
minScore?: number; minScore?: number;
}): Promise<VisaSponsorSearchResponse> { }): Promise<VisaSponsorSearchResponse> {
if (input.query?.trim()) { if (input.query?.trim()) {
trackEvent('visa_sponsor_search', { trackEvent("visa_sponsor_search", {
query: input.query.trim(), query: input.query.trim(),
limit: input.limit, limit: input.limit,
minScore: input.minScore, minScore: input.minScore,
}); });
} }
return fetchApi<VisaSponsorSearchResponse>('/visa-sponsors/search', { return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {
method: 'POST', method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export async function getVisaSponsorOrganization(name: string): Promise<VisaSponsor[]> { export async function getVisaSponsorOrganization(
return fetchApi<VisaSponsor[]>(`/visa-sponsors/organization/${encodeURIComponent(name)}`); name: string,
): Promise<VisaSponsor[]> {
return fetchApi<VisaSponsor[]>(
`/visa-sponsors/organization/${encodeURIComponent(name)}`,
);
} }
export async function updateVisaSponsorList(): Promise<{ export async function updateVisaSponsorList(): Promise<{
@@ -367,8 +392,8 @@ export async function updateVisaSponsorList(): Promise<{
return fetchApi<{ return fetchApi<{
message: string; message: string;
status: VisaSponsorStatusResponse; status: VisaSponsorStatusResponse;
}>('/visa-sponsors/update', { }>("/visa-sponsors/update", {
method: 'POST', method: "POST",
}); });
} }
+1 -1
View File
@@ -1 +1 @@
export * from './client'; export * from "./client";
@@ -1,5 +1,5 @@
import React from "react";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
+42 -40
View File
@@ -2,7 +2,6 @@
* Header component with logo and pipeline trigger. * Header component with logo and pipeline trigger.
*/ */
import React from "react";
import { import {
Briefcase, Briefcase,
ChevronDown, ChevronDown,
@@ -14,6 +13,7 @@ import {
Settings, Settings,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import React from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -56,7 +56,12 @@ export const Header: React.FC<HeaderProps> = ({
const location = useLocation(); const location = useLocation();
const [sheetOpen, setSheetOpen] = React.useState(false); const [sheetOpen, setSheetOpen] = React.useState(false);
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; const orderedSources: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
const navLinks = [ const navLinks = [
{ to: "/", label: "Dashboard", icon: Home }, { to: "/", label: "Dashboard", icon: Home },
@@ -75,23 +80,21 @@ export const Header: React.FC<HeaderProps> = ({
}; };
return ( return (
<header className='sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60'> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className='container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4'> <div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className='flex items-center gap-3'> <div className="flex items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant='ghost' size='icon'> <Button variant="ghost" size="icon">
<Menu className='h-5 w-5' /> <Menu className="h-5 w-5" />
<span className='sr-only'>Open navigation menu</span> <span className="sr-only">Open navigation menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side='left' className='w-64'> <SheetContent side="left" className="w-64">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>JobOps</SheetTitle>
JobOps
</SheetTitle>
</SheetHeader> </SheetHeader>
<nav className='mt-6 flex flex-col gap-2'> <nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => ( {navLinks.map(({ to, label, icon: Icon }) => (
<Link <Link
key={to} key={to}
@@ -103,7 +106,7 @@ export const Header: React.FC<HeaderProps> = ({
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
> >
<Icon className='h-4 w-4' /> <Icon className="h-4 w-4" />
{label} {label}
</Link> </Link>
))} ))}
@@ -112,49 +115,51 @@ export const Header: React.FC<HeaderProps> = ({
</Sheet> </Sheet>
<Link <Link
to='/' to="/"
className='flex items-center gap-3 hover:opacity-80 transition-opacity' className="flex items-center gap-3 hover:opacity-80 transition-opacity"
> >
<div className='flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm'> <div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm">
<img <img
src='/favicon.png' src="/favicon.png"
alt='Job Ops Logo' alt="Job Ops Logo"
className='h-full w-full object-contain' className="h-full w-full object-contain"
/> />
</div> </div>
<div className='leading-tight'> <div className="leading-tight">
<div className='text-sm font-semibold tracking-tight'>Job Ops</div> <div className="text-sm font-semibold tracking-tight">
<div className='text-xs text-muted-foreground'>Orchestrator</div> Job Ops
</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div> </div>
</Link> </Link>
</div> </div>
<div className='flex flex-wrap items-center gap-1.5'> <div className="flex flex-wrap items-center gap-1.5">
<Button <Button
variant='outline' variant="outline"
size='sm' size="sm"
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
> >
<RefreshCcw className='h-4 w-4' /> <RefreshCcw className="h-4 w-4" />
<span className='hidden sm:inline'>Refresh</span> <span className="hidden sm:inline">Refresh</span>
</Button> </Button>
<div> <div>
<Button <Button
size='sm' size="sm"
onClick={onRunPipeline} onClick={onRunPipeline}
disabled={isPipelineRunning} disabled={isPipelineRunning}
className='rounded-r-none' className="rounded-r-none"
> >
{isPipelineRunning ? ( {isPipelineRunning ? (
<> <>
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className="h-4 w-4 animate-spin" />
Running... Running...
</> </>
) : ( ) : (
<> <>
<Play className='h-4 w-4' /> <Play className="h-4 w-4" />
Run Pipeline Run Pipeline
</> </>
)} )}
@@ -163,18 +168,15 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
size='sm' size="sm"
disabled={isPipelineRunning} disabled={isPipelineRunning}
className='rounded-l-none border-l border-primary-foreground/20' className="rounded-l-none border-l border-primary-foreground/20"
aria-label='Select pipeline sources' aria-label="Select pipeline sources"
> >
<ChevronDown className='h-4 w-4' /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end" className="w-56">
align='end'
className='w-56'
>
<DropdownMenuLabel>Sources</DropdownMenuLabel> <DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{orderedSources.map((source) => ( {orderedSources.map((source) => (
@@ -1,104 +1,122 @@
import React from "react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import type React from "react";
import { render, screen, fireEvent } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { JobHeader } from "./JobHeader";
import { useSettings } from "../hooks/useSettings";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
import { useSettings } from "../hooks/useSettings";
import { JobHeader } from "./JobHeader";
// Mock useSettings // Mock useSettings
vi.mock("../hooks/useSettings", () => ({ vi.mock("../hooks/useSettings", () => ({
useSettings: vi.fn(), useSettings: vi.fn(),
})); }));
// Mock api // Mock api
vi.mock("../api", () => ({ vi.mock("../api", () => ({
checkSponsor: vi.fn(), checkSponsor: vi.fn(),
})); }));
// Mock Tooltip components to simplify testing // Mock Tooltip components to simplify testing
vi.mock("@/components/ui/tooltip", () => ({ vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, TooltipProvider: ({ children }: { children: React.ReactNode }) => (
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, <>{children}</>
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, ),
TooltipContent: ({ children }: { children: React.ReactNode }) => ( Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
<div data-testid="tooltip-content">{children}</div> TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
), <>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
})); }));
const mockJob: Job = { const mockJob: Job = {
id: "job-1", id: "job-1",
title: "Software Engineer", title: "Software Engineer",
employer: "Tech Corp", employer: "Tech Corp",
location: "London", location: "London",
salary: "£60,000", salary: "£60,000",
deadline: "2025-12-31", deadline: "2025-12-31",
status: "discovered", status: "discovered",
source: "linkedin", source: "linkedin",
suitabilityScore: 85, suitabilityScore: 85,
suitabilityReason: "Strong match", suitabilityReason: "Strong match",
sponsorMatchScore: null, sponsorMatchScore: null,
sponsorMatchNames: null, sponsorMatchNames: null,
// Other fields... // Other fields...
} as Job; } as Job;
describe("JobHeader", () => { describe("JobHeader", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(useSettings as any).mockReturnValue({ (useSettings as any).mockReturnValue({
showSponsorInfo: true, showSponsorInfo: true,
}); });
});
it("renders basic job information", () => {
render(<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("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
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(<JobHeader job={jobWithSponsor} />);
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(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
it("shows 'Sponsor Not Found' when score < 80", () => {
const jobNoSponsor = {
...mockJob,
sponsorMatchScore: 40,
sponsorMatchNames: '["Other Corp"]',
};
render(<JobHeader job={jobNoSponsor} />);
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
});
it("hides sponsor info when showSponsorInfo is false", () => {
(useSettings as any).mockReturnValue({
showSponsorInfo: false,
}); });
it("renders basic job information", () => { const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={mockJob} />); render(<JobHeader job={jobWithSponsor} />);
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 () => { expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
const onCheckSponsor = vi.fn().mockResolvedValue(undefined); expect(
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />); screen.queryByText("Check Sponsorship Status"),
).not.toBeInTheDocument();
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(<JobHeader job={jobWithSponsor} />);
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(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
it("shows 'Sponsor Not Found' when score < 80", () => {
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
render(<JobHeader job={jobNoSponsor} />);
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(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
});
}); });
@@ -1,13 +1,21 @@
import React, { useMemo, useState } from "react";
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
import type React from "react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, formatDate, sourceLabel } from "@/lib/utils"; import { cn, formatDate, sourceLabel } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types"; import type { Job, JobStatus } from "../../shared/types";
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
import { useSettings } from "../hooks/useSettings"; import { useSettings } from "../hooks/useSettings";
import {
defaultStatusToken,
statusTokens,
} from "../pages/orchestrator/constants";
interface JobHeaderProps { interface JobHeaderProps {
job: Job; job: Job;
@@ -93,7 +101,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
) : ( ) : (
<Search className="h-2 w-2" /> <Search className="h-2 w-2" />
)} )}
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span> <span>
{isChecking ? "Checking..." : "Check Sponsorship Status"}
</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
@@ -109,9 +119,23 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
} }
const getStatus = (s: number) => { const getStatus = (s: number) => {
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" }; if (s >= 95)
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" }; return {
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" }; 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 status = getStatus(score);
@@ -122,7 +146,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help"> <span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} /> <span
className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)}
/>
{status.label} {status.label}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -140,7 +166,11 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
); );
}; };
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => { export const JobHeader: React.FC<JobHeaderProps> = ({
job,
className,
onCheckSponsor,
}) => {
const { showSponsorInfo } = useSettings(); const { showSponsorInfo } = useSettings();
const deadline = formatDate(job.deadline); const deadline = formatDate(job.deadline);
@@ -149,12 +179,17 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSpo
{/* Detail header: lighter weight than list items */} {/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div> <div className="truncate text-base font-semibold text-foreground/90">
{job.title}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span> <span>{job.employer}</span>
</div> </div>
</div> </div>
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"> <Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
{sourceLabel[job.source]} {sourceLabel[job.source]}
</Badge> </Badge>
</div> </div>
@@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ManualImportSheet } from "./ManualImportSheet";
import * as api from "../api";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { ManualImportSheet } from "./ManualImportSheet";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
fetchJobFromUrl: vi.fn(), fetchJobFromUrl: vi.fn(),
@@ -38,19 +37,29 @@ describe("ManualImportSheet", () => {
vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any); vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any);
render( render(
<ManualImportSheet open onOpenChange={onOpenChange} onImported={onImported} /> <ManualImportSheet
open
onOpenChange={onOpenChange}
onImported={onImported}
/>,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), screen.getByPlaceholderText(
{ target: { value: rawDescription } } "Paste the full job description here, or enter a URL above to fetch it...",
),
{ target: { value: rawDescription } },
); );
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
const titleInput = await screen.findByPlaceholderText("e.g. Junior Backend Engineer"); const titleInput = await screen.findByPlaceholderText(
"e.g. Junior Backend Engineer",
);
expect(titleInput).toHaveValue("Backend Engineer"); expect(titleInput).toHaveValue("Backend Engineer");
const jdTextarea = screen.getByPlaceholderText("Paste the job description...") as HTMLTextAreaElement; const jdTextarea = screen.getByPlaceholderText(
"Paste the job description...",
) as HTMLTextAreaElement;
expect(jdTextarea.value).toBe(rawDescription.trim()); expect(jdTextarea.value).toBe(rawDescription.trim());
fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), { fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), {
@@ -76,7 +85,7 @@ describe("ManualImportSheet", () => {
"Job imported", "Job imported",
expect.objectContaining({ expect.objectContaining({
description: expect.any(String), description: expect.any(String),
}) }),
); );
}); });
@@ -89,12 +98,14 @@ describe("ManualImportSheet", () => {
}); });
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), screen.getByPlaceholderText(
{ target: { value: rawDescription } } "Paste the full job description here, or enter a URL above to fetch it...",
),
{ target: { value: rawDescription } },
); );
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -103,9 +114,12 @@ describe("ManualImportSheet", () => {
const importButton = screen.getByRole("button", { name: /import job/i }); const importButton = screen.getByRole("button", { name: /import job/i });
expect(importButton).toBeDisabled(); expect(importButton).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText("e.g. Junior Backend Engineer"), { fireEvent.change(
target: { value: "QA Engineer" }, screen.getByPlaceholderText("e.g. Junior Backend Engineer"),
}); {
target: { value: "QA Engineer" },
},
);
fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), { fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), {
target: { value: "Acme Labs" }, target: { value: "Acme Labs" },
}); });
@@ -116,22 +130,28 @@ describe("ManualImportSheet", () => {
it("returns to the paste step when inference fails", async () => { it("returns to the paste step when inference fails", async () => {
const rawDescription = "Backend role description."; const rawDescription = "Backend role description.";
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed")); vi.mocked(api.inferManualJob).mockRejectedValue(
new Error("Inference failed"),
);
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), screen.getByPlaceholderText(
{ target: { value: rawDescription } } "Paste the full job description here, or enter a URL above to fetch it...",
),
{ target: { value: rawDescription } },
); );
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
await screen.findByText("Inference failed"); await screen.findByText("Inference failed");
expect(screen.getByRole("button", { name: /analyze jd/i })).toBeInTheDocument();
expect( expect(
screen.queryByPlaceholderText("e.g. Junior Backend Engineer") screen.getByRole("button", { name: /analyze jd/i }),
).toBeInTheDocument();
expect(
screen.queryByPlaceholderText("e.g. Junior Backend Engineer"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -142,17 +162,25 @@ describe("ManualImportSheet", () => {
employer: "Acme Labs", employer: "Acme Labs",
}, },
}); });
vi.mocked(api.importManualJob).mockRejectedValue(new Error("Import failed")); vi.mocked(api.importManualJob).mockRejectedValue(
new Error("Import failed"),
);
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
render( render(
<ManualImportSheet open onOpenChange={onOpenChange} onImported={vi.fn()} /> <ManualImportSheet
open
onOpenChange={onOpenChange}
onImported={vi.fn()}
/>,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), screen.getByPlaceholderText(
{ target: { value: "Backend Engineer role." } } "Paste the full job description here, or enter a URL above to fetch it...",
),
{ target: { value: "Backend Engineer role." } },
); );
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
@@ -161,7 +189,7 @@ describe("ManualImportSheet", () => {
fireEvent.click(screen.getByRole("button", { name: /import job/i })); fireEvent.click(screen.getByRole("button", { name: /import job/i }));
await waitFor(() => await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Import failed") expect(toast.error).toHaveBeenCalledWith("Import failed"),
); );
expect(onOpenChange).not.toHaveBeenCalled(); expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled(); expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
@@ -170,20 +198,24 @@ describe("ManualImportSheet", () => {
describe("URL fetch functionality", () => { describe("URL fetch functionality", () => {
it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => { it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => {
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
// Initially should show Paste button // Initially should show Paste button
expect(screen.getByRole("button", { name: /paste/i })).toBeInTheDocument(); expect(
screen.getByRole("button", { name: /paste/i }),
).toBeInTheDocument();
// Enter a URL // Enter a URL
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"), screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } } { target: { value: "https://example.com/job" } },
); );
// Should now show Fetch button // Should now show Fetch button
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument(); expect(
screen.getByRole("button", { name: /fetch/i }),
).toBeInTheDocument();
}); });
it("fetches URL and proceeds to review on successful fetch", async () => { it("fetches URL and proceeds to review on successful fetch", async () => {
@@ -201,13 +233,13 @@ describe("ManualImportSheet", () => {
}); });
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
// Enter a URL // Enter a URL
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"), screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } } { target: { value: "https://example.com/job" } },
); );
// Click Fetch // Click Fetch
@@ -224,8 +256,12 @@ describe("ManualImportSheet", () => {
}); });
// Check inferred values are shown // Check inferred values are shown
expect(screen.getByPlaceholderText("e.g. Junior Backend Engineer")).toHaveValue("Software Engineer"); expect(
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue("Acme Corp"); screen.getByPlaceholderText("e.g. Junior Backend Engineer"),
).toHaveValue("Software Engineer");
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue(
"Acme Corp",
);
}); });
it("preserves fetched URL in the job URL field", async () => { it("preserves fetched URL in the job URL field", async () => {
@@ -241,12 +277,12 @@ describe("ManualImportSheet", () => {
}); });
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"), screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } } { target: { value: "https://example.com/job" } },
); );
fireEvent.click(screen.getByRole("button", { name: /fetch/i })); fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
@@ -258,22 +294,28 @@ describe("ManualImportSheet", () => {
}); });
it("shows error and returns to paste step when fetch fails", async () => { it("shows error and returns to paste step when fetch fails", async () => {
vi.mocked(api.fetchJobFromUrl).mockRejectedValue(new Error("Failed to fetch URL")); vi.mocked(api.fetchJobFromUrl).mockRejectedValue(
new Error("Failed to fetch URL"),
);
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"), screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/bad-url" } } { target: { value: "https://example.com/bad-url" } },
); );
fireEvent.click(screen.getByRole("button", { name: /fetch/i })); fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
await screen.findByText("Failed to fetch URL"); await screen.findByText("Failed to fetch URL");
// Should still be on paste step // Should still be on paste step
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument(); expect(
screen.getByPlaceholderText(
"Paste the full job description here, or enter a URL above to fetch it...",
),
).toBeInTheDocument();
}); });
it("shows error when inference fails after fetch", async () => { it("shows error when inference fails after fetch", async () => {
@@ -281,22 +323,28 @@ describe("ManualImportSheet", () => {
content: "Job content", content: "Job content",
url: "https://example.com/job", url: "https://example.com/job",
}); });
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed")); vi.mocked(api.inferManualJob).mockRejectedValue(
new Error("Inference failed"),
);
render( render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} /> <ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
); );
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"), screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } } { target: { value: "https://example.com/job" } },
); );
fireEvent.click(screen.getByRole("button", { name: /fetch/i })); fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
await screen.findByText("Inference failed"); await screen.findByText("Inference failed");
// Should be back on paste step // Should be back on paste step
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument(); expect(
screen.getByPlaceholderText(
"Paste the full job description here, or enter a URL above to fetch it...",
),
).toBeInTheDocument();
}); });
}); });
}); });
@@ -2,8 +2,16 @@
* Manual job import flow (paste JD -> infer -> review -> import). * Manual job import flow (paste JD -> infer -> review -> import).
*/ */
import React, { useEffect, useMemo, useState } from "react"; import {
import { ArrowLeft, ClipboardPaste, FileText, Link, Loader2, Sparkles } from "lucide-react"; ArrowLeft,
ClipboardPaste,
FileText,
Link,
Loader2,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,8 +26,8 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import * as api from "../api";
import type { ManualJobDraft } from "../../shared/types"; import type { ManualJobDraft } from "../../shared/types";
import * as api from "../api";
type ManualImportStep = "paste" | "loading" | "review"; type ManualImportStep = "paste" | "loading" | "review";
@@ -57,7 +65,10 @@ const emptyDraft: ManualJobDraftState = {
starting: "", starting: "",
}; };
const normalizeDraft = (draft?: ManualJobDraft | null, jd?: string): ManualJobDraftState => ({ const normalizeDraft = (
draft?: ManualJobDraft | null,
jd?: string,
): ManualJobDraftState => ({
...emptyDraft, ...emptyDraft,
title: draft?.title ?? "", title: draft?.title ?? "",
employer: draft?.employer ?? "", employer: draft?.employer ?? "",
@@ -136,7 +147,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex]; const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading"; const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
const canFetch = fetchUrl.trim().length > 0 && !isFetching && step === "paste"; const canFetch =
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
const canImport = useMemo(() => { const canImport = useMemo(() => {
if (step !== "review") return false; if (step !== "review") return false;
return ( return (
@@ -163,7 +175,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
// Automatically proceed to analysis // Automatically proceed to analysis
setStep("loading"); setStep("loading");
const inferResponse = await api.inferManualJob({ jobDescription: fetchedContent }); const inferResponse = await api.inferManualJob({
jobDescription: fetchedContent,
});
// Don't pass raw HTML as job description - let user fill it in or use inferred data // Don't pass raw HTML as job description - let user fill it in or use inferred data
const normalized = normalizeDraft(inferResponse.job); const normalized = normalizeDraft(inferResponse.job);
@@ -176,7 +190,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setWarning(inferResponse.warning ?? null); setWarning(inferResponse.warning ?? null);
setStep("review"); setStep("review");
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch URL"; const message =
err instanceof Error ? err.message : "Failed to fetch URL";
setError(message); setError(message);
setIsFetching(false); setIsFetching(false);
setStep("paste"); setStep("paste");
@@ -193,7 +208,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setError(null); setError(null);
setWarning(null); setWarning(null);
setStep("loading"); setStep("loading");
const response = await api.inferManualJob({ jobDescription: rawDescription }); const response = await api.inferManualJob({
jobDescription: rawDescription,
});
const normalized = normalizeDraft(response.job, rawDescription.trim()); const normalized = normalizeDraft(response.job, rawDescription.trim());
// Preserve the fetched URL if we fetched from a URL // Preserve the fetched URL if we fetched from a URL
if (draft.jobUrl && !normalized.jobUrl) { if (draft.jobUrl && !normalized.jobUrl) {
@@ -203,7 +220,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setWarning(response.warning ?? null); setWarning(response.warning ?? null);
setStep("review"); setStep("review");
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to analyze job description"; const message =
err instanceof Error
? err.message
: "Failed to analyze job description";
setError(message); setError(message);
setStep("paste"); setStep("paste");
} }
@@ -222,7 +242,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
await onImported(created.id); await onImported(created.id);
onOpenChange(false); onOpenChange(false);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to import job"; const message =
err instanceof Error ? err.message : "Failed to import job";
toast.error(message); toast.error(message);
} finally { } finally {
setIsImporting(false); setIsImporting(false);
@@ -241,7 +262,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
Manual Import Manual Import
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
Paste a job description, review the AI draft, then import the role. Paste a job description, review the AI draft, then import the
role.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -306,7 +328,11 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
) : ( ) : (
<ClipboardPaste className="h-4 w-4" /> <ClipboardPaste className="h-4 w-4" />
)} )}
{isFetching ? "Fetching..." : fetchUrl.trim() ? "Fetch" : "Paste"} {isFetching
? "Fetching..."
: fetchUrl.trim()
? "Fetch"
: "Paste"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -347,7 +373,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
{step === "loading" && ( {step === "loading" && (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="text-sm font-semibold">Analyzing job description</div> <div className="text-sm font-semibold">
Analyzing job description
</div>
<p className="text-xs text-muted-foreground max-w-xs"> <p className="text-xs text-muted-foreground max-w-xs">
Extracting title, company, location, and other details. Extracting title, company, location, and other details.
</p> </p>
@@ -380,106 +408,197 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Title *</label> <label className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input <Input
value={draft.title} value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
title: event.target.value,
}))
}
placeholder="e.g. Junior Backend Engineer" placeholder="e.g. Junior Backend Engineer"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Employer *</label> <label className="text-xs font-medium text-muted-foreground">
Employer *
</label>
<Input <Input
value={draft.employer} value={draft.employer}
onChange={(event) => setDraft((prev) => ({ ...prev, employer: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
employer: event.target.value,
}))
}
placeholder="e.g. Acme Labs" placeholder="e.g. Acme Labs"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Location</label> <label className="text-xs font-medium text-muted-foreground">
Location
</label>
<Input <Input
value={draft.location} value={draft.location}
onChange={(event) => setDraft((prev) => ({ ...prev, location: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
location: event.target.value,
}))
}
placeholder="e.g. London, UK" placeholder="e.g. London, UK"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Salary</label> <label className="text-xs font-medium text-muted-foreground">
Salary
</label>
<Input <Input
value={draft.salary} value={draft.salary}
onChange={(event) => setDraft((prev) => ({ ...prev, salary: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
salary: event.target.value,
}))
}
placeholder="e.g. GBP 45k-55k" placeholder="e.g. GBP 45k-55k"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Deadline</label> <label className="text-xs font-medium text-muted-foreground">
Deadline
</label>
<Input <Input
value={draft.deadline} value={draft.deadline}
onChange={(event) => setDraft((prev) => ({ ...prev, deadline: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
deadline: event.target.value,
}))
}
placeholder="e.g. 30 Sep 2025" placeholder="e.g. 30 Sep 2025"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Job type</label> <label className="text-xs font-medium text-muted-foreground">
Job type
</label>
<Input <Input
value={draft.jobType} value={draft.jobType}
onChange={(event) => setDraft((prev) => ({ ...prev, jobType: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
jobType: event.target.value,
}))
}
placeholder="e.g. Full-time" placeholder="e.g. Full-time"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Job level</label> <label className="text-xs font-medium text-muted-foreground">
Job level
</label>
<Input <Input
value={draft.jobLevel} value={draft.jobLevel}
onChange={(event) => setDraft((prev) => ({ ...prev, jobLevel: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
jobLevel: event.target.value,
}))
}
placeholder="e.g. Graduate" placeholder="e.g. Graduate"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Job function</label> <label className="text-xs font-medium text-muted-foreground">
Job function
</label>
<Input <Input
value={draft.jobFunction} value={draft.jobFunction}
onChange={(event) => setDraft((prev) => ({ ...prev, jobFunction: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
jobFunction: event.target.value,
}))
}
placeholder="e.g. Software Engineering" placeholder="e.g. Software Engineering"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Disciplines</label> <label className="text-xs font-medium text-muted-foreground">
Disciplines
</label>
<Input <Input
value={draft.disciplines} value={draft.disciplines}
onChange={(event) => setDraft((prev) => ({ ...prev, disciplines: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
disciplines: event.target.value,
}))
}
placeholder="e.g. Computer Science" placeholder="e.g. Computer Science"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Degree required</label> <label className="text-xs font-medium text-muted-foreground">
Degree required
</label>
<Input <Input
value={draft.degreeRequired} value={draft.degreeRequired}
onChange={(event) => setDraft((prev) => ({ ...prev, degreeRequired: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
degreeRequired: event.target.value,
}))
}
placeholder="e.g. BSc or MSc" placeholder="e.g. BSc or MSc"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Starting</label> <label className="text-xs font-medium text-muted-foreground">
Starting
</label>
<Input <Input
value={draft.starting} value={draft.starting}
onChange={(event) => setDraft((prev) => ({ ...prev, starting: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
starting: event.target.value,
}))
}
placeholder="e.g. Summer 2026" placeholder="e.g. Summer 2026"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Job URL</label> <label className="text-xs font-medium text-muted-foreground">
Job URL
</label>
<Input <Input
value={draft.jobUrl} value={draft.jobUrl}
onChange={(event) => setDraft((prev) => ({ ...prev, jobUrl: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
jobUrl: event.target.value,
}))
}
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Application link</label> <label className="text-xs font-medium text-muted-foreground">
Application link
</label>
<Input <Input
value={draft.applicationLink} value={draft.applicationLink}
onChange={(event) => setDraft((prev) => ({ ...prev, applicationLink: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
applicationLink: event.target.value,
}))
}
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
@@ -491,7 +610,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</label> </label>
<Textarea <Textarea
value={draft.jobDescription} value={draft.jobDescription}
onChange={(event) => setDraft((prev) => ({ ...prev, jobDescription: event.target.value }))} onChange={(event) =>
setDraft((prev) => ({
...prev,
jobDescription: event.target.value,
}))
}
className="min-h-[200px] font-mono text-sm leading-relaxed" className="min-h-[200px] font-mono text-sm leading-relaxed"
placeholder="Paste the job description..." placeholder="Paste the job description..."
/> />
@@ -502,7 +626,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<Button <Button
onClick={handleImport} onClick={handleImport}
disabled={!canImport || isImporting} disabled={!canImport || isImporting}
className={cn("w-full h-10 gap-2", !canImport && "opacity-70")} className={cn(
"w-full h-10 gap-2",
!canImport && "opacity-70",
)}
> >
{isImporting ? ( {isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -1,119 +1,154 @@
import React, { useCallback, useEffect, useMemo, useState } from "react" import * as api from "@client/api";
import { Check } from "lucide-react" import { useSettings } from "@client/hooks/useSettings";
import { toast } from "sonner" import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { formatSecretHint } from "@client/pages/settings/utils";
import type { ValidationResult } from "@shared/types";
import { Check } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" type ValidationState = ValidationResult & { checked: boolean };
import { Button } from "@/components/ui/button"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils"
import * as api from "@client/api"
import { useSettings } from "@client/hooks/useSettings"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"
import type { ValidationResult } from "@shared/types"
type ValidationState = ValidationResult & { checked: boolean }
export const OnboardingGate: React.FC = () => { export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() const {
const [isSavingEnv, setIsSavingEnv] = useState(false) settings,
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) isLoading: settingsLoading,
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) refreshSettings,
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false) } = useSettings();
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({ const [isSavingEnv, setIsSavingEnv] = useState(false);
valid: false, const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false);
message: null, const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
checked: false, const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
}) const [openrouterValidation, setOpenrouterValidation] =
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({ useState<ValidationState>({
valid: false, valid: false,
message: null, message: null,
checked: false, checked: false,
}) });
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({ const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
valid: false, {
message: null, valid: false,
checked: false, message: null,
}) checked: false,
const [currentStep, setCurrentStep] = useState<string | null>(null) },
);
const [baseResumeValidation, setBaseResumeValidation] =
useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("");
const [rxresumePassword, setRxresumePassword] = useState("") const [rxresumePassword, setRxresumePassword] = useState("");
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(null) const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
string | null
>(null);
const validateOpenrouter = useCallback(async (apiKey?: string) => { const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true) setIsValidatingOpenrouter(true);
try { try {
const result = await api.validateOpenrouter(apiKey) const result = await api.validateOpenrouter(apiKey);
setOpenrouterValidation({ ...result, checked: true }) setOpenrouterValidation({ ...result, checked: true });
return result return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "OpenRouter validation failed" const message =
const result = { valid: false, message } error instanceof Error ? error.message : "OpenRouter validation failed";
setOpenrouterValidation({ ...result, checked: true }) const result = { valid: false, message };
return result setOpenrouterValidation({ ...result, checked: true });
return result;
} finally { } finally {
setIsValidatingOpenrouter(false) setIsValidatingOpenrouter(false);
} }
}, []) }, []);
const validateRxresume = useCallback(async (email?: string, password?: string) => { const validateRxresume = useCallback(
setIsValidatingRxresume(true) async (email?: string, password?: string) => {
try { setIsValidatingRxresume(true);
const result = await api.validateRxresume(email, password) try {
setRxresumeValidation({ ...result, checked: true }) const result = await api.validateRxresume(email, password);
return result setRxresumeValidation({ ...result, checked: true });
} catch (error) { return result;
const message = error instanceof Error ? error.message : "RxResume validation failed" } catch (error) {
const result = { valid: false, message } const message =
setRxresumeValidation({ ...result, checked: true }) error instanceof Error ? error.message : "RxResume validation failed";
return result const result = { valid: false, message };
} finally { setRxresumeValidation({ ...result, checked: true });
setIsValidatingRxresume(false) return result;
} } finally {
}, []) setIsValidatingRxresume(false);
}
},
[],
);
const validateBaseResume = useCallback(async () => { const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true) setIsValidatingBaseResume(true);
try { try {
const result = await api.validateResumeConfig() const result = await api.validateResumeConfig();
setBaseResumeValidation({ ...result, checked: true }) setBaseResumeValidation({ ...result, checked: true });
return result return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Base resume validation failed" const message =
const result = { valid: false, message } error instanceof Error
setBaseResumeValidation({ ...result, checked: true }) ? error.message
return result : "Base resume validation failed";
const result = { valid: false, message };
setBaseResumeValidation({ ...result, checked: true });
return result;
} finally { } finally {
setIsValidatingBaseResume(false) setIsValidatingBaseResume(false);
} }
}, []) }, []);
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const shouldOpen = Boolean(settings && !settingsLoading) const shouldOpen =
&& !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid) Boolean(settings && !settingsLoading) &&
!(
openrouterValidation.valid &&
rxresumeValidation.valid &&
baseResumeValidation.valid
);
const openrouterCurrent = settings?.openrouterApiKeyHint const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint) ? formatSecretHint(settings.openrouterApiKeyHint)
: undefined : undefined;
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail ? settings.rxresumeEmail
: undefined : undefined;
const rxresumePasswordCurrent = settings?.rxresumePasswordHint const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings.rxresumePasswordHint) ? formatSecretHint(settings.rxresumePasswordHint)
: undefined : undefined;
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null) setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
} }
}, [settings]) }, [settings]);
const steps = useMemo( const steps = useMemo(
() => [ () => [
@@ -139,189 +174,236 @@ export const OnboardingGate: React.FC = () => {
disabled: !rxresumeValidation.valid, disabled: !rxresumeValidation.valid,
}, },
], ],
[openrouterValidation.valid, rxresumeValidation.valid, baseResumeValidation.valid] [
) openrouterValidation.valid,
rxresumeValidation.valid,
baseResumeValidation.valid,
],
);
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
useEffect(() => { useEffect(() => {
if (!shouldOpen) return if (!shouldOpen) return;
if (!currentStep && defaultStep) { if (!currentStep && defaultStep) {
setCurrentStep(defaultStep) setCurrentStep(defaultStep);
} }
}, [currentStep, defaultStep, shouldOpen]) }, [currentStep, defaultStep, shouldOpen]);
const runAllValidations = useCallback(async () => { const runAllValidations = useCallback(async () => {
if (!settings) return if (!settings) return;
const results = await Promise.allSettled([ const results = await Promise.allSettled([
validateOpenrouter(), validateOpenrouter(),
validateRxresume(), validateRxresume(),
validateBaseResume(), validateBaseResume(),
]) ]);
const failed = results.find((result) => result.status === "rejected") const failed = results.find((result) => result.status === "rejected");
if (failed) { if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null const reason = failed.status === "rejected" ? failed.reason : null;
const message = reason instanceof Error ? reason.message : "Validation checks failed" const message =
toast.error(message) reason instanceof Error ? reason.message : "Validation checks failed";
toast.error(message);
} }
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume]) }, [settings, validateOpenrouter, validateRxresume, validateBaseResume]);
useEffect(() => { useEffect(() => {
if (!settings || settingsLoading) return if (!settings || settingsLoading) return;
if (openrouterValidation.checked || rxresumeValidation.checked || baseResumeValidation.checked) return if (
void runAllValidations() openrouterValidation.checked ||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations]) rxresumeValidation.checked ||
baseResumeValidation.checked
)
return;
void runAllValidations();
}, [
settings,
settingsLoading,
openrouterValidation.checked,
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
]);
const handleRefresh = async () => { const handleRefresh = async () => {
const results = await Promise.allSettled([refreshSettings(), runAllValidations()]) const results = await Promise.allSettled([
const failed = results.find((result) => result.status === "rejected") refreshSettings(),
runAllValidations(),
]);
const failed = results.find((result) => result.status === "rejected");
if (failed) { if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null const reason = failed.status === "rejected" ? failed.reason : null;
const message = reason instanceof Error ? reason.message : "Failed to refresh setup" const message =
toast.error(message) reason instanceof Error ? reason.message : "Failed to refresh setup";
toast.error(message);
} }
} };
const handleSaveOpenrouter = async (): Promise<boolean> => { const handleSaveOpenrouter = async (): Promise<boolean> => {
const openrouterValue = openrouterApiKey.trim() const openrouterValue = openrouterApiKey.trim();
if (!openrouterValue && !hasOpenrouterKey) { if (!openrouterValue && !hasOpenrouterKey) {
toast.info("Add your OpenRouter API key to continue") toast.info("Add your OpenRouter API key to continue");
return false return false;
} }
try { try {
const validation = await validateOpenrouter(openrouterValue || undefined) const validation = await validateOpenrouter(openrouterValue || undefined);
if (!validation.valid) { if (!validation.valid) {
toast.error(validation.message || "OpenRouter validation failed") toast.error(validation.message || "OpenRouter validation failed");
return false return false;
} }
if (openrouterValue) { if (openrouterValue) {
setIsSavingEnv(true) setIsSavingEnv(true);
await api.updateSettings({ openrouterApiKey: openrouterValue }) await api.updateSettings({ openrouterApiKey: openrouterValue });
await refreshSettings() await refreshSettings();
setOpenrouterApiKey("") setOpenrouterApiKey("");
} }
toast.success("OpenRouter connected") toast.success("OpenRouter connected");
return true return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key" const message =
toast.error(message) error instanceof Error
return false ? error.message
: "Failed to save OpenRouter key";
toast.error(message);
return false;
} finally { } finally {
setIsSavingEnv(false) setIsSavingEnv(false);
} }
} };
const handleSaveRxresume = async (): Promise<boolean> => { const handleSaveRxresume = async (): Promise<boolean> => {
const emailValue = rxresumeEmail.trim() const emailValue = rxresumeEmail.trim();
const passwordValue = rxresumePassword.trim() const passwordValue = rxresumePassword.trim();
const missing: string[] = [] const missing: string[] = [];
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email") if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password") if (!hasRxresumePassword && !passwordValue)
missing.push("RxResume password");
if (missing.length > 0) { if (missing.length > 0) {
toast.info("Almost there", { toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`, description: `Missing: ${missing.join(", ")}`,
}) });
return false return false;
} }
try { try {
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined) const validation = await validateRxresume(
emailValue || undefined,
passwordValue || undefined,
);
if (!validation.valid) { if (!validation.valid) {
toast.error(validation.message || "RxResume validation failed") toast.error(validation.message || "RxResume validation failed");
return false return false;
} }
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {} const update: { rxresumeEmail?: string; rxresumePassword?: string } = {};
if (emailValue) update.rxresumeEmail = emailValue if (emailValue) update.rxresumeEmail = emailValue;
if (passwordValue) update.rxresumePassword = passwordValue if (passwordValue) update.rxresumePassword = passwordValue;
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
setIsSavingEnv(true) setIsSavingEnv(true);
await api.updateSettings(update) await api.updateSettings(update);
await refreshSettings() await refreshSettings();
setRxresumePassword("") setRxresumePassword("");
} }
toast.success("RxResume connected") toast.success("RxResume connected");
return true return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to save RxResume credentials" const message =
toast.error(message) error instanceof Error
return false ? error.message
: "Failed to save RxResume credentials";
toast.error(message);
return false;
} finally { } finally {
setIsSavingEnv(false) setIsSavingEnv(false);
} }
} };
const handleSaveBaseResume = async (): Promise<boolean> => { const handleSaveBaseResume = async (): Promise<boolean> => {
if (!rxresumeBaseResumeId) { if (!rxresumeBaseResumeId) {
toast.info("Select a base resume to continue") toast.info("Select a base resume to continue");
return false return false;
} }
try { try {
setIsSavingEnv(true) setIsSavingEnv(true);
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId }) await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId });
const validation = await validateBaseResume() const validation = await validateBaseResume();
if (!validation.valid) { if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed") toast.error(validation.message || "Base resume validation failed");
return false return false;
} }
await refreshSettings() await refreshSettings();
toast.success("Base resume set") toast.success("Base resume set");
return true return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to save base resume" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to save base resume";
return false toast.error(message);
return false;
} finally { } finally {
setIsSavingEnv(false) setIsSavingEnv(false);
} }
} };
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 const resolvedStepIndex = currentStep
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 ? steps.findIndex((step) => step.id === currentStep)
const completedSteps = steps.filter((step) => step.complete).length : 0;
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0;
const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume const completedSteps = steps.filter((step) => step.complete).length;
const canGoBack = stepIndex > 0 const progressValue =
const primaryLabel = currentStep === "openrouter" steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0;
? (openrouterValidation.valid ? "Revalidate" : "Validate") const isBusy =
: currentStep === "rxresume" isSavingEnv ||
? (rxresumeValidation.valid ? "Revalidate" : "Validate") settingsLoading ||
: currentStep === "baseresume" isValidatingOpenrouter ||
? (baseResumeValidation.valid ? "Revalidate" : "Validate") isValidatingRxresume ||
isValidatingBaseResume;
const canGoBack = stepIndex > 0;
const primaryLabel =
currentStep === "openrouter"
? openrouterValidation.valid
? "Revalidate"
: "Validate" : "Validate"
: currentStep === "rxresume"
? rxresumeValidation.valid
? "Revalidate"
: "Validate"
: currentStep === "baseresume"
? baseResumeValidation.valid
? "Revalidate"
: "Validate"
: "Validate";
const handlePrimaryAction = async () => { const handlePrimaryAction = async () => {
if (!currentStep) return if (!currentStep) return;
if (currentStep === "openrouter") { if (currentStep === "openrouter") {
await handleSaveOpenrouter() await handleSaveOpenrouter();
return return;
} }
if (currentStep === "rxresume") { if (currentStep === "rxresume") {
await handleSaveRxresume() await handleSaveRxresume();
return return;
} }
if (currentStep === "baseresume") { if (currentStep === "baseresume") {
await handleSaveBaseResume() await handleSaveBaseResume();
return return;
} }
} };
const handleBack = () => { const handleBack = () => {
if (!canGoBack) return if (!canGoBack) return;
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep) setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep);
} };
if (!shouldOpen || !currentStep) return null if (!shouldOpen || !currentStep) return null;
return ( return (
<AlertDialog open> <AlertDialog open>
@@ -333,22 +415,23 @@ export const OnboardingGate: React.FC = () => {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle> <AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end. Let’s get your workspace ready. Add your keys and resume once,
then the pipeline can run end-to-end.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}> <Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3"> <TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => { {steps.map((step, index) => {
const isActive = step.id === currentStep const isActive = step.id === currentStep;
const isComplete = step.complete const isComplete = step.complete;
return ( return (
<FieldLabel <FieldLabel
key={step.id} key={step.id}
className={cn( className={cn(
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none", "w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
step.disabled && "opacity-50 cursor-not-allowed" step.disabled && "opacity-50 cursor-not-allowed",
)} )}
> >
<TabsTrigger <TabsTrigger
@@ -356,7 +439,9 @@ export const OnboardingGate: React.FC = () => {
disabled={step.disabled} disabled={step.disabled}
className={cn( className={cn(
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none", "w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary !bg-muted/60 text-foreground" : "text-muted-foreground" isActive
? "border-primary !bg-muted/60 text-foreground"
: "text-muted-foreground",
)} )}
> >
<Field orientation="horizontal" className="items-start"> <Field orientation="horizontal" className="items-start">
@@ -369,22 +454,28 @@ export const OnboardingGate: React.FC = () => {
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold", "mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
isComplete isComplete
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground",
)} )}
> >
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1} {isComplete ? (
<Check className="h-3.5 w-3.5" />
) : (
index + 1
)}
</span> </span>
</Field> </Field>
</TabsTrigger> </TabsTrigger>
</FieldLabel> </FieldLabel>
) );
})} })}
</TabsList> </TabsList>
<TabsContent value="openrouter" className="space-y-4 pt-6"> <TabsContent value="openrouter" className="space-y-4 pt-6">
<div> <div>
<p className="text-sm font-semibold">Connect OpenRouter</p> <p className="text-sm font-semibold">Connect OpenRouter</p>
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p> <p className="text-xs text-muted-foreground">
Used for job scoring, summaries, and tailoring.
</p>
</div> </div>
<SettingsInput <SettingsInput
label="OpenRouter API key" label="OpenRouter API key"
@@ -403,8 +494,12 @@ export const OnboardingGate: React.FC = () => {
<TabsContent value="rxresume" className="space-y-4 pt-6"> <TabsContent value="rxresume" className="space-y-4 pt-6">
<div> <div>
<p className="text-sm font-semibold">Link your RxResume account</p> <p className="text-sm font-semibold">
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p> Link your RxResume account
</p>
<p className="text-xs text-muted-foreground">
Used to export tailored PDFs.
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<SettingsInput <SettingsInput
@@ -423,7 +518,8 @@ export const OnboardingGate: React.FC = () => {
inputProps={{ inputProps={{
name: "rxresumePassword", name: "rxresumePassword",
value: rxresumePassword, value: rxresumePassword,
onChange: (event) => setRxresumePassword(event.target.value), onChange: (event) =>
setRxresumePassword(event.target.value),
}} }}
type="password" type="password"
placeholder="Enter password" placeholder="Enter password"
@@ -435,9 +531,12 @@ export const OnboardingGate: React.FC = () => {
<TabsContent value="baseresume" className="space-y-4 pt-6"> <TabsContent value="baseresume" className="space-y-4 pt-6">
<div> <div>
<p className="text-sm font-semibold">Select your template resume</p> <p className="text-sm font-semibold">
<p className="text-xs text-muted-foreground">Choose the resume you want to use as a template. Select your template resume
The selected resume will be used as a template for tailoring. </p>
<p className="text-xs text-muted-foreground">
Choose the resume you want to use as a template. The selected
resume will be used as a template for tailoring.
</p> </p>
</div> </div>
<BaseResumeSelection <BaseResumeSelection
@@ -447,11 +546,14 @@ export const OnboardingGate: React.FC = () => {
disabled={isSavingEnv} disabled={isSavingEnv}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}> <Button
variant="outline"
onClick={handleBack}
disabled={!canGoBack || isBusy}
>
Back Back
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -467,8 +569,8 @@ export const OnboardingGate: React.FC = () => {
<Progress value={progressValue} className="h-2" /> <Progress value={progressValue} className="h-2" />
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground"> <div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and Friendly heads-up: pipelines can be slow or a little flaky in alpha.
we will take a look.{" "} If anything feels off, open a GitHub issue and we will take a look.{" "}
<a <a
className="font-semibold text-foreground underline underline-offset-2" className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues" href="https://github.com/DaKheera47/job-ops/issues"
@@ -482,5 +584,5 @@ export const OnboardingGate: React.FC = () => {
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
) );
} };
@@ -2,8 +2,9 @@
* Live pipeline progress display component. * Live pipeline progress display component.
*/ */
import React, { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,7 +13,14 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface PipelineProgress { interface PipelineProgress {
step: "idle" | "crawling" | "importing" | "scoring" | "processing" | "completed" | "failed"; step:
| "idle"
| "crawling"
| "importing"
| "scoring"
| "processing"
| "completed"
| "failed";
message: string; message: string;
detail?: string; detail?: string;
crawlingListPagesProcessed: number; crawlingListPagesProcessed: number;
@@ -61,9 +69,12 @@ const stepBadgeClasses: Record<PipelineProgress["step"], string> = {
failed: "bg-destructive/10 text-destructive border-destructive/20", failed: "bg-destructive/10 text-destructive border-destructive/20",
}; };
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning }) => { export const PipelineProgress: React.FC<PipelineProgressProps> = ({
isRunning,
}) => {
const [progress, setProgress] = useState<PipelineProgress | null>(null); const [progress, setProgress] = useState<PipelineProgress | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -74,9 +85,11 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
case "crawling": { case "crawling": {
if (progress.crawlingListPagesTotal > 0) { if (progress.crawlingListPagesTotal > 0) {
return clamp( return clamp(
(progress.crawlingListPagesProcessed / progress.crawlingListPagesTotal) * 15, (progress.crawlingListPagesProcessed /
progress.crawlingListPagesTotal) *
15,
0, 0,
15 15,
); );
} }
if (progress.crawlingListPagesProcessed > 0) return 8; if (progress.crawlingListPagesProcessed > 0) return 8;
@@ -87,9 +100,10 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
case "scoring": { case "scoring": {
if (progress.jobsScored > 0) { if (progress.jobsScored > 0) {
return clamp( return clamp(
20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30, 20 +
(progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
20, 20,
50 50,
); );
} }
return 25; return 25;
@@ -99,7 +113,7 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
return clamp( return clamp(
50 + (progress.jobsProcessed / progress.totalToProcess) * 50, 50 + (progress.jobsProcessed / progress.totalToProcess) * 50,
50, 50,
100 100,
); );
} }
return 55; return 55;
@@ -151,7 +165,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
const step = progress?.step ?? "idle"; const step = progress?.step ?? "idle";
const isActive = step !== "idle" && step !== "completed" && step !== "failed"; const isActive = step !== "idle" && step !== "completed" && step !== "failed";
const showStats = !!progress && ["crawling", "scoring", "processing", "completed"].includes(step); const showStats =
!!progress &&
["crawling", "scoring", "processing", "completed"].includes(step);
return ( return (
<Card> <Card>
@@ -159,7 +175,10 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<CardTitle className="text-base">Pipeline</CardTitle> <CardTitle className="text-base">Pipeline</CardTitle>
<Badge variant="outline" className={cn("uppercase tracking-wide", stepBadgeClasses[step])}> <Badge
variant="outline"
className={cn("uppercase tracking-wide", stepBadgeClasses[step])}
>
{stepLabels[step]} {stepLabels[step]}
</Badge> </Badge>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
@@ -180,7 +199,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm">{progress.message}</p> <p className="text-sm">{progress.message}</p>
{progress.detail && <p className="text-sm text-muted-foreground">{progress.detail}</p>} {progress.detail && (
<p className="text-sm text-muted-foreground">{progress.detail}</p>
)}
</div> </div>
{showStats && ( {showStats && (
@@ -190,46 +211,73 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
{step === "crawling" ? ( {step === "crawling" ? (
<> <>
<div> <div>
<div className="text-xs text-muted-foreground">Sources</div> <div className="text-xs text-muted-foreground">
Sources
</div>
<div className="tabular-nums"> <div className="tabular-nums">
{progress.crawlingListPagesProcessed} {progress.crawlingListPagesProcessed}
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ""} {progress.crawlingListPagesTotal > 0
? `/${progress.crawlingListPagesTotal}`
: ""}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Pages</div> <div className="text-xs text-muted-foreground">Pages</div>
<div className="tabular-nums"> <div className="tabular-nums">
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)} {progress.crawlingJobPagesProcessed}/
{Math.max(progress.crawlingJobPagesEnqueued, 0)}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Enqueued</div> <div className="text-xs text-muted-foreground">
<div className="tabular-nums">{progress.crawlingJobPagesEnqueued}</div> Enqueued
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesEnqueued}
</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Skipped</div> <div className="text-xs text-muted-foreground">
<div className="tabular-nums">{progress.crawlingJobPagesSkipped}</div> Skipped
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesSkipped}
</div>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div> <div>
<div className="text-xs text-muted-foreground">Discovered</div> <div className="text-xs text-muted-foreground">
<div className="tabular-nums">{progress.jobsDiscovered}</div> Discovered
</div> </div>
<div>
<div className="text-xs text-muted-foreground">Scored</div>
<div className="tabular-nums">{progress.jobsScored}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Processed</div>
<div className="tabular-nums"> <div className="tabular-nums">
{progress.totalToProcess > 0 ? `${progress.jobsProcessed}/${progress.totalToProcess}` : progress.jobsProcessed} {progress.jobsDiscovered}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">To process</div> <div className="text-xs text-muted-foreground">
<div className="tabular-nums">{progress.totalToProcess}</div> Scored
</div>
<div className="tabular-nums">{progress.jobsScored}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Processed
</div>
<div className="tabular-nums">
{progress.totalToProcess > 0
? `${progress.jobsProcessed}/${progress.totalToProcess}`
: progress.jobsProcessed}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
To process
</div>
<div className="tabular-nums">
{progress.totalToProcess}
</div>
</div> </div>
</> </>
)} )}
@@ -1,17 +1,22 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { ReadyPanel } from "./ReadyPanel"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
import * as api from "../api"; import * as api from "../api";
import { toast } from "sonner"; import { ReadyPanel } from "./ReadyPanel";
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => (
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, <div>{children}</div>
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>, ),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({ DropdownMenuItem: ({
children, children,
onSelect, onSelect,
@@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode; children: React.ReactNode;
onSelect?: () => void; onSelect?: () => void;
}) => ( }) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}> <button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children} {children}
</button> </button>
), ),
@@ -126,14 +136,12 @@ describe("ReadyPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render( render(
<ReadyPanel <ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />,
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
); );
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i })); fireEvent.click(
screen.getByRole("menuitem", { name: /recalculate match/i }),
);
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1")); await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalled(); expect(onJobUpdated).toHaveBeenCalled();
@@ -7,26 +7,32 @@
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs. * Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Briefcase,
Building2,
CheckCircle2, CheckCircle2,
ChevronUp, ChevronUp,
Copy,
Download, Download,
Edit2,
ExternalLink, ExternalLink,
FileText, FileText,
FolderKanban,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
RefreshCcw, RefreshCcw,
Undo2, Undo2,
Copy,
Edit2,
XCircle, XCircle,
Briefcase,
Building2,
FolderKanban,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -35,19 +41,13 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils"; import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
import * as api from "../api"; import * as api from "../api";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob"; import { useRescoreJob } from "../hooks/useRescoreJob";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
type PanelMode = "ready" | "tailor"; type PanelMode = "ready" | "tailor";
@@ -103,7 +103,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const selectedProjectNames = useMemo(() => { const selectedProjectNames = useMemo(() => {
if (!catalog.length || !selectedProjectIds.length) return []; if (!catalog.length || !selectedProjectIds.length) return [];
return selectedProjectIds return selectedProjectIds
.map(id => catalog.find(p => p.id === id)?.name) .map((id) => catalog.find((p) => p.id === id)?.name)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
}, [catalog, selectedProjectIds]); }, [catalog, selectedProjectIds]);
@@ -140,7 +140,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
duration: 6000, duration: 6000,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to mark as applied"; const message =
error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message); toast.error(message);
} finally { } finally {
setIsMarkingApplied(false); setIsMarkingApplied(false);
@@ -160,7 +161,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
setRecentlyApplied(null); setRecentlyApplied(null);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to undo"; const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message); toast.error(message);
} }
}, },
@@ -176,14 +178,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
toast.success("PDF regenerated"); toast.success("PDF regenerated");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to regenerate PDF"; const message =
error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message); toast.error(message);
} finally { } finally {
setIsRegenerating(false); setIsRegenerating(false);
} }
}, [job, onJobUpdated]); }, [job, onJobUpdated]);
const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]); const handleRescore = useCallback(
() => rescoreJob(job?.id),
[job?.id, rescoreJob],
);
const handleSkip = useCallback(async () => { const handleSkip = useCallback(async () => {
if (!job) return; if (!job) return;
@@ -222,7 +228,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
await onJobUpdated(); await onJobUpdated();
setMode("ready"); setMode("ready");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to regenerate PDF"; const message =
error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message); toast.error(message);
} finally { } finally {
setIsRegenerating(false); setIsRegenerating(false);
@@ -236,7 +243,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
<FileText className="h-5 w-5 text-muted-foreground" /> <FileText className="h-5 w-5 text-muted-foreground" />
</div> </div>
<div className="text-sm font-medium text-muted-foreground">No job selected</div> <div className="text-sm font-medium text-muted-foreground">
No job selected
</div>
<p className="text-xs text-muted-foreground/70 max-w-[200px]"> <p className="text-xs text-muted-foreground/70 max-w-[200px]">
Select a Ready job to view its application kit and take action. Select a Ready job to view its application kit and take action.
</p> </p>
@@ -275,7 +284,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<div className="pb-4 border-b border-border/40"> <div className="pb-4 border-b border-border/40">
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{/* Show PDF - to verify quickly without download */} {/* Show PDF - to verify quickly without download */}
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs"> <Button
asChild
variant="outline"
className="h-9 w-full gap-1 px-2 text-xs"
>
<a href={pdfHref} target="_blank" rel="noopener noreferrer"> <a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileText className="h-3.5 w-3.5 shrink-0" /> <FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">View PDF</span> <span className="truncate">View PDF</span>
@@ -283,7 +296,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button> </Button>
{/* Download PDF - primary artifact action */} {/* Download PDF - primary artifact action */}
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs"> <Button
asChild
variant="outline"
className="h-9 w-full gap-1 px-2 text-xs"
>
<a <a
href={pdfHref} href={pdfHref}
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`} download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
@@ -294,7 +311,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button> </Button>
{/* Open job - to verify before applying */} {/* Open job - to verify before applying */}
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs"> <Button
asChild
variant="outline"
className="h-9 w-full gap-1 px-2 text-xs"
>
<a href={jobLink} target="_blank" rel="noopener noreferrer"> <a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5 shrink-0" /> <ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Open Job Listing</span> <span className="truncate">Open Job Listing</span>
@@ -338,7 +359,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</div> </div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-foreground leading-tight"> <div className="text-sm font-medium text-foreground leading-tight">
{selectedProjectIds.length} {selectedProjectIds.length === 1 ? "project" : "projects"} selected {selectedProjectIds.length}{" "}
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
selected
</div> </div>
</div> </div>
</div> </div>
@@ -385,15 +408,16 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
onSelect={handleRegenerate} onSelect={handleRegenerate}
disabled={isRegenerating} disabled={isRegenerating}
> >
<RefreshCcw className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")} /> <RefreshCcw
className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")}
/>
{isRegenerating ? "Regenerating..." : "Regenerate PDF"} {isRegenerating ? "Regenerating..." : "Regenerate PDF"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onSelect={handleRescore} disabled={isRescoring}>
onSelect={handleRescore} <RefreshCcw
disabled={isRescoring} className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")}
> />
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
{isRescoring ? "Recalculating..." : "Recalculate match"} {isRescoring ? "Recalculating..." : "Recalculate match"}
</DropdownMenuItem> </DropdownMenuItem>
@@ -2,7 +2,7 @@
* Suitability score display component. * Suitability score display component.
*/ */
import React from "react"; import type React from "react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
@@ -18,8 +18,9 @@ export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={score} className="h-2 w-20" /> <Progress value={score} className="h-2 w-20" />
<span className="text-sm tabular-nums text-muted-foreground">{score}</span> <span className="text-sm tabular-nums text-muted-foreground">
{score}
</span>
</div> </div>
); );
}; };
+1 -2
View File
@@ -2,7 +2,6 @@
* Stats dashboard showing job counts by status. * Stats dashboard showing job counts by status.
*/ */
import React from "react";
import { import {
CheckCircle2, CheckCircle2,
Clock, Clock,
@@ -11,6 +10,7 @@ import {
Sparkles, Sparkles,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { JobStatus } from "../../shared/types"; import type { JobStatus } from "../../shared/types";
@@ -74,4 +74,3 @@ export const Stats: React.FC<StatsProps> = ({ stats }) => {
</Card> </Card>
); );
}; };
@@ -2,8 +2,8 @@
* Status badge component. * Status badge component.
*/ */
import React from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -24,12 +24,18 @@ const statusLabels: Record<JobStatus, string> = {
const statusStyles: Record< const statusStyles: Record<
JobStatus, JobStatus,
{ variant: "default" | "secondary" | "destructive" | "outline"; className?: string } {
variant: "default" | "secondary" | "destructive" | "outline";
className?: string;
}
> = { > = {
discovered: { variant: "secondary" }, discovered: { variant: "secondary" },
processing: { variant: "secondary" }, processing: { variant: "secondary" },
ready: { variant: "default" }, ready: { variant: "default" },
applied: { variant: "outline", className: "text-emerald-400 border-emerald-500/30" }, applied: {
variant: "outline",
className: "text-emerald-400 border-emerald-500/30",
},
skipped: { variant: "destructive" }, skipped: { variant: "destructive" },
expired: { variant: "outline", className: "text-muted-foreground" }, expired: { variant: "outline", className: "text-muted-foreground" },
}; };
@@ -44,4 +50,3 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
</Badge> </Badge>
); );
}; };
@@ -1,4 +1,4 @@
import React from "react"; import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
@@ -7,11 +7,19 @@ interface TailoredSummaryProps {
className?: string; className?: string;
} }
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({ job, className }) => { export const TailoredSummary: React.FC<TailoredSummaryProps> = ({
job,
className,
}) => {
if (!job.tailoredSummary) return null; if (!job.tailoredSummary) return null;
return ( return (
<div className={cn("rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5", className)}> <div
className={cn(
"rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5",
className,
)}
>
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5"> <div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
Tailored Summary Tailored Summary
</div> </div>
@@ -1,13 +1,20 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import {
import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react"; AlertTriangle,
Check,
FileText,
Loader2,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import * as api from "../api";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
import * as api from "../api";
interface TailoringEditorProps { interface TailoringEditorProps {
job: Job; job: Job;
@@ -26,7 +33,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}) => { }) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]); const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || ""); const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isSummarizing, setIsSummarizing] = useState(false); const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
@@ -45,9 +54,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
return false; return false;
}, [selectedIds, savedSelectedIds]); }, [selectedIds, savedSelectedIds]);
const isDirty = summary !== (job.tailoredSummary || "") || const isDirty =
jobDescription !== (job.jobDescription || "") || summary !== (job.tailoredSummary || "") ||
hasSelectionDiff; jobDescription !== (job.jobDescription || "") ||
hasSelectionDiff;
useEffect(() => { useEffect(() => {
onDirtyChange?.(isDirty); onDirtyChange?.(isDirty);
@@ -59,7 +69,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
// Set initial selection // Set initial selection
if (job.selectedProjectIds) { if (job.selectedProjectIds) {
setSelectedIds(new Set(job.selectedProjectIds.split(',').filter(Boolean))); setSelectedIds(
new Set(job.selectedProjectIds.split(",").filter(Boolean)),
);
} }
setJobDescription(job.jobDescription || ""); setJobDescription(job.jobDescription || "");
}, [job.selectedProjectIds, job.jobDescription]); }, [job.selectedProjectIds, job.jobDescription]);
@@ -119,7 +131,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
setSummary(updatedJob.tailoredSummary || ""); setSummary(updatedJob.tailoredSummary || "");
setJobDescription(updatedJob.jobDescription || ""); setJobDescription(updatedJob.jobDescription || "");
if (updatedJob.selectedProjectIds) { if (updatedJob.selectedProjectIds) {
setSelectedIds(new Set(updatedJob.selectedProjectIds.split(',').filter(Boolean))); setSelectedIds(
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
);
} }
toast.success("AI Summary & Projects generated"); toast.success("AI Summary & Projects generated");
await onUpdate(); await onUpdate();
@@ -164,7 +178,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
disabled={isSummarizing || isGeneratingPdf || isSaving} disabled={isSummarizing || isGeneratingPdf || isSaving}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{isSummarizing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />} {isSummarizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
AI Summarize AI Summarize
</Button> </Button>
<Button <Button
@@ -173,7 +191,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary} disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{isGeneratingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />} {isGeneratingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Generate PDF Generate PDF
</Button> </Button>
</div> </div>
@@ -181,7 +203,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm"> <div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Job Description (Edit to help AI tailoring)</label> <label className="text-sm font-medium">
Job Description (Edit to help AI tailoring)
</label>
<textarea <textarea
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription} value={jobDescription}
@@ -210,7 +234,8 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
{tooManyProjects && ( {tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium"> <span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
Warning: More than {maxProjects} projects might make the resume too long. Warning: More than {maxProjects} projects might make the resume
too long.
</span> </span>
)} )}
</div> </div>
@@ -231,7 +256,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
className="flex flex-1 flex-col gap-1 cursor-pointer" className="flex flex-1 flex-col gap-1 cursor-pointer"
> >
<span className="font-semibold">{project.name}</span> <span className="font-semibold">{project.name}</span>
<span className="text-xs text-muted-foreground line-clamp-2">{project.description}</span> <span className="text-xs text-muted-foreground line-clamp-2">
{project.description}
</span>
</label> </label>
</div> </div>
))} ))}
@@ -239,10 +266,19 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
</div> </div>
<div className="flex justify-end border-t pt-4"> <div className="flex justify-end border-t pt-4">
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving}> <Button
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />} variant="ghost"
Save Selection size="sm"
</Button> onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Selection
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import React from "react";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import type React from "react";
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
isOpen: boolean; isOpen: boolean;
@@ -15,16 +15,16 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
children, children,
}) => { }) => {
return ( return (
<div className='space-y-2'> <div className="space-y-2">
<button <button
type='button' type="button"
onClick={onToggle} onClick={onToggle}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full' className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
> >
{isOpen ? ( {isOpen ? (
<ChevronUp className='h-3.5 w-3.5' /> <ChevronUp className="h-3.5 w-3.5" />
) : ( ) : (
<ChevronDown className='h-3.5 w-3.5' /> <ChevronDown className="h-3.5 w-3.5" />
)} )}
{label} {label}
</button> </button>
@@ -1,5 +1,13 @@
import React, { useMemo, useState } from "react"; import {
import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react"; ChevronUp,
ExternalLink,
Loader2,
RefreshCcw,
Sparkles,
XCircle,
} from "lucide-react";
import type React from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -9,9 +17,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { FitAssessment, JobHeader, TailoredSummary } from "..";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import { FitAssessment, JobHeader, TailoredSummary } from "..";
import { CollapsibleSection } from "./CollapsibleSection"; import { CollapsibleSection } from "./CollapsibleSection";
import { getPlainDescription } from "./helpers"; import { getPlainDescription } from "./helpers";
@@ -39,46 +46,43 @@ export const DecideMode: React.FC<DecideModeProps> = ({
const description = useMemo( const description = useMemo(
() => getPlainDescription(job.jobDescription), () => getPlainDescription(job.jobDescription),
[job.jobDescription] [job.jobDescription],
); );
return ( return (
<div className='flex flex-col h-full'> <div className="flex flex-col h-full">
<div className='space-y-4 pb-4'> <div className="space-y-4 pb-4">
<JobHeader <JobHeader job={job} onCheckSponsor={onCheckSponsor} />
job={job}
onCheckSponsor={onCheckSponsor}
/>
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'> <div className="flex flex-col gap-2.5 pt-2 sm:flex-row">
<Button <Button
variant='outline' variant="outline"
size='default' size="default"
onClick={onSkip} onClick={onSkip}
disabled={isSkipping} disabled={isSkipping}
className='flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs' className="flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs"
> >
{isSkipping ? ( {isSkipping ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<XCircle className='mr-2 h-4 w-4' /> <XCircle className="mr-2 h-4 w-4" />
)} )}
Skip Job Skip Job
</Button> </Button>
<Button <Button
size='default' size="default"
onClick={onTailor} onClick={onTailor}
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm' className="flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm"
> >
<Sparkles className='mr-2 h-4 w-4' /> <Sparkles className="mr-2 h-4 w-4" />
Start Tailoring Start Tailoring
</Button> </Button>
</div> </div>
</div> </div>
<Separator className='opacity-40' /> <Separator className="opacity-40" />
<div className='flex-1 py-6 space-y-6 overflow-y-auto'> <div className="flex-1 py-6 space-y-6 overflow-y-auto">
<FitAssessment job={job} /> <FitAssessment job={job} />
<TailoredSummary job={job} /> <TailoredSummary job={job} />
@@ -87,17 +91,17 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onToggle={() => setShowDescription((prev) => !prev)} onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "View"} Full Job Description`} label={`${showDescription ? "Hide" : "View"} Full Job Description`}
> >
<div className='rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner'> <div className="rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner">
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'> <p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
{description} {description}
</p> </p>
</div> </div>
</CollapsibleSection> </CollapsibleSection>
</div> </div>
<Separator className='opacity-40' /> <Separator className="opacity-40" />
<div className='pt-4 pb-2 space-y-4'> <div className="pt-4 pb-2 space-y-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -111,21 +115,25 @@ export const DecideMode: React.FC<DecideModeProps> = ({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56"> <DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}> <DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
<RefreshCcw className={isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"} /> <RefreshCcw
className={
isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"
}
/>
{isRescoring ? "Recalculating..." : "Recalculate match"} {isRescoring ? "Recalculating..." : "Recalculate match"}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{jobLink ? ( {jobLink ? (
<div className='flex justify-center'> <div className="flex justify-center">
<a <a
href={jobLink} href={jobLink}
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors' className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<ExternalLink className='h-3.5 w-3.5' /> <ExternalLink className="h-3.5 w-3.5" />
Original Job Listing Original Job Listing
</a> </a>
</div> </div>
@@ -1,17 +1,22 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { DiscoveredPanel } from "./DiscoveredPanel"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import * as api from "../../api"; import * as api from "../../api";
import { toast } from "sonner"; import { DiscoveredPanel } from "./DiscoveredPanel";
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => (
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, <div>{children}</div>
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>, ),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({ DropdownMenuItem: ({
children, children,
onSelect, onSelect,
@@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode; children: React.ReactNode;
onSelect?: () => void; onSelect?: () => void;
}) => ( }) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}> <button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children} {children}
</button> </button>
), ),
@@ -123,10 +133,12 @@ describe("DiscoveredPanel", () => {
job={job} job={job}
onJobUpdated={onJobUpdated} onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()} onJobMoved={vi.fn()}
/> />,
); );
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i })); fireEvent.click(
screen.getByRole("menuitem", { name: /recalculate match/i }),
);
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2")); await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
expect(onJobUpdated).toHaveBeenCalled(); expect(onJobUpdated).toHaveBeenCalled();
@@ -1,13 +1,13 @@
import React, { useEffect, useState } from "react"; import type React from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as api from "../../api";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import * as api from "../../api";
import { useRescoreJob } from "../../hooks/useRescoreJob";
import { DecideMode } from "./DecideMode"; import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
import { ProcessingState } from "./ProcessingState"; import { ProcessingState } from "./ProcessingState";
import { TailorMode } from "./TailorMode"; import { TailorMode } from "./TailorMode";
import { useRescoreJob } from "../../hooks/useRescoreJob";
type PanelMode = "decide" | "tailor"; type PanelMode = "decide" | "tailor";
@@ -82,7 +82,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
} }
return ( return (
<div className='h-full'> <div className="h-full">
{mode === "decide" ? ( {mode === "decide" ? (
<DecideMode <DecideMode
job={job} job={job}
@@ -1,16 +1,16 @@
import React from "react";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import type React from "react";
export const EmptyState: React.FC = () => { export const EmptyState: React.FC = () => {
return ( return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'> <div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4">
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'> <div className="h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center">
<Sparkles className='h-4 w-4 text-muted-foreground/50' /> <Sparkles className="h-4 w-4 text-muted-foreground/50" />
</div> </div>
<div className='text-sm font-medium text-muted-foreground'> <div className="text-sm font-medium text-muted-foreground">
No job selected No job selected
</div> </div>
<p className='text-xs text-muted-foreground/70 max-w-[200px]'> <p className="text-xs text-muted-foreground/70 max-w-[200px]">
Select a job from the list to see details and decide whether to tailor. Select a job from the list to see details and decide whether to tailor.
</p> </p>
</div> </div>
@@ -1,14 +1,14 @@
import React from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react";
export const ProcessingState: React.FC = () => { export const ProcessingState: React.FC = () => {
return ( return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'> <div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4">
<Loader2 className='h-8 w-8 animate-spin text-amber-400' /> <Loader2 className="h-8 w-8 animate-spin text-amber-400" />
<div className='text-sm font-medium text-foreground/80'> <div className="text-sm font-medium text-foreground/80">
Processing job... Processing job...
</div> </div>
<p className='text-xs text-muted-foreground max-w-[220px]'> <p className="text-xs text-muted-foreground max-w-[220px]">
This job is currently being analyzed by the pipeline. Please wait. This job is currently being analyzed by the pipeline. Please wait.
</p> </p>
</div> </div>
@@ -1,5 +1,5 @@
import React from "react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import type React from "react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -23,22 +23,22 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
const tooManyProjects = selectedIds.size > maxProjects; const tooManyProjects = selectedIds.size > maxProjects;
return ( return (
<div className='space-y-2'> <div className="space-y-2">
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'> <div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className='text-xs font-medium text-muted-foreground'> <label className="text-xs font-medium text-muted-foreground">
Selected Projects Selected Projects
</label> </label>
{tooManyProjects && ( {tooManyProjects && (
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'> <span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className='h-3 w-3' /> <AlertTriangle className="h-3 w-3" />
Max {maxProjects} recommended Max {maxProjects} recommended
</span> </span>
)} )}
</div> </div>
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'> <div className="space-y-1.5 max-h-[200px] overflow-y-auto pr-1">
{catalog.length === 0 ? ( {catalog.length === 0 ? (
<div className='text-xs text-muted-foreground text-center py-4'> <div className="text-xs text-muted-foreground text-center py-4">
Loading projects... Loading projects...
</div> </div>
) : ( ) : (
@@ -49,7 +49,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer", "flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
selectedIds.has(project.id) selectedIds.has(project.id)
? "border-primary/40 bg-primary/5" ? "border-primary/40 bg-primary/5"
: "border-border/40 bg-muted/5 hover:bg-muted/10" : "border-border/40 bg-muted/5 hover:bg-muted/10",
)} )}
onClick={() => !disabled && onToggle(project.id)} onClick={() => !disabled && onToggle(project.id)}
> >
@@ -58,11 +58,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
checked={selectedIds.has(project.id)} checked={selectedIds.has(project.id)}
onCheckedChange={() => onToggle(project.id)} onCheckedChange={() => onToggle(project.id)}
disabled={disabled} disabled={disabled}
className='mt-0.5' className="mt-0.5"
/> />
<div className='flex-1 min-w-0'> <div className="flex-1 min-w-0">
<div className='font-medium truncate'>{project.name}</div> <div className="font-medium truncate">{project.name}</div>
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'> <div className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
{project.description} {project.description}
</div> </div>
</div> </div>
@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react"; import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types"; import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
import * as api from "../../api";
import { CollapsibleSection } from "./CollapsibleSection"; import { CollapsibleSection } from "./CollapsibleSection";
import { ProjectSelector } from "./ProjectSelector"; import { ProjectSelector } from "./ProjectSelector";
@@ -16,7 +16,7 @@ interface TailorModeProps {
onFinalize: () => void; onFinalize: () => void;
isFinalizing: boolean; isFinalizing: boolean;
/** Variant controls the finalize button text. Default is 'discovered'. */ /** Variant controls the finalize button text. Default is 'discovered'. */
variant?: 'discovered' | 'ready'; variant?: "discovered" | "ready";
} }
export const TailorMode: React.FC<TailorModeProps> = ({ export const TailorMode: React.FC<TailorModeProps> = ({
@@ -24,11 +24,13 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onBack, onBack,
onFinalize, onFinalize,
isFinalizing, isFinalizing,
variant = 'discovered', variant = "discovered",
}) => { }) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]); const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || ""); const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => { const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved); return new Set(saved);
@@ -67,7 +69,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
if (!savedIds.has(id)) return true; if (!savedIds.has(id)) return true;
} }
return false; return false;
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]); }, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedIds,
]);
useEffect(() => { useEffect(() => {
if (hasChanges && draftStatus === "saved") { if (hasChanges && draftStatus === "saved") {
@@ -105,7 +114,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
return next; return next;
}); });
}, },
[isGenerating, isFinalizing] [isGenerating, isFinalizing],
); );
const handleGenerateWithAI = async () => { const handleGenerateWithAI = async () => {
@@ -125,7 +134,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
setJobDescription(updatedJob.jobDescription || ""); setJobDescription(updatedJob.jobDescription || "");
if (updatedJob.selectedProjectIds) { if (updatedJob.selectedProjectIds) {
setSelectedIds( setSelectedIds(
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)) new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
); );
} }
setDraftStatus("saved"); setDraftStatus("saved");
@@ -165,67 +174,69 @@ export const TailorMode: React.FC<TailorModeProps> = ({
const disableInputs = isGenerating || isFinalizing || isSaving; const disableInputs = isGenerating || isFinalizing || isSaving;
return ( return (
<div className='flex flex-col h-full'> <div className="flex flex-col h-full">
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'> <div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<button <button
type='button' type="button"
onClick={onBack} onClick={onBack}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors' className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
> >
<ArrowLeft className='h-3.5 w-3.5' /> <ArrowLeft className="h-3.5 w-3.5" />
Back to overview Back to overview
</button> </button>
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'> <div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{draftStatus === "saving" && ( {draftStatus === "saving" && (
<> <>
<Loader2 className='h-3 w-3 animate-spin' /> <Loader2 className="h-3 w-3 animate-spin" />
Saving... Saving...
</> </>
)} )}
{draftStatus === "saved" && !hasChanges && ( {draftStatus === "saved" && !hasChanges && (
<> <>
<Check className='h-3 w-3 text-emerald-400' /> <Check className="h-3 w-3 text-emerald-400" />
Saved Saved
</> </>
)} )}
{draftStatus === "unsaved" && ( {draftStatus === "unsaved" && (
<span className='text-amber-400'>Unsaved changes</span> <span className="text-amber-400">Unsaved changes</span>
)} )}
</div> </div>
</div> </div>
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'> <div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4">
<div className='flex items-center gap-2'> <div className="flex items-center gap-2">
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' /> <div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
<span className='text-xs font-medium text-amber-300'> <span className="text-xs font-medium text-amber-300">
Draft tailoring for this role Draft tailoring for this role
</span> </span>
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1 ml-4'> <p className="text-[10px] text-muted-foreground mt-1 ml-4">
Edit below, then finalize to generate your PDF and move to Ready. Edit below, then finalize to generate your PDF and move to Ready.
</p> </p>
</div> </div>
<div className='flex-1 overflow-y-auto space-y-4 pr-1'> <div className="flex-1 overflow-y-auto space-y-4 pr-1">
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'> <div className="flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className='text-xs font-medium'>Need help getting started?</div> <div className="text-xs font-medium">
<div className='text-[10px] text-muted-foreground'> Need help getting started?
</div>
<div className="text-[10px] text-muted-foreground">
AI can draft a summary and select projects for you AI can draft a summary and select projects for you
</div> </div>
</div> </div>
<Button <Button
size='sm' size="sm"
variant='outline' variant="outline"
onClick={handleGenerateWithAI} onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing} disabled={isGenerating || isFinalizing}
className='h-8 w-full text-xs sm:w-auto' className="h-8 w-full text-xs sm:w-auto"
> >
{isGenerating ? ( {isGenerating ? (
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Sparkles className='mr-1.5 h-3.5 w-3.5' /> <Sparkles className="mr-1.5 h-3.5 w-3.5" />
)} )}
Generate draft Generate draft
</Button> </Button>
@@ -236,29 +247,29 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onToggle={() => setShowDescription((prev) => !prev)} onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "Edit"} job description`} label={`${showDescription ? "Hide" : "Edit"} job description`}
> >
<div className='space-y-1'> <div className="space-y-1">
<label className='text-[10px] font-medium text-muted-foreground/70'> <label className="text-[10px] font-medium text-muted-foreground/70">
Edit to help AI tailoring Edit to help AI tailoring
</label> </label>
<textarea <textarea
className='w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50' className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription} value={jobDescription}
onChange={(event) => setJobDescription(event.target.value)} onChange={(event) => setJobDescription(event.target.value)}
placeholder='The raw job description...' placeholder="The raw job description..."
disabled={disableInputs} disabled={disableInputs}
/> />
</div> </div>
</CollapsibleSection> </CollapsibleSection>
<div className='space-y-2'> <div className="space-y-2">
<label className='text-xs font-medium text-muted-foreground'> <label className="text-xs font-medium text-muted-foreground">
Tailored Summary Tailored Summary
</label> </label>
<textarea <textarea
className='w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50' className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={summary} value={summary}
onChange={(event) => setSummary(event.target.value)} onChange={(event) => setSummary(event.target.value)}
placeholder='Write a tailored summary for this role, or generate with AI...' placeholder="Write a tailored summary for this role, or generate with AI..."
disabled={disableInputs} disabled={disableInputs}
/> />
</div> </div>
@@ -272,35 +283,40 @@ export const TailorMode: React.FC<TailorModeProps> = ({
/> />
</div> </div>
<Separator className='opacity-50 my-4' /> <Separator className="opacity-50 my-4" />
<div className='space-y-2'> <div className="space-y-2">
{!canFinalize && ( {!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'> <p className="text-[10px] text-center text-muted-foreground">
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}. Add a summary and select at least one project to{" "}
{variant === "ready" ? "regenerate" : "finalize"}.
</p> </p>
)} )}
<Button <Button
onClick={handleFinalize} onClick={handleFinalize}
disabled={isFinalizing || !canFinalize || isGenerating} disabled={isFinalizing || !canFinalize || isGenerating}
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white' className="w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white"
> >
{isFinalizing ? ( {isFinalizing ? (
<> <>
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'} {variant === "ready"
? "Regenerating PDF..."
: "Finalizing & generating PDF..."}
</> </>
) : ( ) : (
<> <>
<Check className='mr-2 h-4 w-4' /> <Check className="mr-2 h-4 w-4" />
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'} {variant === "ready"
? "Regenerate PDF"
: "Finalize & Move to Ready"}
</> </>
)} )}
</Button> </Button>
<p className='text-[10px] text-center text-muted-foreground/70'> <p className="text-[10px] text-center text-muted-foreground/70">
{variant === 'ready' {variant === "ready"
? 'This will save your changes and regenerate the tailored PDF.' ? "This will save your changes and regenerate the tailored PDF."
: 'This will generate your tailored PDF and move the job to Ready.'} : "This will generate your tailored PDF and move the job to Ready."}
</p> </p>
</div> </div>
</div> </div>
+13 -13
View File
@@ -1,13 +1,13 @@
export { Header } from './Header'; export { DiscoveredPanel } from "./discovered-panel";
export { Stats } from './Stats'; export { FitAssessment } from "./FitAssessment";
export { StatusBadge } from './StatusBadge'; export { Header } from "./Header";
export { ScoreIndicator } from './ScoreIndicator'; export { JobHeader } from "./JobHeader";
export { JobHeader } from './JobHeader'; export * from "./layout";
export { TailoredSummary } from './TailoredSummary'; export { ManualImportSheet } from "./ManualImportSheet";
export { FitAssessment } from './FitAssessment'; export { PipelineProgress } from "./PipelineProgress";
export { PipelineProgress } from './PipelineProgress'; export { ReadyPanel } from "./ReadyPanel";
export { TailoringEditor } from './TailoringEditor'; export { ScoreIndicator } from "./ScoreIndicator";
export { DiscoveredPanel } from './discovered-panel'; export { Stats } from "./Stats";
export { ReadyPanel } from './ReadyPanel'; export { StatusBadge } from "./StatusBadge";
export { ManualImportSheet } from './ManualImportSheet'; export { TailoredSummary } from "./TailoredSummary";
export * from './layout'; export { TailoringEditor } from "./TailoringEditor";
+115 -29
View File
@@ -2,9 +2,17 @@
* Shared layout components for consistent page structure. * Shared layout components for consistent page structure.
*/ */
import React, { useState } from "react"; import {
Briefcase,
Home,
type LucideIcon,
Menu,
Settings,
Shield,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Briefcase, Home, LucideIcon, Menu, Settings, Shield } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -81,9 +89,16 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
onClick={() => handleNavClick(to)} onClick={() => handleNavClick(to)}
className={cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname)) location.pathname === to ||
(to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname))
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "text-muted-foreground" : "text-muted-foreground",
)} )}
> >
<NavIcon className="h-4 w-4" /> <NavIcon className="h-4 w-4" />
@@ -109,9 +124,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
{statusIndicator} {statusIndicator}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">{actions}</div>
{actions}
</div>
</div> </div>
</header> </header>
); );
@@ -126,7 +139,10 @@ interface StatusIndicatorProps {
variant?: "amber" | "emerald" | "sky"; variant?: "amber" | "emerald" | "sky";
} }
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant = "amber" }) => { export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
label,
variant = "amber",
}) => {
const colorMap = { const colorMap = {
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200", amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
@@ -142,10 +158,15 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant
<span <span
className={cn( className={cn(
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide", "inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
colorMap[variant] colorMap[variant],
)} )}
> >
<span className={cn("h-1.5 w-1.5 rounded-full animate-pulse", dotMap[variant])} /> <span
className={cn(
"h-1.5 w-1.5 rounded-full animate-pulse",
dotMap[variant],
)}
/>
{label} {label}
</span> </span>
); );
@@ -160,8 +181,16 @@ interface SplitLayoutProps {
className?: string; className?: string;
} }
export const SplitLayout: React.FC<SplitLayoutProps> = ({ children, className }) => ( export const SplitLayout: React.FC<SplitLayoutProps> = ({
<section className={cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]", className)}> children,
className,
}) => (
<section
className={cn(
"grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]",
className,
)}
>
{children} {children}
</section> </section>
); );
@@ -177,11 +206,27 @@ interface ListPanelProps {
className?: string; className?: string;
} }
export const ListPanel: React.FC<ListPanelProps> = ({ children, header, footer, className }) => ( export const ListPanel: React.FC<ListPanelProps> = ({
<div className={cn("min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}> children,
{header && <div className="border-b border-border/60 px-4 py-3">{header}</div>} header,
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">{children}</div> footer,
{footer && <div className="border-t border-border/60 px-4 py-2">{footer}</div>} className,
}) => (
<div
className={cn(
"min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col",
className,
)}
>
{header && (
<div className="border-b border-border/60 px-4 py-3">{header}</div>
)}
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">
{children}
</div>
{footer && (
<div className="border-t border-border/60 px-4 py-2">{footer}</div>
)}
</div> </div>
); );
@@ -196,14 +241,19 @@ interface ListItemProps {
className?: string; className?: string;
} }
export const ListItem: React.FC<ListItemProps> = ({ selected, onClick, children, className }) => ( export const ListItem: React.FC<ListItemProps> = ({
selected,
onClick,
children,
className,
}) => (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors", "flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
selected ? "bg-muted/40" : "hover:bg-muted/30", selected ? "bg-muted/40" : "hover:bg-muted/30",
className className,
)} )}
aria-pressed={selected} aria-pressed={selected}
> >
@@ -221,12 +271,16 @@ interface DetailPanelProps {
sticky?: boolean; sticky?: boolean;
} }
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => ( export const DetailPanel: React.FC<DetailPanelProps> = ({
children,
className,
sticky = true,
}) => (
<div <div
className={cn( className={cn(
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4", "min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
sticky && "lg:sticky lg:top-24 lg:self-start", sticky && "lg:sticky lg:top-24 lg:self-start",
className className,
)} )}
> >
{children} {children}
@@ -244,11 +298,18 @@ interface EmptyStateProps {
action?: React.ReactNode; action?: React.ReactNode;
} }
export const EmptyState: React.FC<EmptyStateProps> = ({ icon: Icon, title, description, action }) => ( export const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
action,
}) => (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
{Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />} {Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />}
<div className="text-base font-semibold">{title}</div> <div className="text-base font-semibold">{title}</div>
{description && <p className="max-w-md text-sm text-muted-foreground">{description}</p>} {description && (
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-2">{action}</div>} {action && <div className="mt-2">{action}</div>}
</div> </div>
); );
@@ -269,7 +330,10 @@ const getScoreTokens = (score: number) => {
return { bar: "bg-rose-500/80" }; return { bar: "bg-rose-500/80" };
}; };
export const ScoreMeter: React.FC<ScoreMeterProps> = ({ score, showLabel = true }) => { export const ScoreMeter: React.FC<ScoreMeterProps> = ({
score,
showLabel = true,
}) => {
if (score == null) { if (score == null) {
return <span className="text-xs text-muted-foreground">Not scored</span>; return <span className="text-xs text-muted-foreground">Not scored</span>;
} }
@@ -283,7 +347,9 @@ export const ScoreMeter: React.FC<ScoreMeterProps> = ({ score, showLabel = true
style={{ width: `${Math.max(4, Math.min(100, score))}%` }} style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
/> />
</div> </div>
{showLabel && <span className="tabular-nums text-foreground">{score}%</span>} {showLabel && (
<span className="tabular-nums text-foreground">{score}%</span>
)}
</div> </div>
); );
}; };
@@ -304,7 +370,14 @@ export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
children, children,
}) => ( }) => (
<div className="flex flex-1 flex-col overflow-hidden lg:flex-row"> <div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
<div className={cn("flex w-full flex-col border-b lg:border-b-0 lg:border-r", sidebarWidth)}>{sidebar}</div> <div
className={cn(
"flex w-full flex-col border-b lg:border-b-0 lg:border-r",
sidebarWidth,
)}
>
{sidebar}
</div>
<div className="flex-1 overflow-y-auto">{children}</div> <div className="flex-1 overflow-y-auto">{children}</div>
</div> </div>
); );
@@ -318,8 +391,16 @@ interface SectionCardProps {
className?: string; className?: string;
} }
export const SectionCard: React.FC<SectionCardProps> = ({ children, className }) => ( export const SectionCard: React.FC<SectionCardProps> = ({
<section className={cn("rounded-xl border border-border/60 bg-card/40 p-4", className)}> children,
className,
}) => (
<section
className={cn(
"rounded-xl border border-border/60 bg-card/40 p-4",
className,
)}
>
{children} {children}
</section> </section>
); );
@@ -334,7 +415,12 @@ interface PageMainProps {
} }
export const PageMain: React.FC<PageMainProps> = ({ children, className }) => ( export const PageMain: React.FC<PageMainProps> = ({ children, className }) => (
<main className={cn("container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12", className)}> <main
className={cn(
"container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12",
className,
)}
>
{children} {children}
</main> </main>
); );
+77 -71
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import * as api from '../api'; import type { ResumeProfile } from "../../shared/types";
import type { ResumeProfile } from '../../shared/types'; import * as api from "../api";
let profileCache: ResumeProfile | null = null; let profileCache: ResumeProfile | null = null;
let profileError: Error | null = null; let profileError: Error | null = null;
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set(); const subscribers: Set<
(profile: ResumeProfile | null, error: Error | null) => void
> = new Set();
let isFetching = false; let isFetching = false;
/** /**
@@ -12,80 +14,84 @@ let isFetching = false;
* Caches the result to avoid re-fetching. * Caches the result to avoid re-fetching.
*/ */
export function useProfile() { export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache); const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError); const [error, setError] = useState<Error | null>(profileError);
useEffect(() => { useEffect(() => {
if (profileCache) { if (profileCache) {
setProfile(profileCache); setProfile(profileCache);
} }
if (profileError) { if (profileError) {
setError(profileError); setError(profileError);
} }
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => { const handleUpdate = (
setProfile(newProfile); newProfile: ResumeProfile | null,
setError(newError); newError: Error | null,
}; ) => {
setProfile(newProfile);
subscribers.add(handleUpdate); setError(newError);
if (!profileCache && !isFetching) {
isFetching = true;
profileError = null;
api.getProfile()
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach(sub => sub(profileCache, null));
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
throw profileError;
} finally {
isFetching = false;
}
}; };
return { subscribers.add(handleUpdate);
profile,
error, if (!profileCache && !isFetching) {
isLoading: !profile && isFetching && !error, isFetching = true;
personName: profile?.basics?.name || 'Resume', profileError = null;
refreshProfile, api
.getProfile()
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, null));
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(profileCache, profileError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
}; };
}, []);
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach((sub) => sub(profileCache, null));
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, null));
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(profileCache, profileError));
throw profileError;
} finally {
isFetching = false;
}
};
return {
profile,
error,
isLoading: !profile && isFetching && !error,
personName: profile?.basics?.name || "Resume",
refreshProfile,
};
} }
/** @internal For testing only */ /** @internal For testing only */
export function _resetProfileCache() { export function _resetProfileCache() {
profileCache = null; profileCache = null;
profileError = null; profileError = null;
isFetching = false; isFetching = false;
subscribers.clear(); subscribers.clear();
} }
@@ -1,9 +1,8 @@
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useRescoreJob } from "./useRescoreJob";
import * as api from "../api";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { useRescoreJob } from "./useRescoreJob";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
rescoreJob: vi.fn(), rescoreJob: vi.fn(),
@@ -16,7 +16,10 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
toast.success("Match recalculated"); toast.success("Match recalculated");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to recalculate match"; const message =
error instanceof Error
? error.message
: "Failed to recalculate match";
toast.error(message); toast.error(message);
} finally { } finally {
setIsRescoring(false); setIsRescoring(false);
@@ -1,80 +1,80 @@
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSettings, _resetSettingsCache } from './useSettings'; import * as api from "../api";
import * as api from '../api'; import { _resetSettingsCache, useSettings } from "./useSettings";
vi.mock('../api', () => ({ vi.mock("../api", () => ({
getSettings: vi.fn(), getSettings: vi.fn(),
})); }));
describe('useSettings', () => { describe("useSettings", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
_resetSettingsCache(); _resetSettingsCache();
});
it("fetches settings on mount if not already cached", async () => {
const mockSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValue(mockSettings);
const { result } = renderHook(() => useSettings());
// Should start in loading state
expect(result.current.settings).toBeNull();
await waitFor(() => {
expect(result.current.settings).toEqual(mockSettings);
}); });
it('fetches settings on mount if not already cached', async () => { expect(result.current.showSponsorInfo).toBe(false);
const mockSettings = { showSponsorInfo: false }; expect(api.getSettings).toHaveBeenCalledTimes(1);
(api.getSettings as any).mockResolvedValue(mockSettings); });
const { result } = renderHook(() => useSettings()); it("uses default values when settings are null", async () => {
(api.getSettings as any).mockResolvedValue(null);
// Should start in loading state const { result } = renderHook(() => useSettings());
expect(result.current.settings).toBeNull();
await waitFor(() => { await waitFor(() => {
expect(result.current.settings).toEqual(mockSettings); // settings is null, so showSponsorInfo should default to true
}); expect(result.current.showSponsorInfo).toBe(true);
});
});
expect(result.current.showSponsorInfo).toBe(false); it("provides a refresh function that updates settings", async () => {
expect(api.getSettings).toHaveBeenCalledTimes(1); const initialSettings = { showSponsorInfo: true };
const updatedSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings);
}); });
it('uses default values when settings are null', async () => { let refreshed;
(api.getSettings as any).mockResolvedValue(null); await waitFor(async () => {
refreshed = await result.current.refreshSettings();
const { result } = renderHook(() => useSettings());
await waitFor(() => {
// settings is null, so showSponsorInfo should default to true
expect(result.current.showSponsorInfo).toBe(true);
});
}); });
it('provides a refresh function that updates settings', async () => { expect(refreshed).toEqual(updatedSettings);
const initialSettings = { showSponsorInfo: true }; expect(result.current.settings).toEqual(updatedSettings);
const updatedSettings = { showSponsorInfo: false }; expect(result.current.showSponsorInfo).toBe(false);
});
(api.getSettings as any).mockResolvedValueOnce(initialSettings); it("handles errors when fetching settings", async () => {
(api.getSettings as any).mockResolvedValueOnce(updatedSettings); const mockError = new Error("Failed to fetch");
(api.getSettings as any).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings()); const { result } = renderHook(() => useSettings());
await waitFor(() => { await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings); expect(result.current.error).toEqual(mockError);
});
let refreshed;
await waitFor(async () => {
refreshed = await result.current.refreshSettings();
});
expect(refreshed).toEqual(updatedSettings);
expect(result.current.settings).toEqual(updatedSettings);
expect(result.current.showSponsorInfo).toBe(false);
}); });
it('handles errors when fetching settings', async () => { expect(result.current.isLoading).toBe(false);
const mockError = new Error('Failed to fetch'); expect(result.current.settings).toBeNull();
(api.getSettings as any).mockRejectedValue(mockError); });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.error).toEqual(mockError);
});
expect(result.current.isLoading).toBe(false);
expect(result.current.settings).toBeNull();
});
}); });
+77 -71
View File
@@ -1,87 +1,93 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import type { AppSettings } from '../../shared/types'; import type { AppSettings } from "../../shared/types";
import * as api from '../api'; import * as api from "../api";
let settingsCache: AppSettings | null = null; let settingsCache: AppSettings | null = null;
let settingsError: Error | null = null; let settingsError: Error | null = null;
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set(); const subscribers: Set<
(settings: AppSettings | null, error: Error | null) => void
> = new Set();
let isFetching = false; let isFetching = false;
export function useSettings() { export function useSettings() {
const [settings, setSettings] = useState<AppSettings | null>(settingsCache); const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
const [error, setError] = useState<Error | null>(settingsError); const [error, setError] = useState<Error | null>(settingsError);
useEffect(() => { useEffect(() => {
if (settingsCache) { if (settingsCache) {
setSettings(settingsCache); setSettings(settingsCache);
} }
if (settingsError) { if (settingsError) {
setError(settingsError); setError(settingsError);
} }
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => { const handleUpdate = (
setSettings(newSettings); newSettings: AppSettings | null,
setError(newError); newError: Error | null,
}; ) => {
setSettings(newSettings);
subscribers.add(handleUpdate); setError(newError);
if (!settingsCache && !isFetching) {
isFetching = true;
settingsError = null;
api.getSettings()
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach(sub => sub(data, null));
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(settingsCache, settingsError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach(sub => sub(settingsCache, null));
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach(sub => sub(data, null));
return data;
} catch (err) {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(settingsCache, settingsError));
throw settingsError;
} finally {
isFetching = false;
}
}; };
return { subscribers.add(handleUpdate);
settings,
error, if (!settingsCache && !isFetching) {
isLoading: !settings && isFetching && !error, isFetching = true;
showSponsorInfo: settings?.showSponsorInfo ?? true, settingsError = null;
refreshSettings, api
.getSettings()
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, null));
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(settingsCache, settingsError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
}; };
}, []);
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach((sub) => sub(settingsCache, null));
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, null));
return data;
} catch (err) {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(settingsCache, settingsError));
throw settingsError;
} finally {
isFetching = false;
}
};
return {
settings,
error,
isLoading: !settings && isFetching && !error,
showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings,
};
} }
/** @internal For testing only */ /** @internal For testing only */
export function _resetSettingsCache() { export function _resetSettingsCache() {
settingsCache = null; settingsCache = null;
settingsError = null; settingsError = null;
isFetching = false; isFetching = false;
subscribers.clear(); subscribers.clear();
} }
+7 -7
View File
@@ -1,13 +1,13 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from "react-router-dom";
import { App } from './App'; import { App } from "./App";
import '../index.css'; import "../index.css";
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>,
); );
@@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OrchestratorPage } from "./OrchestratorPage";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants"; import type { FilterTab } from "./orchestrator/constants";
const jobFixture: Job = { const jobFixture: Job = {
@@ -138,8 +137,12 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
<div data-testid="filters"> <div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div> <div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button> <button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button> <button onClick={() => onSearchQueryChange("test search")}>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button> Set Search
</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>
Set Sort
</button>
</div> </div>
), ),
})); }));
@@ -149,13 +152,27 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({
})); }));
vi.mock("./orchestrator/JobListPanel", () => ({ vi.mock("./orchestrator/JobListPanel", () => ({
JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => ( JobListPanel: ({
onSelectJob,
selectedJobId,
}: {
onSelectJob: (id: string) => void;
selectedJobId: string | null;
}) => (
<div> <div>
<div data-testid="selected-job">{selectedJobId ?? "none"}</div> <div data-testid="selected-job">{selectedJobId ?? "none"}</div>
<button data-testid="select-job-1" type="button" onClick={() => onSelectJob("job-1")}> <button
data-testid="select-job-1"
type="button"
onClick={() => onSelectJob("job-1")}
>
Select job 1 Select job 1
</button> </button>
<button data-testid="select-job-2" type="button" onClick={() => onSelectJob("job-2")}> <button
data-testid="select-job-2"
type="button"
onClick={() => onSelectJob("job-2")}
>
Select job 2 Select job 2
</button> </button>
</div> </div>
@@ -168,7 +185,9 @@ vi.mock("../components", () => ({
const LocationWatcher = () => { const LocationWatcher = () => {
const location = useLocation(); const location = useLocation();
return <div data-testid="location">{location.pathname + location.search}</div>; return (
<div data-testid="location">{location.pathname + location.search}</div>
);
}; };
describe("OrchestratorPage", () => { describe("OrchestratorPage", () => {
@@ -177,7 +196,9 @@ describe("OrchestratorPage", () => {
}); });
it("syncs tab selection to the URL", () => { it("syncs tab selection to the URL", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready"]}> <MemoryRouter initialEntries={["/ready"]}>
@@ -186,7 +207,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
fireEvent.click(screen.getByText("To Discovered")); fireEvent.click(screen.getByText("To Discovered"));
@@ -194,7 +215,9 @@ describe("OrchestratorPage", () => {
}); });
it("syncs job selection to the URL", async () => { it("syncs job selection to the URL", async () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/all"]}> <MemoryRouter initialEntries={["/all"]}>
@@ -203,7 +226,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
// Initial load will auto-select the first matching job (job-1 for all tab) // Initial load will auto-select the first matching job (job-1 for all tab)
@@ -221,7 +244,9 @@ describe("OrchestratorPage", () => {
}); });
it("syncs search query to URL as a parameter", () => { it("syncs search query to URL as a parameter", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready"]}> <MemoryRouter initialEntries={["/ready"]}>
@@ -230,15 +255,19 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
fireEvent.click(screen.getByText("Set Search")); fireEvent.click(screen.getByText("Set Search"));
expect(screen.getByTestId("location").textContent).toContain("q=test+search"); expect(screen.getByTestId("location").textContent).toContain(
"q=test+search",
);
}); });
it("syncs sorting to URL and removes it when default", () => { it("syncs sorting to URL and removes it when default", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready"]}> <MemoryRouter initialEntries={["/ready"]}>
@@ -247,15 +276,19 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
fireEvent.click(screen.getByText("Set Sort")); fireEvent.click(screen.getByText("Set Sort"));
expect(screen.getByTestId("location").textContent).toContain("sort=title-asc"); expect(screen.getByTestId("location").textContent).toContain(
"sort=title-asc",
);
}); });
it("opens the detail drawer on mobile when a job is selected", () => { it("opens the detail drawer on mobile when a job is selected", () => {
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
false,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready"]}> <MemoryRouter initialEntries={["/ready"]}>
@@ -263,7 +296,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument(); expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
@@ -274,7 +307,9 @@ describe("OrchestratorPage", () => {
}); });
it("renders the detail panel inline on desktop", () => { it("renders the detail panel inline on desktop", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready"]}> <MemoryRouter initialEntries={["/ready"]}>
@@ -282,14 +317,16 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} /> <Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
expect(screen.getByTestId("detail-panel")).toBeInTheDocument(); expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
}); });
it("clears source filter when no jobs exist for it", async () => { it("clears source filter when no jobs exist for it", async () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia; window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render( render(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}> <MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
@@ -297,11 +334,13 @@ describe("OrchestratorPage", () => {
<Routes> <Routes>
<Route path="/:tab" element={<OrchestratorPage />} /> <Route path="/:tab" element={<OrchestratorPage />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("location").textContent).not.toContain("source=ukvisajobs"); expect(screen.getByTestId("location").textContent).not.toContain(
"source=ukvisajobs",
);
}); });
}); });
}); });
@@ -2,18 +2,18 @@
* Orchestrator layout with a split list/detail experience. * Orchestrator layout with a split list/detail experience.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useSettings } from "@client/hooks/useSettings";
import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { ManualImportSheet } from "../components";
import * as api from "../api";
import type { JobSource } from "../../shared/types"; import type { JobSource } from "../../shared/types";
import { DEFAULT_SORT } from "./orchestrator/constants"; import * as api from "../api";
import { ManualImportSheet } from "../components";
import type { FilterTab, JobSort } from "./orchestrator/constants"; import type { FilterTab, JobSort } from "./orchestrator/constants";
import { DEFAULT_SORT } from "./orchestrator/constants";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
import { JobListPanel } from "./orchestrator/JobListPanel"; import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
@@ -22,8 +22,11 @@ import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources"; import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { useSettings } from "@client/hooks/useSettings"; import {
import { getEnabledSources, getJobCounts, getSourcesWithJobs } from "./orchestrator/utils"; getEnabledSources,
getJobCounts,
getSourcesWithJobs,
} from "./orchestrator/utils";
export const OrchestratorPage: React.FC = () => { export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>(); const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
@@ -43,7 +46,9 @@ export const OrchestratorPage: React.FC = () => {
(newTab: string, newJobId?: string | null, isReplace = false) => { (newTab: string, newJobId?: string | null, isReplace = false) => {
const search = searchParams.toString(); const search = searchParams.toString();
const suffix = search ? `?${search}` : ""; const suffix = search ? `?${search}` : "";
const path = newJobId ? `/${newTab}/${newJobId}${suffix}` : `/${newTab}${suffix}`; const path = newJobId
? `/${newTab}/${newJobId}${suffix}`
: `/${newTab}${suffix}`;
navigate(path, { replace: isReplace }); navigate(path, { replace: isReplace });
}, },
[navigate, searchParams], [navigate, searchParams],
@@ -65,7 +70,8 @@ export const OrchestratorPage: React.FC = () => {
}; };
// Sync sourceFilter with URL // Sync sourceFilter with URL
const sourceFilter = (searchParams.get("source") as JobSource | "all") || "all"; const sourceFilter =
(searchParams.get("source") as JobSource | "all") || "all";
const setSourceFilter = (source: JobSource | "all") => { const setSourceFilter = (source: JobSource | "all") => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
@@ -88,7 +94,10 @@ export const OrchestratorPage: React.FC = () => {
const setSort = (newSort: JobSort) => { const setSort = (newSort: JobSort) => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
if (newSort.key === DEFAULT_SORT.key && newSort.direction === DEFAULT_SORT.direction) { if (
newSort.key === DEFAULT_SORT.key &&
newSort.direction === DEFAULT_SORT.direction
) {
prev.delete("sort"); prev.delete("sort");
} else { } else {
prev.set("sort", `${newSort.key}-${newSort.direction}`); prev.set("sort", `${newSort.key}-${newSort.direction}`);
@@ -107,12 +116,13 @@ export const OrchestratorPage: React.FC = () => {
} }
}, [tab, navigateWithContext]); }, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false); const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(() =>
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false), typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
); );
const setActiveTab = (newTab: FilterTab) => { const setActiveTab = (newTab: FilterTab) => {
@@ -124,15 +134,35 @@ export const OrchestratorPage: React.FC = () => {
}; };
const { settings } = useSettings(); const { settings } = useSettings();
const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); const {
const enabledSources = useMemo(() => getEnabledSources(settings ?? null), [settings]); jobs,
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(enabledSources); stats,
isLoading,
isPipelineRunning,
setIsPipelineRunning,
loadJobs,
} = useOrchestratorData();
const enabledSources = useMemo(
() => getEnabledSources(settings ?? null),
[settings],
);
const { pipelineSources, setPipelineSources, toggleSource } =
usePipelineSources(enabledSources);
const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort); const activeJobs = useFilteredJobs(
jobs,
activeTab,
sourceFilter,
searchQuery,
sort,
);
const counts = useMemo(() => getJobCounts(jobs), [jobs]); const counts = useMemo(() => getJobCounts(jobs), [jobs]);
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]); const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const selectedJob = useMemo( const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), () =>
selectedJobId
? (jobs.find((job) => job.id === selectedJobId) ?? null)
: null,
[jobs, selectedJobId], [jobs, selectedJobId],
); );
@@ -175,7 +205,8 @@ export const OrchestratorPage: React.FC = () => {
}, 5000); }, 5000);
} catch (error) { } catch (error) {
setIsPipelineRunning(false); setIsPipelineRunning(false);
const message = error instanceof Error ? error.message : "Failed to start pipeline"; const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message); toast.error(message);
} }
}; };
@@ -237,20 +268,23 @@ export const OrchestratorPage: React.FC = () => {
return ( return (
<> <>
<OrchestratorHeader <OrchestratorHeader
navOpen={navOpen} navOpen={navOpen}
onNavOpenChange={setNavOpen} onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning} isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources} pipelineSources={pipelineSources}
enabledSources={enabledSources} enabledSources={enabledSources}
onToggleSource={toggleSource} onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources} onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline} onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)} onOpenManualImport={() => setIsManualImportOpen(true)}
/> />
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12"> <main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<OrchestratorSummary stats={stats} isPipelineRunning={isPipelineRunning} /> <OrchestratorSummary
stats={stats}
isPipelineRunning={isPipelineRunning}
/>
{/* Main content: tabs/filters -> list/detail */} {/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4"> <section className="space-y-4">
@@ -307,7 +341,9 @@ export const OrchestratorPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}> <Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2"> <div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div> <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job details
</div>
<DrawerClose asChild> <DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs"> <Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close Close
@@ -1,18 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest" import type { AppSettings } from "@shared/types";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react" import {
import { MemoryRouter } from "react-router-dom" fireEvent,
render,
import { SettingsPage } from "./SettingsPage" screen,
import * as api from "../api" waitFor,
import { toast } from "sonner" within,
import type { AppSettings } from "@shared/types" } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { SettingsPage } from "./SettingsPage";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
getSettings: vi.fn(), getSettings: vi.fn(),
updateSettings: vi.fn(), updateSettings: vi.fn(),
clearDatabase: vi.fn(), clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(), deleteJobsByStatus: vi.fn(),
})) }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: { toast: {
@@ -20,7 +25,7 @@ vi.mock("sonner", () => ({
error: vi.fn(), error: vi.fn(),
info: vi.fn(), info: vi.fn(),
}, },
})) }));
const baseSettings: AppSettings = { const baseSettings: AppSettings = {
model: "google/gemini-3-flash-preview", model: "google/gemini-3-flash-preview",
@@ -104,146 +109,173 @@ const baseSettings: AppSettings = {
ukvisajobsPasswordHint: null, ukvisajobsPasswordHint: null,
webhookSecretHint: null, webhookSecretHint: null,
basicAuthActive: false, basicAuthActive: false,
} };
const renderPage = () => { const renderPage = () => {
return render( return render(
<MemoryRouter initialEntries={["/settings"]}> <MemoryRouter initialEntries={["/settings"]}>
<SettingsPage /> <SettingsPage />
</MemoryRouter> </MemoryRouter>,
) );
} };
describe("SettingsPage", () => { describe("SettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks();
}) });
it("saves trimmed model overrides", async () => { it("saves trimmed model overrides", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({ vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings, ...baseSettings,
overrideModel: "gpt-4", overrideModel: "gpt-4",
model: "gpt-4", model: "gpt-4",
}) });
renderPage() renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i }) const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger) fireEvent.click(modelTrigger);
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main") const modelField =
const modelInput = within(modelField).getByRole("textbox") screen.getByText("Override model").parentElement ??
fireEvent.change(modelInput, { target: { value: " gpt-4 " } }) screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled()) await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton) fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.updateSettings).toHaveBeenCalledWith( expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
model: "gpt-4", model: "gpt-4",
}) }),
) );
expect(toast.success).toHaveBeenCalledWith("Settings saved") expect(toast.success).toHaveBeenCalledWith("Settings saved");
}) });
it("shows validation error for too long model override", async () => { it("shows validation error for too long model override", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i }) const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger) fireEvent.click(modelTrigger);
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main") const modelField =
const modelInput = within(modelField).getByRole("textbox") screen.getByText("Override model").parentElement ??
screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
// Change to > 200 chars // Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } }) fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
// Should see error message // Should see error message
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument() expect(
await screen.findByText(
/String must contain at most 200 character\(s\)/i,
),
).toBeInTheDocument();
// Save button should be disabled due to validation error (isValid will be false) // Save button should be disabled due to validation error (isValid will be false)
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled() expect(saveButton).toBeDisabled();
}) });
it("clears jobs by status and summarizes results", async () => { it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 }) vi.mocked(api.deleteJobsByStatus).mockResolvedValue({
message: "",
count: 2,
});
renderPage() renderPage();
const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i }) const dangerTrigger = await screen.findByRole("button", {
fireEvent.click(dangerTrigger) name: /danger zone/i,
});
fireEvent.click(dangerTrigger);
const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i }) const clearSelectedButton = await screen.findByRole("button", {
fireEvent.click(clearSelectedButton) name: /clear selected/i,
});
fireEvent.click(clearSelectedButton);
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i }) const confirmButton = await screen.findByRole("button", {
fireEvent.click(confirmButton) name: /clear 1 status/i,
});
fireEvent.click(confirmButton);
await waitFor(() => expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered")) await waitFor(() =>
expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered"),
);
expect(toast.success).toHaveBeenCalledWith( expect(toast.success).toHaveBeenCalledWith(
"Jobs cleared", "Jobs cleared",
expect.objectContaining({ expect.objectContaining({
description: "Deleted 2 jobs: 2 discovered", description: "Deleted 2 jobs: 2 discovered",
}) }),
) );
}) });
it("enables save button when model is changed", async () => { it("enables save button when model is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled() expect(saveButton).toBeDisabled();
const modelTrigger = await screen.findByRole("button", { name: /model/i }) const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger) fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/override model/i) const modelInput = screen.getByLabelText(/override model/i);
fireEvent.change(modelInput, { target: { value: "new-model" } }) fireEvent.change(modelInput, { target: { value: "new-model" } });
expect(saveButton).toBeEnabled() expect(saveButton).toBeEnabled();
}) });
it("enables save button when numeric setting is changed", async () => { it("enables save button when numeric setting is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i }) const visaTrigger = await screen.findByRole("button", {
fireEvent.click(visaTrigger) name: /ukvisajobs extractor/i,
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i) });
fireEvent.change(maxJobsInput, { target: { value: "100" } }) fireEvent.click(visaTrigger);
expect(saveButton).toBeEnabled() const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i);
}) fireEvent.change(maxJobsInput, { target: { value: "100" } });
expect(saveButton).toBeEnabled();
});
it("enables save button when display setting is changed", async () => { it("enables save button when display setting is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
const displayTrigger = await screen.findByRole("button", { name: /display settings/i }) const displayTrigger = await screen.findByRole("button", {
fireEvent.click(displayTrigger) name: /display settings/i,
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i) });
fireEvent.click(sponsorCheckbox) fireEvent.click(displayTrigger);
expect(saveButton).toBeEnabled() const sponsorCheckbox = screen.getByLabelText(
}) /show visa sponsor information/i,
);
fireEvent.click(sponsorCheckbox);
expect(saveButton).toBeEnabled();
});
it("enables save button when basic auth toggle is changed", async () => { it("enables save button when basic auth toggle is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) const envTrigger = await screen.findByRole("button", {
fireEvent.click(envTrigger) name: /environment & accounts/i,
const authCheckbox = screen.getByLabelText(/enable basic authentication/i) });
fireEvent.click(authCheckbox) fireEvent.click(envTrigger);
expect(saveButton).toBeEnabled() const authCheckbox = screen.getByLabelText(/enable basic authentication/i);
}) fireEvent.click(authCheckbox);
expect(saveButton).toBeEnabled();
});
it("wipes basic auth credentials when toggle is disabled and saved", async () => { it("wipes basic auth credentials when toggle is disabled and saved", async () => {
// Initial state: Basic Auth is active // Initial state: Basic Auth is active
@@ -252,32 +284,34 @@ describe("SettingsPage", () => {
basicAuthActive: true, basicAuthActive: true,
basicAuthUser: "admin", basicAuthUser: "admin",
basicAuthPasswordHint: "pass", basicAuthPasswordHint: "pass",
} };
vi.mocked(api.getSettings).mockResolvedValue(activeSettings) vi.mocked(api.getSettings).mockResolvedValue(activeSettings);
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings) vi.mocked(api.updateSettings).mockResolvedValue(baseSettings);
renderPage() renderPage();
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) const envTrigger = await screen.findByRole("button", {
fireEvent.click(envTrigger) name: /environment & accounts/i,
});
fireEvent.click(envTrigger);
const authCheckbox = screen.getByLabelText(/enable basic authentication/i) const authCheckbox = screen.getByLabelText(/enable basic authentication/i);
expect(authCheckbox).toBeChecked() expect(authCheckbox).toBeChecked();
// Disable it // Disable it
fireEvent.click(authCheckbox) fireEvent.click(authCheckbox);
expect(authCheckbox).not.toBeChecked() expect(authCheckbox).not.toBeChecked();
const saveButton = screen.getByRole("button", { name: /^save$/i }) const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeEnabled() expect(saveButton).toBeEnabled();
fireEvent.click(saveButton) fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.updateSettings).toHaveBeenCalledWith( expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
basicAuthUser: null, basicAuthUser: null,
basicAuthPassword: null, basicAuthPassword: null,
}) }),
) );
}) });
}) });
+296 -219
View File
@@ -1,27 +1,35 @@
import React, { useEffect, useState } from "react" import * as api from "@client/api";
import { Settings } from "lucide-react" import { PageHeader } from "@client/components/layout";
import { toast } from "sonner" import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { useForm, FormProvider } from "react-hook-form" import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { zodResolver } from "@hookform/resolvers/zod" import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection";
import { PageHeader } from "@client/components/layout" import { JobspySection } from "@client/pages/settings/components/JobspySection";
import { Accordion } from "@/components/ui/accordion" import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { Button } from "@/components/ui/button" import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema" import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
import * as api from "@client/api" import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
import { arraysEqual } from "@/lib/utils" import { resumeProjectsEqual } from "@client/pages/settings/utils";
import { resumeProjectsEqual } from "@client/pages/settings/utils" import { zodResolver } from "@hookform/resolvers/zod";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" import {
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" type UpdateSettingsInput,
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection" updateSettingsSchema,
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" } from "@shared/settings-schema";
import { JobspySection } from "@client/pages/settings/components/JobspySection" import type {
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" AppSettings,
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" JobStatus,
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" ResumeProjectCatalogItem,
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" ResumeProjectsSettings,
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection" } from "@shared/types";
import { Settings } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { arraysEqual } from "@/lib/utils";
const DEFAULT_FORM_VALUES: UpdateSettingsInput = { const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "", model: "",
@@ -51,7 +59,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
ukvisajobsPassword: "", ukvisajobsPassword: "",
webhookSecret: "", webhookSecret: "",
enableBasicAuth: false, enableBasicAuth: false,
} };
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null, model: null,
@@ -81,7 +89,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
ukvisajobsPassword: null, ukvisajobsPassword: null,
webhookSecret: null, webhookSecret: null,
enableBasicAuth: undefined, enableBasicAuth: undefined,
} };
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "", model: data.overrideModel ?? "",
@@ -111,71 +119,86 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
ukvisajobsPassword: "", ukvisajobsPassword: "",
webhookSecret: "", webhookSecret: "",
enableBasicAuth: data.basicAuthActive, enableBasicAuth: data.basicAuthActive,
}) });
const normalizeString = (value: string | null | undefined) => { const normalizeString = (value: string | null | undefined) => {
const trimmed = value?.trim() const trimmed = value?.trim();
return trimmed ? trimmed : null return trimmed ? trimmed : null;
} };
const normalizePrivateInput = (value: string | null | undefined) => { const normalizePrivateInput = (value: string | null | undefined) => {
const trimmed = value?.trim() const trimmed = value?.trim();
if (trimmed === "") return null if (trimmed === "") return null;
return trimmed || undefined return trimmed || undefined;
} };
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { const isSameStringList = (
if (!left && !right) return true left: string[] | null | undefined,
if (!left || !right) return false right: string[] | null | undefined,
return arraysEqual(left, right) ) => {
} if (!left && !right) return true;
if (!left || !right) return false;
return arraysEqual(left, right);
};
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { const isSameSortedStringList = (
if (!left && !right) return true left: string[] | null | undefined,
if (!left || !right) return false right: string[] | null | undefined,
return arraysEqual(left.slice().sort(), right.slice().sort()) ) => {
} if (!left && !right) return true;
if (!left || !right) return false;
return arraysEqual(left.slice().sort(), right.slice().sort());
};
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) => const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
value === defaultValue ? null : value ?? null value === defaultValue ? null : (value ?? null);
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) => const nullIfSameList = (
isSameStringList(value, defaultValue) ? null : value ?? null value: string[] | null | undefined,
defaultValue: string[],
) => (isSameStringList(value, defaultValue) ? null : (value ?? null));
const normalizeResumeProjectsForCatalog = ( const normalizeResumeProjectsForCatalog = (
catalog: ResumeProjectCatalogItem[], catalog: ResumeProjectCatalogItem[],
current: ResumeProjectsSettings | null current: ResumeProjectsSettings | null,
): ResumeProjectsSettings | null => { ): ResumeProjectsSettings | null => {
const allowed = new Set(catalog.map((project) => project.id)) const allowed = new Set(catalog.map((project) => project.id));
const base = current ?? { const base = current ?? {
maxProjects: 0, maxProjects: 0,
lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id), lockedProjectIds: catalog
.filter((project) => project.isVisibleInBase)
.map((project) => project.id),
aiSelectableProjectIds: [], aiSelectableProjectIds: [],
} };
const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id)) const lockedProjectIds = base.lockedProjectIds.filter((id) =>
const lockedSet = new Set(lockedProjectIds) allowed.has(id),
const aiSelectableProjectIds = (current );
? base.aiSelectableProjectIds const lockedSet = new Set(lockedProjectIds);
: catalog.map((project) => project.id) const aiSelectableProjectIds = (
current ? base.aiSelectableProjectIds : catalog.map((project) => project.id)
) )
.filter((id) => allowed.has(id)) .filter((id) => allowed.has(id))
.filter((id) => !lockedSet.has(id)) .filter((id) => !lockedSet.has(id));
const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0 const maxProjectsRaw = Number.isFinite(base.maxProjects)
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw)) ? base.maxProjects
: 0;
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
const maxProjects = Math.min( const maxProjects = Math.min(
catalog.length, catalog.length,
Math.max(lockedProjectIds.length, maxProjectsInt, 3) Math.max(lockedProjectIds.length, maxProjectsInt, 3),
) );
return { maxProjects, lockedProjectIds, aiSelectableProjectIds } return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
} };
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) => const nullIfSameSortedList = (
isSameSortedStringList(value, defaultValue) ? null : value ?? null value: string[] | null | undefined,
defaultValue: string[],
) => (isSameSortedStringList(value, defaultValue) ? null : (value ?? null));
const getDerivedSettings = (settings: AppSettings | null) => { const getDerivedSettings = (settings: AppSettings | null) => {
const profileProjects = settings?.profileProjects ?? [] const profileProjects = settings?.profileProjects ?? [];
return { return {
model: { model: {
@@ -254,23 +277,30 @@ const getDerivedSettings = (settings: AppSettings | null) => {
profileProjects, profileProjects,
maxProjectsTotal: profileProjects.length, maxProjectsTotal: profileProjects.length,
} };
} };
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null) const [settings, setSettings] = useState<AppSettings | null>(null);
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered']) const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null) "discovered",
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<ResumeProjectCatalogItem[] | null>(null) ]);
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false) const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<
string | null
>(null);
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<
ResumeProjectCatalogItem[] | null
>(null);
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
useState(false);
const methods = useForm<UpdateSettingsInput>({ const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema), resolver: zodResolver(updateSettingsSchema),
mode: "onChange", mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES, defaultValues: DEFAULT_FORM_VALUES,
}) });
const { const {
handleSubmit, handleSubmit,
@@ -279,94 +309,99 @@ export const SettingsPage: React.FC = () => {
setValue, setValue,
getValues, getValues,
watch, watch,
formState: { isDirty, errors, isValid, dirtyFields } formState: { isDirty, errors, isValid, dirtyFields },
} = methods } = methods;
const hasRxResumeAccess = Boolean( const hasRxResumeAccess = Boolean(
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
) );
useEffect(() => { useEffect(() => {
let isMounted = true let isMounted = true;
setIsLoading(true) setIsLoading(true);
api api
.getSettings() .getSettings()
.then((data) => { .then((data) => {
if (!isMounted) return if (!isMounted) return;
setSettings(data) setSettings(data);
reset(mapSettingsToForm(data)) reset(mapSettingsToForm(data));
}) })
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to load settings";
toast.error(message);
}) })
.finally(() => { .finally(() => {
if (!isMounted) return if (!isMounted) return;
setIsLoading(false) setIsLoading(false);
}) });
return () => { return () => {
isMounted = false isMounted = false;
} };
}, [reset]) }, [reset]);
useEffect(() => { useEffect(() => {
if (!settings) return if (!settings) return;
const storedId = settings.rxresumeBaseResumeId ?? null const storedId = settings.rxresumeBaseResumeId ?? null;
setRxResumeBaseResumeIdDraft(storedId) setRxResumeBaseResumeIdDraft(storedId);
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false }) setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
setRxResumeProjectsOverride(null) setRxResumeProjectsOverride(null);
}, [settings, setValue]) }, [settings, setValue]);
useEffect(() => { useEffect(() => {
let isMounted = true let isMounted = true;
const controller = new AbortController() const controller = new AbortController();
if (!rxResumeBaseResumeIdDraft) { if (!rxResumeBaseResumeIdDraft) {
setRxResumeProjectsOverride(null) setRxResumeProjectsOverride(null);
return () => { return () => {
isMounted = false isMounted = false;
controller.abort() controller.abort();
} };
} }
if (!hasRxResumeAccess) return () => { if (!hasRxResumeAccess)
isMounted = false return () => {
controller.abort() isMounted = false;
} controller.abort();
};
setIsFetchingRxResumeProjects(true) setIsFetchingRxResumeProjects(true);
api api
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal) .getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
.then((projects) => { .then((projects) => {
if (!isMounted) return if (!isMounted) return;
setRxResumeProjectsOverride(projects) setRxResumeProjectsOverride(projects);
const normalized = normalizeResumeProjectsForCatalog( const normalized = normalizeResumeProjectsForCatalog(
projects, projects,
getValues("resumeProjects") ?? null getValues("resumeProjects") ?? null,
) );
if (normalized) { if (normalized) {
setValue("resumeProjects", normalized, { shouldDirty: true }) setValue("resumeProjects", normalized, { shouldDirty: true });
} }
}) })
.catch((error) => { .catch((error) => {
if (!isMounted || error.name === 'AbortError') return if (!isMounted || error.name === "AbortError") return;
const message = error instanceof Error ? error.message : "Failed to load RxResume projects" const message =
toast.error(message) error instanceof Error
setRxResumeProjectsOverride(null) ? error.message
: "Failed to load RxResume projects";
toast.error(message);
setRxResumeProjectsOverride(null);
}) })
.finally(() => { .finally(() => {
if (!isMounted) return if (!isMounted) return;
setIsFetchingRxResumeProjects(false) setIsFetchingRxResumeProjects(false);
}) });
return () => { return () => {
isMounted = false isMounted = false;
controller.abort() controller.abort();
} };
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]) }, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]);
const derived = getDerivedSettings(settings) const derived = getDerivedSettings(settings);
const { const {
model, model,
pipelineWebhook, pipelineWebhook,
@@ -380,79 +415,87 @@ export const SettingsPage: React.FC = () => {
defaultResumeProjects, defaultResumeProjects,
profileProjects, profileProjects,
maxProjectsTotal, maxProjectsTotal,
} = derived } = derived;
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
const effectiveMaxProjectsTotal = effectiveProfileProjects.length const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
const watchedValues = watch() const watchedValues = watch();
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0 const lockedCount =
watchedValues.resumeProjects?.lockedProjectIds.length ?? 0;
const canSave = isDirty && isValid const canSave = isDirty && isValid;
const onSave = async (data: UpdateSettingsInput) => { const onSave = async (data: UpdateSettingsInput) => {
if (!settings) return if (!settings) return;
if (data.enableBasicAuth && !settings.basicAuthActive) { if (data.enableBasicAuth && !settings.basicAuthActive) {
const password = data.basicAuthPassword?.trim() ?? "" const password = data.basicAuthPassword?.trim() ?? "";
if (!password) { if (!password) {
setError("basicAuthPassword", { setError("basicAuthPassword", {
type: "manual", type: "manual",
message: "Password is required when basic auth is enabled", message: "Password is required when basic auth is enabled",
}) });
return return;
} }
} }
try { try {
setIsSaving(true) setIsSaving(true);
// Prepare payload: nullify if equal to default // Prepare payload: nullify if equal to default
const resumeProjectsData = data.resumeProjects const resumeProjectsData = data.resumeProjects;
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)) const resumeProjectsOverride =
? null resumeProjectsData &&
: resumeProjectsData defaultResumeProjects &&
resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)
? null
: resumeProjectsData;
const envPayload: Partial<UpdateSettingsInput> = {} const envPayload: Partial<UpdateSettingsInput> = {};
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) { if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail) envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
} }
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
} }
if (data.enableBasicAuth === false) { if (data.enableBasicAuth === false) {
envPayload.basicAuthUser = null envPayload.basicAuthUser = null;
envPayload.basicAuthPassword = null envPayload.basicAuthPassword = null;
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) { } else if (
dirtyFields.enableBasicAuth ||
dirtyFields.basicAuthUser ||
dirtyFields.basicAuthPassword
) {
// If enabling basic auth or changing either field, ensure we send at least the username // If enabling basic auth or changing either field, ensure we send at least the username
// to keep the pair consistent in the backend. // to keep the pair consistent in the backend.
envPayload.basicAuthUser = normalizeString(data.basicAuthUser) envPayload.basicAuthUser = normalizeString(data.basicAuthUser);
if (dirtyFields.basicAuthPassword) { if (dirtyFields.basicAuthPassword) {
const value = normalizePrivateInput(data.basicAuthPassword) const value = normalizePrivateInput(data.basicAuthPassword);
if (value !== undefined) envPayload.basicAuthPassword = value if (value !== undefined) envPayload.basicAuthPassword = value;
} }
} }
if (dirtyFields.openrouterApiKey) { if (dirtyFields.openrouterApiKey) {
const value = normalizePrivateInput(data.openrouterApiKey) const value = normalizePrivateInput(data.openrouterApiKey);
if (value !== undefined) envPayload.openrouterApiKey = value if (value !== undefined) envPayload.openrouterApiKey = value;
} }
if (dirtyFields.rxresumePassword) { if (dirtyFields.rxresumePassword) {
const value = normalizePrivateInput(data.rxresumePassword) const value = normalizePrivateInput(data.rxresumePassword);
if (value !== undefined) envPayload.rxresumePassword = value if (value !== undefined) envPayload.rxresumePassword = value;
} }
if (dirtyFields.ukvisajobsPassword) { if (dirtyFields.ukvisajobsPassword) {
const value = normalizePrivateInput(data.ukvisajobsPassword) const value = normalizePrivateInput(data.ukvisajobsPassword);
if (value !== undefined) envPayload.ukvisajobsPassword = value if (value !== undefined) envPayload.ukvisajobsPassword = value;
} }
if (dirtyFields.webhookSecret) { if (dirtyFields.webhookSecret) {
const value = normalizePrivateInput(data.webhookSecret) const value = normalizePrivateInput(data.webhookSecret);
if (value !== undefined) envPayload.webhookSecret = value if (value !== undefined) envPayload.webhookSecret = value;
} }
const payload: UpdateSettingsInput = { const payload: UpdateSettingsInput = {
@@ -464,108 +507,135 @@ export const SettingsPage: React.FC = () => {
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride, resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default), ukvisajobsMaxJobs: nullIfSame(
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default), data.ukvisajobsMaxJobs,
ukvisajobs.default,
),
gradcrackerMaxJobsPerTerm: nullIfSame(
data.gradcrackerMaxJobsPerTerm,
gradcracker.default,
),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default), searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default), jobspyLocation: nullIfSame(
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default), data.jobspyLocation,
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default), jobspy.location.default,
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default), ),
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default), jobspyResultsWanted: nullIfSame(
data.jobspyResultsWanted,
jobspy.resultsWanted.default,
),
jobspyHoursOld: nullIfSame(
data.jobspyHoursOld,
jobspy.hoursOld.default,
),
jobspyCountryIndeed: nullIfSame(
data.jobspyCountryIndeed,
jobspy.countryIndeed.default,
),
jobspySites: nullIfSameSortedList(
data.jobspySites,
jobspy.sites.default,
),
jobspyLinkedinFetchDescription: nullIfSame( jobspyLinkedinFetchDescription: nullIfSame(
data.jobspyLinkedinFetchDescription, data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default jobspy.linkedinFetchDescription.default,
), ),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
...envPayload, ...envPayload,
} };
// Remove virtual field because the backend doesn't expect it // Remove virtual field because the backend doesn't expect it
// this exists only to toggle the UI // this exists only to toggle the UI
// need to track it so that the save button is enabled when it changes // need to track it so that the save button is enabled when it changes
delete payload.enableBasicAuth delete payload.enableBasicAuth;
const updated = await api.updateSettings(payload) const updated = await api.updateSettings(payload);
setSettings(updated) setSettings(updated);
reset(mapSettingsToForm(updated)) reset(mapSettingsToForm(updated));
toast.success("Settings saved") toast.success("Settings saved");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to save settings";
toast.error(message);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
const handleClearDatabase = async () => { const handleClearDatabase = async () => {
try { try {
setIsSaving(true) setIsSaving(true);
const result = await api.clearDatabase() const result = await api.clearDatabase();
toast.success("Database cleared", { description: `Deleted ${result.jobsDeleted} jobs.` }) toast.success("Database cleared", {
description: `Deleted ${result.jobsDeleted} jobs.`,
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to clear database" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to clear database";
toast.error(message);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
const handleClearByStatuses = async () => { const handleClearByStatuses = async () => {
if (statusesToClear.length === 0) { if (statusesToClear.length === 0) {
toast.error("No statuses selected") toast.error("No statuses selected");
return return;
} }
try { try {
setIsSaving(true) setIsSaving(true);
let totalDeleted = 0 let totalDeleted = 0;
const results: string[] = [] const results: string[] = [];
for (const status of statusesToClear) { for (const status of statusesToClear) {
const result = await api.deleteJobsByStatus(status) const result = await api.deleteJobsByStatus(status);
totalDeleted += result.count totalDeleted += result.count;
if (result.count > 0) { if (result.count > 0) {
results.push(`${result.count} ${status}`) results.push(`${result.count} ${status}`);
} }
} }
if (totalDeleted > 0) { if (totalDeleted > 0) {
toast.success("Jobs cleared", { toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`, description: `Deleted ${totalDeleted} jobs: ${results.join(", ")}`,
}) });
} else { } else {
toast.info("No jobs found", { toast.info("No jobs found", {
description: `No jobs with selected statuses found`, description: `No jobs with selected statuses found`,
}) });
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to clear jobs" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to clear jobs";
toast.error(message);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
const toggleStatusToClear = (status: JobStatus) => { const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear(prev => setStatusesToClear((prev) =>
prev.includes(status) prev.includes(status)
? prev.filter(s => s !== status) ? prev.filter((s) => s !== status)
: [...prev, status] : [...prev, status],
) );
} };
const handleReset = async () => { const handleReset = async () => {
try { try {
setIsSaving(true) setIsSaving(true);
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD) const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD);
setSettings(updated) setSettings(updated);
reset(mapSettingsToForm(updated)) reset(mapSettingsToForm(updated));
toast.success("Reset to default") toast.success("Reset to default");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings" const message =
toast.error(message) error instanceof Error ? error.message : "Failed to reset settings";
toast.error(message);
} finally { } finally {
setIsSaving(false) setIsSaving(false);
} }
} };
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@@ -612,8 +682,8 @@ export const SettingsPage: React.FC = () => {
<ReactiveResumeSection <ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft} rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={(value) => { setRxResumeBaseResumeIdDraft={(value) => {
setRxResumeBaseResumeIdDraft(value) setRxResumeBaseResumeIdDraft(value);
setValue("rxresumeBaseResumeId", value, { shouldDirty: true }) setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
}} }}
hasRxResumeAccess={hasRxResumeAccess} hasRxResumeAccess={hasRxResumeAccess}
profileProjects={effectiveProfileProjects} profileProjects={effectiveProfileProjects}
@@ -644,10 +714,17 @@ export const SettingsPage: React.FC = () => {
</Accordion> </Accordion>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}> <Button
onClick={handleSubmit(onSave)}
disabled={isLoading || isSaving || !canSave}
>
{isSaving ? "Saving..." : "Save"} {isSaving ? "Saving..." : "Save"}
</Button> </Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}> <Button
variant="outline"
onClick={handleReset}
disabled={isLoading || isSaving || !settings}
>
Reset to default Reset to default
</Button> </Button>
</div> </div>
@@ -658,5 +735,5 @@ export const SettingsPage: React.FC = () => {
)} )}
</main> </main>
</FormProvider> </FormProvider>
) );
} };
+139 -43
View File
@@ -2,7 +2,6 @@
* UK Visa Jobs search page. * UK Visa Jobs search page.
*/ */
import React, { useEffect, useMemo, useState } from "react";
import { import {
Briefcase, Briefcase,
Calendar, Calendar,
@@ -21,15 +20,17 @@ import {
Settings, Settings,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -38,10 +39,11 @@ import {
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils"; import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
import * as api from "../api";
import type { CreateJobInput } from "../../shared/types"; import type { CreateJobInput } from "../../shared/types";
import * as api from "../api";
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value); const clampText = (value: string, max = 160) =>
value.length > max ? `${value.slice(0, max).trim()}...` : value;
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl; const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
@@ -71,8 +73,10 @@ export const UkVisaJobsPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(() =>
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false), typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
); );
useEffect(() => { useEffect(() => {
@@ -81,7 +85,10 @@ export const UkVisaJobsPage: React.FC = () => {
return; return;
} }
const firstKey = jobKey(results[0]); const firstKey = jobKey(results[0]);
if (!selectedJobId || !results.some((job) => jobKey(job) === selectedJobId)) { if (
!selectedJobId ||
!results.some((job) => jobKey(job) === selectedJobId)
) {
setSelectedJobId(firstKey); setSelectedJobId(firstKey);
} }
}, [results, selectedJobId]); }, [results, selectedJobId]);
@@ -116,7 +123,10 @@ export const UkVisaJobsPage: React.FC = () => {
}, [results]); }, [results]);
const selectedJob = useMemo( const selectedJob = useMemo(
() => (selectedJobId ? results.find((job) => jobKey(job) === selectedJobId) ?? null : null), () =>
selectedJobId
? (results.find((job) => jobKey(job) === selectedJobId) ?? null)
: null,
[results, selectedJobId], [results, selectedJobId],
); );
@@ -129,7 +139,13 @@ export const UkVisaJobsPage: React.FC = () => {
}; };
}, [page, pageSize, totalJobs]); }, [page, pageSize, totalJobs]);
const runSearch = async ({ term, pageNumber }: { term: string | null; pageNumber: number }) => { const runSearch = async ({
term,
pageNumber,
}: {
term: string | null;
pageNumber: number;
}) => {
try { try {
setIsSearching(true); setIsSearching(true);
setErrorMessage(null); setErrorMessage(null);
@@ -147,7 +163,8 @@ export const UkVisaJobsPage: React.FC = () => {
toast.message("No UK Visa Jobs found for this search."); toast.message("No UK Visa Jobs found for this search.");
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "UK Visa Jobs search failed"; const message =
error instanceof Error ? error.message : "UK Visa Jobs search failed";
setErrorMessage(message); setErrorMessage(message);
toast.error(message); toast.error(message);
} finally { } finally {
@@ -184,18 +201,23 @@ export const UkVisaJobsPage: React.FC = () => {
}; };
const handleImportSelected = async () => { const handleImportSelected = async () => {
const selectedJobs = results.filter((job) => selectedJobIds.has(jobKey(job))); const selectedJobs = results.filter((job) =>
selectedJobIds.has(jobKey(job)),
);
if (selectedJobs.length === 0) return; if (selectedJobs.length === 0) return;
try { try {
setIsImporting(true); setIsImporting(true);
const response = await api.importUkVisaJobs({ jobs: selectedJobs }); const response = await api.importUkVisaJobs({ jobs: selectedJobs });
toast.success(`Imported ${response.created} jobs`, { toast.success(`Imported ${response.created} jobs`, {
description: response.skipped ? `${response.skipped} skipped (duplicates)` : undefined, description: response.skipped
? `${response.skipped} skipped (duplicates)`
: undefined,
}); });
setSelectedJobIds(new Set()); setSelectedJobIds(new Set());
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to import jobs"; const message =
error instanceof Error ? error.message : "Failed to import jobs";
toast.error(message); toast.error(message);
} finally { } finally {
setIsImporting(false); setIsImporting(false);
@@ -208,12 +230,24 @@ export const UkVisaJobsPage: React.FC = () => {
return cleaned || "No description available."; return cleaned || "No description available.";
}, [selectedJob]); }, [selectedJob]);
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#"; const selectedJobLink = selectedJob
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null; ? selectedJob.applicationLink || selectedJob.jobUrl
const selectedPosted = selectedJob ? formatDate(selectedJob.datePosted) : null; : "#";
const selectedDeadline = selectedJob
? formatDate(selectedJob.deadline)
: null;
const selectedPosted = selectedJob
? formatDate(selectedJob.datePosted)
: null;
const selectedCount = selectedJobIds.size; const selectedCount = selectedJobIds.size;
const allSelected = results.length > 0 && results.every((job) => selectedJobIds.has(jobKey(job))); const allSelected =
const selectAllState = allSelected ? true : selectedCount > 0 ? "indeterminate" : false; results.length > 0 &&
results.every((job) => selectedJobIds.has(jobKey(job)));
const selectAllState = allSelected
? true
: selectedCount > 0
? "indeterminate"
: false;
const canGoPrev = page > 1; const canGoPrev = page > 1;
const canGoNext = page < totalPages; const canGoNext = page < totalPages;
@@ -227,14 +261,20 @@ export const UkVisaJobsPage: React.FC = () => {
const detailPanelContent = !selectedJob ? ( const detailPanelContent = !selectedJob ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center"> <div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a job</div> <div className="text-base font-semibold">Select a job</div>
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p> <p className="text-sm text-muted-foreground">
Pick a job from the list to inspect details.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-base font-semibold">{selectedJob.title}</div> <div className="truncate text-base font-semibold">
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div> {selectedJob.title}
</div>
<div className="text-sm text-muted-foreground">
{selectedJob.employer}
</div>
</div> </div>
<Badge variant="outline" className="uppercase tracking-wide"> <Badge variant="outline" className="uppercase tracking-wide">
UK Visa Jobs UK Visa Jobs
@@ -295,7 +335,12 @@ export const UkVisaJobsPage: React.FC = () => {
<Separator /> <Separator />
<Button asChild size="sm" variant="outline" className="w-full gap-2 sm:w-auto"> <Button
asChild
size="sm"
variant="outline"
className="w-full gap-2 sm:w-auto"
>
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer"> <a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
View job View job
@@ -344,9 +389,16 @@ export const UkVisaJobsPage: React.FC = () => {
}} }}
className={cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname)) location.pathname === to ||
(to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname))
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "text-muted-foreground" : "text-muted-foreground",
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -361,8 +413,12 @@ export const UkVisaJobsPage: React.FC = () => {
<Briefcase className="h-4 w-4 text-muted-foreground" /> <Briefcase className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="min-w-0 leading-tight"> <div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div> <div className="text-sm font-semibold tracking-tight">
<div className="text-xs text-muted-foreground">Live search console</div> UK Visa Jobs
</div>
<div className="text-xs text-muted-foreground">
Live search console
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -370,7 +426,10 @@ export const UkVisaJobsPage: React.FC = () => {
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12"> <main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<section className="rounded-xl border border-border/60 bg-card/40 p-4"> <section className="rounded-xl border border-border/60 bg-card/40 p-4">
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]" onSubmit={handleSearch}> <form
className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]"
onSubmit={handleSearch}
>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job title search term Job title search term
@@ -382,13 +441,22 @@ export const UkVisaJobsPage: React.FC = () => {
className="h-10" className="h-10"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Note: Search is limited to job titles only due to API constraints. Note: Search is limited to job titles only due to API
constraints.
</p> </p>
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<Button type="submit" className="h-10 w-full gap-2" disabled={isSearching}> <Button
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />} type="submit"
className="h-10 w-full gap-2"
disabled={isSearching}
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
{isSearching ? "Searching..." : "Search"} {isSearching ? "Searching..." : "Search"}
</Button> </Button>
</div> </div>
@@ -404,7 +472,8 @@ export const UkVisaJobsPage: React.FC = () => {
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div> <div>
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"} Last run:{" "}
{lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@@ -416,7 +485,9 @@ export const UkVisaJobsPage: React.FC = () => {
<span> <span>
Page {page} of {totalPages} Page {page} of {totalPages}
</span> </span>
{lastSearchTerm && <span className="truncate">Term: {lastSearchTerm}</span>} {lastSearchTerm && (
<span className="truncate">Term: {lastSearchTerm}</span>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -441,7 +512,9 @@ export const UkVisaJobsPage: React.FC = () => {
</> </>
) : ( ) : (
<> <>
<div className="text-base font-semibold">No results yet</div> <div className="text-base font-semibold">
No results yet
</div>
<p className="max-w-md text-sm text-muted-foreground"> <p className="max-w-md text-sm text-muted-foreground">
Run a search to fetch fresh UK Visa Jobs listings. Run a search to fetch fresh UK Visa Jobs listings.
</p> </p>
@@ -456,7 +529,9 @@ export const UkVisaJobsPage: React.FC = () => {
checked={selectAllState} checked={selectAllState}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked === true) { if (checked === true) {
setSelectedJobIds(new Set(results.map((job) => jobKey(job)))); setSelectedJobIds(
new Set(results.map((job) => jobKey(job))),
);
} else { } else {
setSelectedJobIds(new Set()); setSelectedJobIds(new Set());
} }
@@ -474,7 +549,11 @@ export const UkVisaJobsPage: React.FC = () => {
onClick={handleImportSelected} onClick={handleImportSelected}
disabled={selectedCount === 0 || isImporting} disabled={selectedCount === 0 || isImporting}
> >
{isImporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />} {isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Database className="h-4 w-4" />
)}
{isImporting ? "Importing..." : "Import to DB"} {isImporting ? "Importing..." : "Import to DB"}
</Button> </Button>
</div> </div>
@@ -483,7 +562,9 @@ export const UkVisaJobsPage: React.FC = () => {
const key = jobKey(job); const key = jobKey(job);
const isSelected = key === selectedJobId; const isSelected = key === selectedJobId;
const isChecked = selectedJobIds.has(key); const isChecked = selectedJobIds.has(key);
const description = job.jobDescription ? clampText(stripHtml(job.jobDescription)) : "No description."; const description = job.jobDescription
? clampText(stripHtml(job.jobDescription))
: "No description.";
return ( return (
<div <div
@@ -530,10 +611,16 @@ export const UkVisaJobsPage: React.FC = () => {
</span> </span>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<div className="truncate text-sm font-semibold">{job.title}</div> <div className="truncate text-sm font-semibold">
<div className="text-xs text-muted-foreground">{job.employer}</div> {job.title}
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
</div>
</div>
<div className="text-xs text-muted-foreground">
{description}
</div> </div>
<div className="text-xs text-muted-foreground">{description}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && ( {job.location && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -556,12 +643,18 @@ export const UkVisaJobsPage: React.FC = () => {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{job.jobType && ( {job.jobType && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide"> <Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType} {job.jobType}
</Badge> </Badge>
)} )}
{job.jobLevel && ( {job.jobLevel && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide"> <Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel} {job.jobLevel}
</Badge> </Badge>
)} )}
@@ -573,7 +666,8 @@ export const UkVisaJobsPage: React.FC = () => {
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground">
<span> <span>
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs} Showing {summaryCounts.startIndex}-{summaryCounts.endIndex}{" "}
of {totalJobs}
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
@@ -614,7 +708,9 @@ export const UkVisaJobsPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}> <Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2"> <div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div> <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job details
</div>
<DrawerClose asChild> <DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs"> <Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close Close
@@ -3,7 +3,6 @@
* Allows searching the government's list of licensed visa sponsors. * Allows searching the government's list of licensed visa sponsors.
*/ */
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { import {
AlertCircle, AlertCircle,
Building2, Building2,
@@ -18,34 +17,38 @@ import {
Shield, Shield,
X, X,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import {
PageHeader,
StatusIndicator,
ListItem,
EmptyState,
ScoreMeter,
SplitLayout,
ListPanel,
DetailPanel,
PageMain,
} from "../components";
import * as api from "../api";
import type { import type {
VisaSponsor, VisaSponsor,
VisaSponsorSearchResult, VisaSponsorSearchResult,
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
} from "../../shared/types"; } from "../../shared/types";
import * as api from "../api";
import {
DetailPanel,
EmptyState,
ListItem,
ListPanel,
PageHeader,
PageMain,
ScoreMeter,
SplitLayout,
StatusIndicator,
} from "../components";
const getScoreTokens = (score: number) => { const getScoreTokens = (score: number) => {
if (score >= 90) if (score >= 90)
return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" }; return {
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
};
if (score >= 70) if (score >= 70)
return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" }; return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" };
if (score >= 50) if (score >= 50)
@@ -67,8 +70,10 @@ export const VisaSponsorsPage: React.FC = () => {
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(() =>
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false), typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
); );
// Fetch status on mount // Fetch status on mount
@@ -82,7 +87,8 @@ export const VisaSponsorsPage: React.FC = () => {
const data = await api.getVisaSponsorStatus(); const data = await api.getVisaSponsorStatus();
setStatus(data); setStatus(data);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch status"; const message =
err instanceof Error ? err.message : "Failed to fetch status";
toast.error(message); toast.error(message);
} finally { } finally {
setIsLoadingStatus(false); setIsLoadingStatus(false);
@@ -129,7 +135,10 @@ export const VisaSponsorsPage: React.FC = () => {
setOrgDetails([]); setOrgDetails([]);
return; return;
} }
if (!selectedOrg || !results.some((r) => r.sponsor.organisationName === selectedOrg)) { if (
!selectedOrg ||
!results.some((r) => r.sponsor.organisationName === selectedOrg)
) {
const firstOrg = results[0].sponsor.organisationName; const firstOrg = results[0].sponsor.organisationName;
setSelectedOrg(firstOrg); setSelectedOrg(firstOrg);
fetchOrgDetails(firstOrg); fetchOrgDetails(firstOrg);
@@ -169,7 +178,8 @@ export const VisaSponsorsPage: React.FC = () => {
const details = await api.getVisaSponsorOrganization(orgName); const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details); setOrgDetails(details);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch details"; const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message); toast.error(message);
setOrgDetails([]); setOrgDetails([]);
} finally { } finally {
@@ -203,8 +213,9 @@ export const VisaSponsorsPage: React.FC = () => {
}; };
const selectedResult = useMemo( const selectedResult = useMemo(
() => results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null, () =>
[results, selectedOrg] results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
[results, selectedOrg],
); );
const isUpdateInProgress = isUpdating || status?.isUpdating; const isUpdateInProgress = isUpdating || status?.isUpdating;
@@ -233,7 +244,7 @@ export const VisaSponsorsPage: React.FC = () => {
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide", "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
getScoreTokens(selectedResult.score).badge getScoreTokens(selectedResult.score).badge,
)} )}
> >
{selectedResult.score}% Match {selectedResult.score}% Match
@@ -244,17 +255,20 @@ export const VisaSponsorsPage: React.FC = () => {
</div> </div>
{/* Location */} {/* Location */}
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && ( {orgDetails.length > 0 &&
<div> (orgDetails[0].townCity || orgDetails[0].county) && (
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1"> <div>
Location <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
Location
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<MapPin className="h-4 w-4 text-muted-foreground" />
{[orgDetails[0].townCity, orgDetails[0].county]
.filter(Boolean)
.join(", ")}
</div>
</div> </div>
<div className="flex items-center gap-2 text-sm text-foreground"> )}
<MapPin className="h-4 w-4 text-muted-foreground" />
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
</div>
</div>
)}
{/* Licence types / routes */} {/* Licence types / routes */}
<div> <div>
@@ -273,7 +287,9 @@ export const VisaSponsorsPage: React.FC = () => {
</Badge> </Badge>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type & Rating:</span>{" "} <span className="font-medium text-foreground">
Type & Rating:
</span>{" "}
{entry.typeRating} {entry.typeRating}
</div> </div>
</div> </div>
@@ -283,10 +299,13 @@ export const VisaSponsorsPage: React.FC = () => {
{/* Info box */} {/* Info box */}
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm"> <div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
<div className="font-medium text-sky-200 mb-1">What does this mean?</div> <div className="font-medium text-sky-200 mb-1">
What does this mean?
</div>
<p className="text-xs text-sky-300/80"> <p className="text-xs text-sky-300/80">
This organisation is licensed by the UK Home Office to sponsor workers on the This organisation is licensed by the UK Home Office to sponsor workers
routes listed above. An "A rating" means they're fully compliant. on the routes listed above. An "A rating" means they're fully
compliant.
</p> </p>
</div> </div>
</div> </div>
@@ -298,7 +317,9 @@ export const VisaSponsorsPage: React.FC = () => {
icon={Shield} icon={Shield}
title="Visa Sponsors" title="Visa Sponsors"
subtitle="UK Register Search" subtitle="UK Register Search"
statusIndicator={isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined} statusIndicator={
isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined
}
actions={ actions={
<> <>
{status && ( {status && (
@@ -356,7 +377,8 @@ export const VisaSponsorsPage: React.FC = () => {
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enter a company name to check if they're a licensed UK visa sponsor. Enter a company name to check if they're a licensed UK visa
sponsor.
</p> </p>
</div> </div>
</section> </section>
@@ -383,7 +405,11 @@ export const VisaSponsorsPage: React.FC = () => {
title="No sponsor data available" title="No sponsor data available"
description="The visa sponsor list hasn't been downloaded yet." description="The visa sponsor list hasn't been downloaded yet."
action={ action={
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}> <Button
size="sm"
onClick={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? ( {isUpdating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -421,7 +447,9 @@ export const VisaSponsorsPage: React.FC = () => {
<ListItem <ListItem
key={`${result.sponsor.organisationName}-${index}`} key={`${result.sponsor.organisationName}-${index}`}
selected={selectedOrg === result.sponsor.organisationName} selected={selectedOrg === result.sponsor.organisationName}
onClick={() => handleSelectOrg(result.sponsor.organisationName)} onClick={() =>
handleSelectOrg(result.sponsor.organisationName)
}
className="gap-3" className="gap-3"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -458,7 +486,9 @@ export const VisaSponsorsPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}> <Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2"> <div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Sponsor details</div> <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sponsor details
</div>
<DrawerClose asChild> <DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs"> <Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close Close
@@ -1,16 +1,21 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { JobDetailPanel } from "./JobDetailPanel"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import * as api from "../../api"; import * as api from "../../api";
import { JobDetailPanel } from "./JobDetailPanel";
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => (
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, <div>{children}</div>
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>, ),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({ DropdownMenuItem: ({
children, children,
onSelect, onSelect,
@@ -19,7 +24,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode; children: React.ReactNode;
onSelect?: () => void; onSelect?: () => void;
}) => ( }) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}> <button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children} {children}
</button> </button>
), ),
@@ -154,14 +164,12 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)} onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
}); });
it("shows an empty state when no job is selected", () => { it("shows an empty state when no job is selected", () => {
render( render(
<JobDetailPanel <JobDetailPanel
@@ -171,7 +179,7 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)} onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
expect(screen.getByText("No job selected")).toBeInTheDocument(); expect(screen.getByText("No job selected")).toBeInTheDocument();
@@ -182,11 +190,13 @@ describe("JobDetailPanel", () => {
<JobDetailPanel <JobDetailPanel
activeTab="all" activeTab="all"
activeJobs={[]} activeJobs={[]}
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })} selectedJob={createJob({
jobDescription: "<p>Hello <strong>world</strong></p>",
})}
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)} onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
expect(screen.getByText("Hello world")).toBeInTheDocument(); expect(screen.getByText("Hello world")).toBeInTheDocument();
@@ -204,7 +214,7 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated} onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
@@ -217,7 +227,9 @@ describe("JobDetailPanel", () => {
fireEvent.click(screen.getByRole("button", { name: /save changes/i })); fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" }) expect(api.updateJob).toHaveBeenCalledWith("job-1", {
jobDescription: "Updated description",
}),
); );
expect(onJobUpdated).toHaveBeenCalled(); expect(onJobUpdated).toHaveBeenCalled();
}); });
@@ -234,12 +246,14 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated} onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
fireEvent.click(screen.getByRole("button", { name: /applied/i })); fireEvent.click(screen.getByRole("button", { name: /applied/i }));
await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1")); await waitFor(() =>
expect(api.markAsApplied).toHaveBeenCalledWith("job-1"),
);
expect(onJobUpdated).toHaveBeenCalled(); expect(onJobUpdated).toHaveBeenCalled();
}); });
@@ -255,10 +269,12 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()} onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated} onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()} onSetActiveTab={vi.fn()}
/> />,
); );
fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i })); fireEvent.pointerDown(
screen.getByRole("button", { name: /more actions/i }),
);
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i }); const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
fireEvent.click(skipItem); fireEvent.click(skipItem);
@@ -1,4 +1,3 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
CheckCircle2, CheckCircle2,
Copy, Copy,
@@ -11,6 +10,8 @@ import {
Save, Save,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -25,14 +26,23 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils"; import {
copyTextToClipboard,
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components"; formatJobForWebhook,
safeFilenamePart,
stripHtml,
} from "@/lib/utils";
import type { Job } from "../../../shared/types";
import * as api from "../../api";
import {
DiscoveredPanel,
FitAssessment,
JobHeader,
TailoredSummary,
} from "../../components";
import { ReadyPanel } from "../../components/ReadyPanel"; import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor"; import { TailoringEditor } from "../../components/TailoringEditor";
import { useProfile } from "../../hooks/useProfile"; import { useProfile } from "../../hooks/useProfile";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
interface JobDetailPanelProps { interface JobDetailPanelProps {
@@ -52,7 +62,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
onJobUpdated, onJobUpdated,
onSetActiveTab, onSetActiveTab,
}) => { }) => {
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [detailTab, setDetailTab] = useState<
"overview" | "tailoring" | "description"
>("overview");
const [isEditingDescription, setIsEditingDescription] = useState(false); const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedDescription, setEditedDescription] = useState(""); const [editedDescription, setEditedDescription] = useState("");
const [isSavingDescription, setIsSavingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false);
@@ -95,12 +107,15 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return; if (!selectedJob) return;
try { try {
setIsSavingDescription(true); setIsSavingDescription(true);
await api.updateJob(selectedJob.id, { jobDescription: editedDescription }); await api.updateJob(selectedJob.id, {
jobDescription: editedDescription,
});
toast.success("Job description updated"); toast.success("Job description updated");
setIsEditingDescription(false); setIsEditingDescription(false);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to update description"; const message =
error instanceof Error ? error.message : "Failed to update description";
toast.error(message); toast.error(message);
} finally { } finally {
setIsSavingDescription(false); setIsSavingDescription(false);
@@ -113,7 +128,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
editedDescription !== (selectedJob.jobDescription || ""); editedDescription !== (selectedJob.jobDescription || "");
const confirmAndSaveEdits = useCallback( const confirmAndSaveEdits = useCallback(
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => { async ({
includeTailoring = true,
}: {
includeTailoring?: boolean;
} = {}) => {
const pendingDescription = hasUnsavedDescription; const pendingDescription = hasUnsavedDescription;
const pendingTailoring = includeTailoring && hasUnsavedTailoring; const pendingTailoring = includeTailoring && hasUnsavedTailoring;
@@ -128,7 +147,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
try { try {
if (pendingDescription && selectedJob) { if (pendingDescription && selectedJob) {
await api.updateJob(selectedJob.id, { jobDescription: editedDescription }); await api.updateJob(selectedJob.id, {
jobDescription: editedDescription,
});
} }
if (pendingTailoring) { if (pendingTailoring) {
@@ -140,20 +161,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
await saveTailoring(); await saveTailoring();
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to save changes"; const errorMessage =
error instanceof Error ? error.message : "Failed to save changes";
toast.error(errorMessage); toast.error(errorMessage);
return false; return false;
} }
return true; return true;
}, },
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob], [
editedDescription,
hasUnsavedDescription,
hasUnsavedTailoring,
selectedJob,
],
); );
const handleProcess = async () => { const handleProcess = async () => {
if (!selectedJob) return; if (!selectedJob) return;
try { try {
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true }); const shouldProceed = await confirmAndSaveEdits({
includeTailoring: true,
});
if (!shouldProceed) return; if (!shouldProceed) return;
setProcessingJobId(selectedJob.id); setProcessingJobId(selectedJob.id);
@@ -167,7 +196,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
} }
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to process job"; const message =
error instanceof Error ? error.message : "Failed to process job";
toast.error(message); toast.error(message);
} finally { } finally {
setProcessingJobId(null); setProcessingJobId(null);
@@ -181,7 +211,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
toast.success("Marked as applied"); toast.success("Marked as applied");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to mark as applied"; const message =
error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message); toast.error(message);
} }
}; };
@@ -193,7 +224,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
toast.message("Job skipped"); toast.message("Job skipped");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to skip job"; const message =
error instanceof Error ? error.message : "Failed to skip job";
toast.error(message); toast.error(message);
} }
}; };
@@ -202,7 +234,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return; if (!selectedJob) return;
try { try {
await copyTextToClipboard(formatJobForWebhook(selectedJob)); await copyTextToClipboard(formatJobForWebhook(selectedJob));
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." }); toast.success("Copied job info", {
description: "Webhook payload copied to clipboard.",
});
} catch { } catch {
toast.error("Could not copy job info"); toast.error("Could not copy job info");
} }
@@ -211,24 +245,32 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleJobMoved = useCallback( const handleJobMoved = useCallback(
(jobId: string) => { (jobId: string) => {
const currentIndex = activeJobs.findIndex((job) => job.id === jobId); const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1]; const nextJob =
activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
onSelectJobId(nextJob?.id ?? null); onSelectJobId(nextJob?.id ?? null);
}, },
[activeJobs, onSelectJobId], [activeJobs, onSelectJobId],
); );
const selectedHasPdf = !!selectedJob?.pdfPath; const selectedHasPdf = !!selectedJob?.pdfPath;
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#"; const selectedJobLink = selectedJob
? selectedJob.applicationLink || selectedJob.jobUrl
: "#";
const selectedPdfHref = selectedJob const selectedPdfHref = selectedJob
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}` ? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
: "#"; : "#";
const canApply = selectedJob?.status === "ready"; const canApply = selectedJob?.status === "ready";
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; const canProcess = selectedJob
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; ? ["discovered", "ready"].includes(selectedJob.status)
: false;
const canSkip = selectedJob
? ["discovered", "ready"].includes(selectedJob.status)
: false;
const showReadyPdf = activeTab === "ready"; const showReadyPdf = activeTab === "ready";
const showGeneratePdf = activeTab === "discovered"; const showGeneratePdf = activeTab === "discovered";
const isProcessingSelected = const isProcessingSelected = selectedJob
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false; ? processingJobId === selectedJob.id || selectedJob.status === "processing"
: false;
if (activeTab === "discovered") { if (activeTab === "discovered") {
return ( return (
@@ -253,8 +295,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) { if (!selectedJob) {
return ( return (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center"> <div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
<div className="text-sm font-medium text-muted-foreground">No job selected</div> <div className="text-sm font-medium text-muted-foreground">
<p className="text-xs text-muted-foreground/70">Select a job to view details</p> No job selected
</div>
<p className="text-xs text-muted-foreground/70">
Select a job to view details
</p>
</div> </div>
); );
} }
@@ -270,7 +316,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
/> />
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs"> <Button
asChild
size="sm"
variant="ghost"
className="h-8 gap-1.5 text-xs"
>
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer"> <a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
View View
@@ -279,14 +330,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
{showReadyPdf && {showReadyPdf &&
(selectedHasPdf ? ( (selectedHasPdf ? (
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs"> <Button
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer"> asChild
size="sm"
variant="ghost"
className="h-8 gap-1.5 text-xs"
>
<a
href={selectedPdfHref}
target="_blank"
rel="noopener noreferrer"
>
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
PDF PDF
</a> </a>
</Button> </Button>
) : ( ) : (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled> <Button
size="sm"
variant="ghost"
className="h-8 gap-1.5 text-xs"
disabled
>
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
PDF PDF
</Button> </Button>
@@ -357,7 +422,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<> <>
{!showReadyPdf && ( {!showReadyPdf && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer"> <a
href={selectedPdfHref}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
View PDF View PDF
</a> </a>
@@ -366,7 +435,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<a <a
href={selectedPdfHref} href={selectedPdfHref}
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`} download={`${personName.replace(/\s+/g, "_")}_${safeFilenamePart(selectedJob.employer)}.pdf`}
> >
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Download PDF Download PDF
@@ -389,11 +458,20 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}> <Tabs
value={detailTab}
onValueChange={(value) => setDetailTab(value as typeof detailTab)}
>
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs"> <TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger> <TabsTrigger value="overview" className="text-xs">
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger> Overview
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger> </TabsTrigger>
<TabsTrigger value="tailoring" className="text-xs">
Tailoring
</TabsTrigger>
<TabsTrigger value="description" className="text-xs">
Description
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="overview" className="space-y-3 pt-2"> <TabsContent value="overview" className="space-y-3 pt-2">
@@ -402,20 +480,36 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<div className="grid gap-2 text-xs sm:grid-cols-2"> <div className="grid gap-2 text-xs sm:grid-cols-2">
<div> <div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div> <div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div> Discipline
</div>
<div className="text-foreground/80">
{selectedJob.disciplines || "-"}
</div>
</div> </div>
<div> <div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div> <div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div> Function
</div>
<div className="text-foreground/80">
{selectedJob.jobFunction || "-"}
</div>
</div> </div>
<div> <div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div> <div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div> Level
</div>
<div className="text-foreground/80">
{selectedJob.jobLevel || "-"}
</div>
</div> </div>
<div> <div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div> <div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div> Type
</div>
<div className="text-foreground/80">
{selectedJob.jobType || "-"}
</div>
</div> </div>
</div> </div>
@@ -433,7 +527,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors" className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
onClick={() => setDetailTab("description")} onClick={() => setDetailTab("description")}
> >
View full description  View full description 
</button> </button>
</div> </div>
</div> </div>
@@ -447,7 +541,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
onRegisterSave={(save) => { onRegisterSave={(save) => {
saveTailoringRef.current = save; saveTailoringRef.current = save;
}} }}
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })} onBeforeGenerate={() =>
confirmAndSaveEdits({ includeTailoring: false })
}
/> />
</TabsContent> </TabsContent>
@@ -499,14 +595,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions"> <Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label="Description actions"
>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void copyTextToClipboard(selectedJob.jobDescription || ""); void copyTextToClipboard(
selectedJob.jobDescription || "",
);
toast.success("Copied raw description"); toast.success("Copied raw description");
}} }}
> >
@@ -555,7 +658,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</div> </div>
) : ( ) : (
<div className="whitespace-pre-wrap leading-relaxed"> <div className="whitespace-pre-wrap leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{description}
</ReactMarkdown>
</div> </div>
)} )}
</div> </div>
@@ -1,8 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { JobListPanel } from "./JobListPanel";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import { JobListPanel } from "./JobListPanel";
const createJob = (overrides: Partial<Job> = {}): Job => ({ const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1", id: "job-1",
@@ -76,7 +75,7 @@ describe("JobListPanel", () => {
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
/> />,
); );
expect(screen.getByText("Loading jobs...")).toBeInTheDocument(); expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
@@ -92,11 +91,13 @@ describe("JobListPanel", () => {
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
/> />,
); );
expect(screen.getByText("No jobs found")).toBeInTheDocument(); expect(screen.getByText("No jobs found")).toBeInTheDocument();
expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument(); expect(
screen.getByText("Run the pipeline to discover and process new jobs."),
).toBeInTheDocument();
}); });
it("shows the query-specific empty state when searching", () => { it("shows the query-specific empty state when searching", () => {
@@ -109,7 +110,7 @@ describe("JobListPanel", () => {
activeTab="ready" activeTab="ready"
searchQuery="iOS" searchQuery="iOS"
onSelectJob={vi.fn()} onSelectJob={vi.fn()}
/> />,
); );
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument(); expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
@@ -119,7 +120,11 @@ describe("JobListPanel", () => {
const onSelectJob = vi.fn(); const onSelectJob = vi.fn();
const jobs = [ const jobs = [
createJob({ id: "job-1", title: "Backend Engineer" }), createJob({ id: "job-1", title: "Backend Engineer" }),
createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }), createJob({
id: "job-2",
title: "Frontend Engineer",
employer: "Globex",
}),
]; ];
render( render(
@@ -131,10 +136,12 @@ describe("JobListPanel", () => {
activeTab="ready" activeTab="ready"
searchQuery="" searchQuery=""
onSelectJob={onSelectJob} onSelectJob={onSelectJob}
/> />,
); );
expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true"); expect(
screen.getByRole("button", { name: /Backend Engineer/i }),
).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i })); fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
expect(onSelectJob).toHaveBeenCalledWith("job-2"); expect(onSelectJob).toHaveBeenCalledWith("job-2");
@@ -1,11 +1,11 @@
import React from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
interface JobListPanelProps { interface JobListPanelProps {
isLoading: boolean; isLoading: boolean;
@@ -36,7 +36,9 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div> <div className="text-base font-semibold">No jobs found</div>
<p className="max-w-md text-sm text-muted-foreground"> <p className="max-w-md text-sm text-muted-foreground">
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]} {searchQuery.trim()
? `No jobs match "${searchQuery.trim()}".`
: emptyStateCopy[activeTab]}
</p> </p>
</div> </div>
) : ( ) : (
@@ -81,7 +83,11 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
</div> </div>
<div className="truncate text-xs text-muted-foreground mt-0.5"> <div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer} {job.employer}
{job.location && <span className="before:content-['_in_']">{job.location}</span>} {job.location && (
<span className="before:content-['_in_']">
{job.location}
</span>
)}
</div> </div>
</div> </div>
@@ -1,20 +1,26 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import React from "react";
import { OrchestratorFilters } from "./OrchestratorFilters"; import { describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort } from "./constants"; import type { FilterTab, JobSort } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters";
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react"); const React = require("react") as typeof import("react");
const RadioGroupContext = React.createContext<((value: string) => void) | null>(null); const RadioGroupContext = React.createContext<
((value: string) => void) | null
>(null);
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => (
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, <div>{children}</div>
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>, ),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({ DropdownMenuItem: ({
children, children,
onSelect, onSelect,
@@ -23,11 +29,18 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode; children: React.ReactNode;
onSelect?: () => void; onSelect?: () => void;
}) => ( }) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}> <button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children} {children}
</button> </button>
), ),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />, DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuRadioGroup: ({ DropdownMenuRadioGroup: ({
children, children,
@@ -49,7 +62,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}) => { }) => {
const onValueChange = React.useContext(RadioGroupContext); const onValueChange = React.useContext(RadioGroupContext);
return ( return (
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}> <button
type="button"
role="menuitemradio"
onClick={() => onValueChange?.(value)}
>
{children} {children}
</button> </button>
); );
@@ -57,7 +74,9 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}; };
}); });
const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>) => { const renderFilters = (
overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>,
) => {
const props = { const props = {
activeTab: "ready" as FilterTab, activeTab: "ready" as FilterTab,
onTabChange: vi.fn(), onTabChange: vi.fn(),
@@ -90,7 +109,9 @@ describe("OrchestratorFilters", () => {
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i })); fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
expect(props.onTabChange).toHaveBeenCalledWith("applied"); expect(props.onTabChange).toHaveBeenCalledWith("applied");
fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } }); fireEvent.change(screen.getByPlaceholderText("Search..."), {
target: { value: "Design" },
});
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design"); expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
}); });
@@ -98,12 +119,19 @@ describe("OrchestratorFilters", () => {
const { props } = renderFilters(); const { props } = renderFilters();
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i })); fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i })); fireEvent.click(
await screen.findByRole("menuitemradio", { name: /LinkedIn/i }),
);
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin"); expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i })); fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i })); fireEvent.click(
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" }); await screen.findByRole("menuitem", { name: /Direction:/i }),
);
expect(props.onSortChange).toHaveBeenCalledWith({
key: "score",
direction: "asc",
});
}); });
it("only shows sources that exist in jobs", async () => { it("only shows sources that exist in jobs", async () => {
@@ -111,9 +139,17 @@ describe("OrchestratorFilters", () => {
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i })); fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
expect(await screen.findByRole("menuitemradio", { name: /Gradcracker/i })).toBeInTheDocument(); expect(
expect(screen.queryByRole("menuitemradio", { name: /LinkedIn/i })).not.toBeInTheDocument(); await screen.findByRole("menuitemradio", { name: /Gradcracker/i }),
expect(screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i })).not.toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("menuitemradio", { name: /Manual/i })).toBeInTheDocument(); expect(
screen.queryByRole("menuitemradio", { name: /LinkedIn/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i }),
).not.toBeInTheDocument();
expect(
screen.getByRole("menuitemradio", { name: /Manual/i }),
).toBeInTheDocument();
}); });
}); });
@@ -1,5 +1,5 @@
import React from "react";
import { ArrowUpDown, Filter, Search } from "lucide-react"; import { ArrowUpDown, Filter, Search } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -17,8 +17,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { sourceLabel } from "@/lib/utils"; import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types"; import type { JobSource } from "../../../shared/types";
import { defaultSortDirection, orderedFilterSources, sortLabels, tabs } from "./constants";
import type { FilterTab, JobSort } from "./constants"; import type { FilterTab, JobSort } from "./constants";
import {
defaultSortDirection,
orderedFilterSources,
sortLabels,
tabs,
} from "./constants";
interface OrchestratorFiltersProps { interface OrchestratorFiltersProps {
activeTab: FilterTab; activeTab: FilterTab;
@@ -45,104 +50,124 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
sort, sort,
onSortChange, onSortChange,
}) => { }) => {
const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source)); const visibleSources = orderedFilterSources.filter((source) =>
sourcesWithJobs.includes(source),
);
return ( return (
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}> <Tabs
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> value={activeTab}
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto"> onValueChange={(value) => onTabChange(value as FilterTab)}
{tabs.map((tab) => ( >
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<span>{tab.label}</span> <TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
{counts[tab.id] > 0 && ( {tabs.map((tab) => (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span> <TabsTrigger
)} key={tab.id}
</TabsTrigger> value={tab.id}
))} className="flex-1 flex items-center lg:flex-none gap-1.5"
</TabsList> >
<span>{tab.label}</span>
{counts[tab.id] > 0 && (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">
{counts[tab.id]}
</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2"> <div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none"> <div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" /> <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)} onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="Search..." placeholder="Search..."
className="h-8 pl-8 text-sm" className="h-8 pl-8 text-sm"
/> />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all"
? "All sources"
: sourceLabel[sourceFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) =>
onSourceFilterChange(value as JobSource | "all")
}
>
<DropdownMenuRadioItem value="all">
All Sources
</DropdownMenuRadioItem>
{visibleSources.map((source) => (
<DropdownMenuRadioItem key={source} value={source}>
{sourceLabel[source]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
onSortChange({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map(
(key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
),
)}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
onSortChange({
...sort,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
Direction:{" "}
{sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
>
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{visibleSources.map((source) => (
<DropdownMenuRadioItem key={source} value={source}>
{sourceLabel[source]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
onSortChange({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
onSortChange({
...sort,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </Tabs>
</Tabs> );
);
}; };
@@ -1,7 +1,7 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { OrchestratorHeader } from "./OrchestratorHeader"; import { OrchestratorHeader } from "./OrchestratorHeader";
@@ -9,16 +9,32 @@ vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react"); const React = require("react") as typeof import("react");
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => (
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, <div>{children}</div>
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>, ),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />, DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: (event: Event) => void }) => ( DropdownMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect?: (event: Event) => void;
}) => (
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)} onClick={() =>
onSelect?.({ preventDefault: () => {} } as unknown as Event)
}
> >
{children} {children}
</button> </button>
@@ -30,7 +46,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode; children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void; onCheckedChange?: (checked: boolean) => void;
}) => ( }) => (
<button type="button" role="menuitemcheckbox" onClick={() => onCheckedChange?.(true)}> <button
type="button"
role="menuitemcheckbox"
onClick={() => onCheckedChange?.(true)}
>
{children} {children}
</button> </button>
), ),
@@ -39,13 +59,23 @@ vi.mock("@/components/ui/dropdown-menu", () => {
vi.mock("@/components/ui/sheet", () => ({ vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SheetTrigger: ({ children }: { children: React.ReactNode }) => (
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, <div>{children}</div>
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, ),
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SheetContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
})); }));
const renderHeader = (overrides: Partial<React.ComponentProps<typeof OrchestratorHeader>> = {}) => { const renderHeader = (
overrides: Partial<React.ComponentProps<typeof OrchestratorHeader>> = {},
) => {
const props: React.ComponentProps<typeof OrchestratorHeader> = { const props: React.ComponentProps<typeof OrchestratorHeader> = {
navOpen: false, navOpen: false,
onNavOpenChange: vi.fn(), onNavOpenChange: vi.fn(),
@@ -64,32 +94,52 @@ const renderHeader = (overrides: Partial<React.ComponentProps<typeof Orchestrato
...render( ...render(
<MemoryRouter> <MemoryRouter>
<OrchestratorHeader {...props} /> <OrchestratorHeader {...props} />
</MemoryRouter> </MemoryRouter>,
), ),
}; };
}; };
describe("OrchestratorHeader", () => { describe("OrchestratorHeader", () => {
it("renders only enabled sources", () => { it("renders only enabled sources", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"], pipelineSources: ["linkedin"] }); renderHeader({
enabledSources: ["gradcracker", "linkedin"],
pipelineSources: ["linkedin"],
});
expect(screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i })).toBeInTheDocument(); expect(
expect(screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i })).toBeInTheDocument(); screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i }),
expect(screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i })).not.toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i }),
).not.toBeInTheDocument();
}); });
it("uses enabled sources for the all sources action", () => { it("uses enabled sources for the all sources action", () => {
const { props } = renderHeader({ enabledSources: ["gradcracker", "linkedin"] }); const { props } = renderHeader({
enabledSources: ["gradcracker", "linkedin"],
});
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Select all sources/i })); fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }),
);
expect(props.onSetPipelineSources).toHaveBeenCalledWith(["gradcracker", "linkedin"]); expect(props.onSetPipelineSources).toHaveBeenCalledWith([
"gradcracker",
"linkedin",
]);
}); });
it("does not show source presets", () => { it("does not show source presets", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"] }); renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
expect(screen.queryByRole("menuitem", { name: /Gradcracker only/i })).not.toBeInTheDocument(); expect(
expect(screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i })).not.toBeInTheDocument(); screen.queryByRole("menuitem", { name: /Gradcracker only/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i }),
).not.toBeInTheDocument();
}); });
}); });
@@ -1,4 +1,3 @@
import React from "react";
import { import {
Briefcase, Briefcase,
ChevronDown, ChevronDown,
@@ -11,6 +10,7 @@ import {
Shield, Shield,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -66,9 +66,12 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
}) => { }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const visibleSources = orderedSources.filter((source) => enabledSources.includes(source)); const visibleSources = orderedSources.filter((source) =>
enabledSources.includes(source),
);
const allSourcesSelected = const allSourcesSelected =
visibleSources.length > 0 && visibleSources.every((source) => pipelineSources.includes(source)); visibleSources.length > 0 &&
visibleSources.every((source) => pipelineSources.includes(source));
return ( return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -99,7 +102,16 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
setTimeout(() => navigate(to), 150); setTimeout(() => navigate(to), 150);
}} }}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${ className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname)) location.pathname === to ||
(
to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname)
)
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
@@ -117,7 +129,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<Sparkles className="h-4 w-4 text-muted-foreground" /> <Sparkles className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="min-w-0 leading-tight"> <div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">Job Ops</div> <div className="text-sm font-semibold tracking-tight">
Job Ops
</div>
<div className="text-xs text-muted-foreground">Orchestrator</div> <div className="text-xs text-muted-foreground">Orchestrator</div>
</div> </div>
</div> </div>
@@ -147,9 +161,15 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
disabled={isPipelineRunning} disabled={isPipelineRunning}
className="gap-2" className="gap-2"
> >
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {isPipelineRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="hidden sm:inline"> <span className="hidden sm:inline">
{isPipelineRunning ? `Running (${pipelineSources.length})` : `Run pipeline (${pipelineSources.length})`} {isPipelineRunning
? `Running (${pipelineSources.length})`
: `Run pipeline (${pipelineSources.length})`}
</span> </span>
</Button> </Button>
@@ -172,7 +192,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={source} key={source}
checked={pipelineSources.includes(source)} checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))} onCheckedChange={(checked) =>
onToggleSource(source, Boolean(checked))
}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
> >
{sourceLabel[source]} {sourceLabel[source]}
@@ -182,7 +204,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={allSourcesSelected} checked={allSourcesSelected}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
onSetPipelineSources(checked ? visibleSources : visibleSources.slice(0, 1)); onSetPipelineSources(
checked ? visibleSources : visibleSources.slice(0, 1),
);
}} }}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
> >
@@ -1,7 +1,6 @@
import React from "react"; import type React from "react";
import { PipelineProgress } from "../../components";
import type { JobStatus } from "../../../shared/types"; import type { JobStatus } from "../../../shared/types";
import { PipelineProgress } from "../../components";
interface OrchestratorSummaryProps { interface OrchestratorSummaryProps {
stats: Record<JobStatus, number>; stats: Record<JobStatus, number>;
@@ -28,17 +27,33 @@ export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
{/* Compact metrics summary - demoted visual weight */} {/* Compact metrics summary - demoted visual weight */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
<span><span className="tabular-nums">{stats.ready}</span> ready</span> <span>
<span className="tabular-nums">{stats.ready}</span> ready
</span>
<span className="text-border">•</span> <span className="text-border">•</span>
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span> <span>
<span className="tabular-nums">
{stats.discovered + stats.processing}
</span>{" "}
discovered
</span>
<span className="text-border">•</span> <span className="text-border">•</span>
<span><span className="tabular-nums">{stats.applied}</span> applied</span> <span>
<span className="tabular-nums">{stats.applied}</span> applied
</span>
<span className="text-border">•</span> <span className="text-border">•</span>
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span> <span className="font-medium text-foreground/60">
{totalJobs} jobs total
</span>
{(stats.skipped > 0 || stats.expired > 0) && ( {(stats.skipped > 0 || stats.expired > 0) && (
<> <>
<span className="text-border">•</span> <span className="text-border">•</span>
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span> <span className="text-muted-foreground/60">
<span className="tabular-nums">
{stats.skipped + stats.expired}
</span>{" "}
skipped
</span>
</> </>
)} )}
</div> </div>
@@ -1,12 +1,25 @@
import type { JobSource, JobStatus } from "../../../shared/types"; import type { JobSource, JobStatus } from "../../../shared/types";
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; export const orderedSources: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
export const orderedFilterSources: JobSource[] = [...orderedSources, "manual"]; export const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = { export const statusTokens: Record<
JobStatus,
{ label: string; badge: string; dot: string }
> = {
discovered: { discovered: {
label: "Discovered", label: "Discovered",
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200", badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
@@ -71,9 +84,17 @@ export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
employer: "asc", employer: "asc",
}; };
export const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ export const tabs: Array<{
id: FilterTab;
label: string;
statuses: JobStatus[];
}> = [
{ id: "ready", label: "Ready", statuses: ["ready"] }, { id: "ready", label: "Ready", statuses: ["ready"] },
{ id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] }, {
id: "discovered",
label: "Discovered",
statuses: ["discovered", "processing"],
},
{ id: "applied", label: "Applied", statuses: ["applied"] }, { id: "applied", label: "Applied", statuses: ["applied"] },
{ id: "all", label: "All Jobs", statuses: [] }, { id: "all", label: "All Jobs", statuses: [] },
]; ];
@@ -17,7 +17,9 @@ export const useFilteredJobs = (
if (activeTab === "ready") { if (activeTab === "ready") {
filtered = filtered.filter((job) => job.status === "ready"); filtered = filtered.filter((job) => job.status === "ready");
} else if (activeTab === "discovered") { } else if (activeTab === "discovered") {
filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing"); filtered = filtered.filter(
(job) => job.status === "discovered" || job.status === "processing",
);
} else if (activeTab === "applied") { } else if (activeTab === "applied") {
filtered = filtered.filter((job) => job.status === "applied"); filtered = filtered.filter((job) => job.status === "applied");
} }
@@ -26,7 +26,8 @@ export const useOrchestratorData = () => {
setJobs(data.jobs); setJobs(data.jobs);
setStats(data.byStatus); setStats(data.byStatus);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load jobs"; const message =
error instanceof Error ? error.message : "Failed to load jobs";
toast.error(message); toast.error(message);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -54,5 +55,13 @@ export const useOrchestratorData = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]); }, [loadJobs, checkPipelineStatus]);
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; return {
jobs,
stats,
isLoading,
isPipelineRunning,
setIsPipelineRunning,
loadJobs,
checkPipelineStatus,
};
}; };
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest";
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants"; import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
import { usePipelineSources } from "./usePipelineSources"; import { usePipelineSources } from "./usePipelineSources";
@@ -10,7 +10,10 @@ describe("usePipelineSources", () => {
}); });
it("filters stored sources to enabled sources", () => { it("filters stored sources to enabled sources", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker", "ukvisajobs"])); localStorage.setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(["gradcracker", "ukvisajobs"]),
);
const enabledSources = ["gradcracker"] as const; const enabledSources = ["gradcracker"] as const;
@@ -20,7 +23,10 @@ describe("usePipelineSources", () => {
}); });
it("falls back to the first enabled source", () => { it("falls back to the first enabled source", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["ukvisajobs"])); localStorage.setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(["ukvisajobs"]),
);
const enabledSources = ["gradcracker", "linkedin"] as const; const enabledSources = ["gradcracker", "linkedin"] as const;
@@ -30,7 +36,10 @@ describe("usePipelineSources", () => {
}); });
it("ignores toggles for disabled sources", () => { it("ignores toggles for disabled sources", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker"])); localStorage.setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(["gradcracker"]),
);
const enabledSources = ["gradcracker"] as const; const enabledSources = ["gradcracker"] as const;
@@ -3,30 +3,42 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { JobSource } from "../../../shared/types"; import type { JobSource } from "../../../shared/types";
import { import {
DEFAULT_PIPELINE_SOURCES, DEFAULT_PIPELINE_SOURCES,
PIPELINE_SOURCES_STORAGE_KEY,
orderedSources, orderedSources,
PIPELINE_SOURCES_STORAGE_KEY,
} from "./constants"; } from "./constants";
const resolveAllowedSources = (enabledSources?: JobSource[]) => const resolveAllowedSources = (enabledSources?: JobSource[]) =>
enabledSources && enabledSources.length > 0 ? enabledSources : DEFAULT_PIPELINE_SOURCES; enabledSources && enabledSources.length > 0
? enabledSources
: DEFAULT_PIPELINE_SOURCES;
const normalizeSources = (sources: JobSource[], allowedSources: JobSource[]) => { const normalizeSources = (
sources: JobSource[],
allowedSources: JobSource[],
) => {
const filtered = sources.filter((value) => allowedSources.includes(value)); const filtered = sources.filter((value) => allowedSources.includes(value));
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1); return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
}; };
const sourcesMatch = (left: JobSource[], right: JobSource[]) => const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
left.length === right.length && left.every((value, index) => value === right[index]); left.length === right.length &&
left.every((value, index) => value === right[index]);
export const usePipelineSources = (enabledSources?: JobSource[]) => { export const usePipelineSources = (enabledSources?: JobSource[]) => {
const allowedSources = useMemo(() => resolveAllowedSources(enabledSources), [enabledSources]); const allowedSources = useMemo(
() => resolveAllowedSources(enabledSources),
[enabledSources],
);
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => { const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try { try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
if (!raw) return normalizeSources(allowedSources, allowedSources); if (!raw) return normalizeSources(allowedSources, allowedSources);
const parsed = JSON.parse(raw) as unknown; const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return normalizeSources(allowedSources, allowedSources); if (!Array.isArray(parsed))
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource)); return normalizeSources(allowedSources, allowedSources);
const next = parsed.filter((value): value is JobSource =>
orderedSources.includes(value as JobSource),
);
return normalizeSources(next, allowedSources); return normalizeSources(next, allowedSources);
} catch { } catch {
return normalizeSources(allowedSources, allowedSources); return normalizeSources(allowedSources, allowedSources);
@@ -42,22 +54,28 @@ export const usePipelineSources = (enabledSources?: JobSource[]) => {
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources)); localStorage.setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(pipelineSources),
);
} catch { } catch {
// Ignore localStorage errors // Ignore localStorage errors
} }
}, [pipelineSources]); }, [pipelineSources]);
const toggleSource = useCallback((source: JobSource, checked: boolean) => { const toggleSource = useCallback(
if (!allowedSources.includes(source)) return; (source: JobSource, checked: boolean) => {
setPipelineSources((current) => { if (!allowedSources.includes(source)) return;
const next = checked setPipelineSources((current) => {
? Array.from(new Set([...current, source])) const next = checked
: current.filter((value) => value !== source); ? Array.from(new Set([...current, source]))
: current.filter((value) => value !== source);
return next.length === 0 ? current : next; return next.length === 0 ? current : next;
}); });
}, [allowedSources]); },
[allowedSources],
);
return { pipelineSources, setPipelineSources, toggleSource }; return { pipelineSources, setPipelineSources, toggleSource };
}; };
@@ -1,6 +1,6 @@
import type { AppSettings, Job, JobSource } from "../../../shared/types"; import type { AppSettings, Job, JobSource } from "../../../shared/types";
import { orderedFilterSources, orderedSources } from "./constants";
import type { FilterTab, JobSort } from "./constants"; import type { FilterTab, JobSort } from "./constants";
import { orderedFilterSources, orderedSources } from "./constants";
const dateValue = (value: string | null) => { const dateValue = (value: string | null) => {
if (!value) return null; if (!value) return null;
@@ -8,7 +8,8 @@ const dateValue = (value: string | null) => {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
}; };
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); const compareString = (a: string, b: string) =>
a.localeCompare(b, undefined, { sensitivity: "base" });
const compareNumber = (a: number, b: number) => a - b; const compareNumber = (a: number, b: number) => a - b;
export const compareJobs = (a: Job, b: Job, sort: JobSort) => { export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
@@ -83,7 +84,8 @@ export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
for (const job of jobs) { for (const job of jobs) {
if (job.status === "ready") byTab.ready += 1; if (job.status === "ready") byTab.ready += 1;
if (job.status === "applied") byTab.applied += 1; if (job.status === "applied") byTab.applied += 1;
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1; if (job.status === "discovered" || job.status === "processing")
byTab.discovered += 1;
} }
return byTab; return byTab;
@@ -97,13 +99,15 @@ export const getSourcesWithJobs = (jobs: Job[]): JobSource[] => {
return orderedFilterSources.filter((source) => seen.has(source)); return orderedFilterSources.filter((source) => seen.has(source));
}; };
export const getEnabledSources = (settings: AppSettings | null): JobSource[] => { export const getEnabledSources = (
settings: AppSettings | null,
): JobSource[] => {
if (!settings) return [...orderedSources]; if (!settings) return [...orderedSources];
const enabled: JobSource[] = []; const enabled: JobSource[] = [];
const jobspySites = settings.jobspySites ?? []; const jobspySites = settings.jobspySites ?? [];
const hasUkVisaJobsAuth = Boolean( const hasUkVisaJobsAuth = Boolean(
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint,
); );
for (const source of orderedSources) { for (const source of orderedSources) {
@@ -1,107 +1,122 @@
import React, { useEffect, useState } from "react" import * as api from "@client/api";
import { RefreshCw } from "lucide-react" import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button" import type React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useEffect, useState } from "react";
import * as api from "@client/api" import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type BaseResumeSelectionProps = { type BaseResumeSelectionProps = {
value: string | null value: string | null;
onValueChange: (value: string | null) => void onValueChange: (value: string | null) => void;
hasRxResumeAccess: boolean hasRxResumeAccess: boolean;
disabled?: boolean disabled?: boolean;
isLoading?: boolean isLoading?: boolean;
} };
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
value, value,
onValueChange, onValueChange,
hasRxResumeAccess, hasRxResumeAccess,
disabled = false, disabled = false,
isLoading = false, isLoading = false,
}) => { }) => {
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]);
const [isFetchingResumes, setIsFetchingResumes] = useState(false) const [isFetchingResumes, setIsFetchingResumes] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null) const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = async () => { const fetchResumes = async () => {
if (!hasRxResumeAccess) return if (!hasRxResumeAccess) return;
setIsFetchingResumes(true) setIsFetchingResumes(true);
setFetchError(null) setFetchError(null);
try { try {
const data = await api.getRxResumes() const data = await api.getRxResumes();
setResumes(data) setResumes(data);
// Preselect if only one option is available and no value is currently set // Preselect if only one option is available and no value is currently set
if (data.length === 1 && !value) { if (data.length === 1 && !value) {
onValueChange(data[0].id) onValueChange(data[0].id);
} }
} catch (error) { } catch (error) {
setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes") setFetchError(
} finally { error instanceof Error ? error.message : "Failed to fetch resumes",
setIsFetchingResumes(false) );
} } finally {
setIsFetchingResumes(false);
} }
};
useEffect(() => { useEffect(() => {
if (hasRxResumeAccess) { if (hasRxResumeAccess) {
fetchResumes() fetchResumes();
} }
}, [hasRxResumeAccess]) }, [hasRxResumeAccess]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div> <div className="text-sm font-medium">Template Resume</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={fetchResumes} onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled} disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2" className="h-8 px-2"
> >
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} /> <RefreshCw
Refresh className={`h-3 w-3 mr-1 ${isFetchingResumes ? "animate-spin" : ""}`}
</Button> />
</div> Refresh
</Button>
</div>
<Select <Select
value={value || ""} value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)} onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes} disabled={disabled || isLoading || isFetchingResumes}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={resumes.length > 0 ? "Select a template resume..." : "No resumes found"} /> <SelectValue
</SelectTrigger> placeholder={
<SelectContent> resumes.length > 0
{resumes.map((resume) => ( ? "Select a template resume..."
<SelectItem key={resume.id} value={resume.id}> : "No resumes found"
{resume.name} }
</SelectItem> />
))} </SelectTrigger>
</SelectContent> <SelectContent>
</Select> {resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{resumes.length === 0 && !isFetchingResumes && !fetchError && ( {resumes.length === 0 && !isFetchingResumes && !fetchError && (
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2"> <div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
No resumes found in your account. Please create a resume on the{" "} No resumes found in your account. Please create a resume on the{" "}
<a <a
href="https://rxresu.me" href="https://rxresu.me"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-semibold underline underline-offset-2" className="font-semibold underline underline-offset-2"
> >
Reactive Resume website Reactive Resume website
</a>{" "} </a>{" "}
first. first.
</div>
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
</div> </div>
) )}
}
{fetchError && (
<div className="text-xs text-destructive mt-1">{fetchError}</div>
)}
</div>
);
};
@@ -1,19 +1,27 @@
import { describe, it, expect, vi } from "vitest" import type { JobStatus } from "@shared/types";
import { render, screen, fireEvent } from "@testing-library/react" import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react" import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { DangerZoneSection } from "./DangerZoneSection";
import { Accordion } from "@/components/ui/accordion" const DangerZoneHarness = ({
import { DangerZoneSection } from "./DangerZoneSection" initialStatuses = [] as JobStatus[],
import type { JobStatus } from "@shared/types" onClear,
}: {
const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => { initialStatuses?: JobStatus[];
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(initialStatuses) onClear?: () => void;
}) => {
const [statusesToClear, setStatusesToClear] =
useState<JobStatus[]>(initialStatuses);
const toggleStatusToClear = (status: JobStatus) => { const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear((prev) => setStatusesToClear((prev) =>
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status] prev.includes(status)
) ? prev.filter((s) => s !== status)
} : [...prev, status],
);
};
return ( return (
<Accordion type="multiple" defaultValue={["danger-zone"]}> <Accordion type="multiple" defaultValue={["danger-zone"]}>
@@ -26,33 +34,37 @@ const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { i
isSaving={false} isSaving={false}
/> />
</Accordion> </Accordion>
) );
} };
describe("DangerZoneSection", () => { describe("DangerZoneSection", () => {
it("disables clear when no statuses are selected", () => { it("disables clear when no statuses are selected", () => {
render(<DangerZoneHarness initialStatuses={[]} />) render(<DangerZoneHarness initialStatuses={[]} />);
const clearButton = screen.getByRole("button", { name: /clear selected/i }) const clearButton = screen.getByRole("button", { name: /clear selected/i });
expect(clearButton).toBeDisabled() expect(clearButton).toBeDisabled();
}) });
it("toggles status selection and confirms clear", async () => { it("toggles status selection and confirms clear", async () => {
const onClear = vi.fn() const onClear = vi.fn();
render(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />) render(
<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />,
);
const appliedButton = screen.getByRole("button", { name: /applied/i }) const appliedButton = screen.getByRole("button", { name: /applied/i });
const clearButton = screen.getByRole("button", { name: /clear selected/i }) const clearButton = screen.getByRole("button", { name: /clear selected/i });
expect(clearButton).toBeEnabled() expect(clearButton).toBeEnabled();
fireEvent.click(clearButton) fireEvent.click(clearButton);
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i }) const confirmButton = await screen.findByRole("button", {
fireEvent.click(confirmButton) name: /clear 1 status/i,
});
fireEvent.click(confirmButton);
expect(onClear).toHaveBeenCalledTimes(1) expect(onClear).toHaveBeenCalledTimes(1);
fireEvent.click(appliedButton) fireEvent.click(appliedButton);
expect(clearButton).toBeDisabled() expect(clearButton).toBeDisabled();
}) });
}) });
@@ -1,6 +1,15 @@
import React from "react" import {
import { AlertTriangle, Trash2 } from "lucide-react" ALL_JOB_STATUSES,
STATUS_DESCRIPTIONS,
} from "@client/pages/settings/constants";
import type { JobStatus } from "@shared/types";
import { AlertTriangle, Trash2 } from "lucide-react";
import type React from "react";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -11,21 +20,18 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator";
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
type DangerZoneSectionProps = { type DangerZoneSectionProps = {
statusesToClear: JobStatus[] statusesToClear: JobStatus[];
toggleStatusToClear: (status: JobStatus) => void toggleStatusToClear: (status: JobStatus) => void;
handleClearByStatuses: () => void handleClearByStatuses: () => void;
handleClearDatabase: () => void handleClearDatabase: () => void;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
statusesToClear, statusesToClear,
@@ -36,18 +42,25 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
isSaving, isSaving,
}) => { }) => {
return ( return (
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4"> <AccordionItem
value="danger-zone"
className="border rounded-lg px-4 border-destructive/30 mt-4"
>
<AccordionTrigger className="hover:no-underline py-4"> <AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2 text-destructive"> <div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span className="text-base font-semibold tracking-wider">Danger Zone</span> <span className="text-base font-semibold tracking-wider">
Danger Zone
</span>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-4"> <AccordionContent className="pb-4">
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4"> <div className="p-3 rounded-md space-y-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div> <div className="text-sm font-semibold text-destructive">
Clear Jobs by Status
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Select which job statuses you want to clear. Select which job statuses you want to clear.
</div> </div>
@@ -55,7 +68,7 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => { {ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status) const isSelected = statusesToClear.includes(status);
return ( return (
<button <button
key={status} key={status}
@@ -63,22 +76,32 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onClick={() => toggleStatusToClear(status)} onClick={() => toggleStatusToClear(status)}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${ className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected ? 'border-destructive bg-destructive/10' : 'border-border' isSelected
? "border-destructive bg-destructive/10"
: "border-border"
}`} }`}
> >
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${ <div
isSelected ? 'border-destructive' : 'border-muted-foreground' className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
}`}> isSelected
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />} ? "border-destructive"
: "border-muted-foreground"
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-destructive" />
)}
</div> </div>
<div className="grid gap-0.5"> <div className="grid gap-0.5">
<span className="text-sm font-medium capitalize">{status}</span> <span className="text-sm font-medium capitalize">
{status}
</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{STATUS_DESCRIPTIONS[status]} {STATUS_DESCRIPTIONS[status]}
</span> </span>
</div> </div>
</button> </button>
) );
})} })}
</div> </div>
@@ -87,7 +110,9 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0} disabled={
isLoading || isSaving || statusesToClear.length === 0
}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length}) Clear Selected ({statusesToClear.length})
@@ -97,14 +122,18 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle> <AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}. This will delete all jobs with the following statuses:{" "}
This action cannot be undone. {statusesToClear.join(", ")}. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> <AlertDialogAction
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''} onClick={handleClearByStatuses}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear {statusesToClear.length} status
{statusesToClear.length !== 1 ? "es" : ""}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -115,14 +144,20 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div> <div className="text-sm font-semibold text-destructive">
Clear Entire Database
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Delete all jobs and pipeline runs from the database. Delete all jobs and pipeline runs from the database.
</div> </div>
</div> </div>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}> <Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Clear Database Clear Database
</Button> </Button>
@@ -131,12 +166,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle> <AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This deletes all jobs and pipeline runs from the database. This action cannot be undone. This deletes all jobs and pipeline runs from the database.
This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> <AlertDialogAction
onClick={handleClearDatabase}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear database Clear database
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -146,5 +185,5 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,81 +1,89 @@
import React from "react" import type { DisplayValues } from "@client/pages/settings/types";
import { useFormContext, Controller } from "react-hook-form" import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Controller, useFormContext } from "react-hook-form";
import { Checkbox } from "@/components/ui/checkbox" import {
import { Separator } from "@/components/ui/separator" AccordionContent,
import { UpdateSettingsInput } from "@shared/settings-schema" AccordionItem,
import type { DisplayValues } from "@client/pages/settings/types" AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type DisplaySettingsSectionProps = { type DisplaySettingsSectionProps = {
values: DisplayValues values: DisplayValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
values, values,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values const {
const { control } = useFormContext<UpdateSettingsInput>() default: defaultShowSponsorInfo,
effective: effectiveShowSponsorInfo,
} = values;
const { control } = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="display" className="border rounded-lg px-4"> <AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4"> <AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span> <span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-4"> <AccordionContent className="pb-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Controller <Controller
name="showSponsorInfo" name="showSponsorInfo"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Checkbox <Checkbox
id="showSponsorInfo" id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo} checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
field.onChange(checked === "indeterminate" ? null : checked === true) field.onChange(
}} checked === "indeterminate" ? null : checked === true,
disabled={isLoading || isSaving} );
/> }}
)} disabled={isLoading || isSaving}
/> />
<div className="flex flex-col gap-1.5"> )}
<label />
htmlFor="showSponsorInfo" <div className="flex flex-col gap-1.5">
className="text-sm font-medium leading-none cursor-pointer" <label
> htmlFor="showSponsorInfo"
Show visa sponsor information className="text-sm font-medium leading-none cursor-pointer"
</label> >
<p className="text-xs text-muted-foreground"> Show visa sponsor information
Display a badge next to the employer name showing the match </label>
percentage with the UK visa sponsor list. This helps identify <p className="text-xs text-muted-foreground">
employers that are licensed to sponsor work visas. Display a badge next to the employer name showing the match
</p> percentage with the UK visa sponsor list. This helps identify
</div> employers that are licensed to sponsor work visas.
</div> </p>
</div>
</div>
<Separator /> <Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2"> <div className="grid gap-2 text-sm sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted-foreground">Effective</div> <div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs"> <div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"} {effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Default</div> <div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold"> <div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"} {defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { useForm, FormProvider } from "react-hook-form" import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { Accordion } from "@/components/ui/accordion" import { Accordion } from "@/components/ui/accordion";
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection" import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection";
import { UpdateSettingsInput } from "@shared/settings-schema"
const EnvironmentSettingsHarness = () => { const EnvironmentSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({ const methods = useForm<UpdateSettingsInput>({
@@ -17,8 +16,8 @@ const EnvironmentSettingsHarness = () => {
basicAuthPassword: "", basicAuthPassword: "",
webhookSecret: "", webhookSecret: "",
enableBasicAuth: true, enableBasicAuth: true,
} },
}) });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@@ -44,28 +43,28 @@ const EnvironmentSettingsHarness = () => {
/> />
</Accordion> </Accordion>
</FormProvider> </FormProvider>
) );
} };
describe("EnvironmentSettingsSection", () => { describe("EnvironmentSettingsSection", () => {
it("renders values grouped logically and masks private secrets with hints", () => { it("renders values grouped logically and masks private secrets with hints", () => {
render(<EnvironmentSettingsHarness />) render(<EnvironmentSettingsHarness />);
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument() expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument() expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument() expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument() expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument() expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
expect(screen.getByText("Not set")).toBeInTheDocument() expect(screen.getByText("Not set")).toBeInTheDocument();
// Basic Auth // Basic Auth
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked() expect(screen.getByLabelText("Enable basic authentication")).toBeChecked();
expect(screen.getByDisplayValue("admin")).toBeInTheDocument() expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
// Sections // Sections
expect(screen.getByText("External Services")).toBeInTheDocument() expect(screen.getByText("External Services")).toBeInTheDocument();
expect(screen.getByText("Service Accounts")).toBeInTheDocument() expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument() expect(screen.getByText("Security")).toBeInTheDocument();
}) });
}) });
@@ -1,29 +1,35 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext, Controller } from "react-hook-form" import type { EnvSettingsValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { Checkbox } from "@/components/ui/checkbox" import type React from "react";
import { Separator } from "@/components/ui/separator" import { Controller, useFormContext } from "react-hook-form";
import { UpdateSettingsInput } from "@shared/settings-schema" import {
import type { EnvSettingsValues } from "@client/pages/settings/types" AccordionContent,
import { formatSecretHint } from "@client/pages/settings/utils" AccordionItem,
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type EnvironmentSettingsSectionProps = { type EnvironmentSettingsSectionProps = {
values: EnvSettingsValues values: EnvSettingsValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({ export const EnvironmentSettingsSection: React.FC<
values, EnvironmentSettingsSectionProps
isLoading, > = ({ values, isLoading, isSaving }) => {
isSaving, const {
}) => { register,
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>() control,
const { private: privateValues } = values watch,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
const { private: privateValues } = values;
const isBasicAuthEnabled = watch("enableBasicAuth") const isBasicAuthEnabled = watch("enableBasicAuth");
return ( return (
<AccordionItem value="environment" className="border rounded-lg px-4"> <AccordionItem value="environment" className="border rounded-lg px-4">
@@ -34,7 +40,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
<div className="space-y-8"> <div className="space-y-8">
{/* External Services */} {/* External Services */}
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div> <div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
External Services
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<SettingsInput <SettingsInput
label="OpenRouter API key" label="OpenRouter API key"
@@ -52,7 +60,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
{/* Service Accounts */} {/* Service Accounts */}
<div className="space-y-6"> <div className="space-y-6">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div> <div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
Service Accounts
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-semibold">RxResume</div> <div className="text-sm font-semibold">RxResume</div>
@@ -92,8 +102,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
type="password" type="password"
placeholder="Enter new password" placeholder="Enter new password"
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.ukvisajobsPassword?.message as string | undefined} error={
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)} errors.ukvisajobsPassword?.message as string | undefined
}
current={formatSecretHint(
privateValues.ukvisajobsPasswordHint,
)}
/> />
</div> </div>
</div> </div>
@@ -103,7 +117,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
{/* Security */} {/* Security */}
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div> <div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
Security
</div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Controller <Controller
name="enableBasicAuth" name="enableBasicAuth"
@@ -146,8 +162,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
type="password" type="password"
placeholder="Enter new password" placeholder="Enter new password"
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.basicAuthPassword?.message as string | undefined} error={
current={formatSecretHint(privateValues.basicAuthPasswordHint)} errors.basicAuthPassword?.message as string | undefined
}
current={formatSecretHint(
privateValues.basicAuthPasswordHint,
)}
/> />
</div> </div>
)} )}
@@ -155,5 +175,5 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,24 +1,33 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext, Controller } from "react-hook-form" import type { NumericSettingValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type React from "react";
import { UpdateSettingsInput } from "@shared/settings-schema" import { Controller, useFormContext } from "react-hook-form";
import type { NumericSettingValues } from "@client/pages/settings/types" import {
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
type GradcrackerSectionProps = { type GradcrackerSectionProps = {
values: NumericSettingValues values: NumericSettingValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
values, values,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values const {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>() effective: effectiveGradcrackerMaxJobsPerTerm,
default: defaultGradcrackerMaxJobsPerTerm,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4"> <AccordionItem value="gradcracker" className="border rounded-lg px-4">
@@ -41,16 +50,20 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
max: 1000, max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm, value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => { onChange: (event) => {
const value = parseInt(event.target.value, 10) const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) { if (Number.isNaN(value)) {
field.onChange(null) field.onChange(null);
} else { } else {
field.onChange(Math.min(1000, Math.max(1, value))) field.onChange(Math.min(1000, Math.max(1, value)));
} }
}, },
}} }}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined} error={
errors.gradcrackerMaxJobsPerTerm?.message as
| string
| undefined
}
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`} helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
current={String(effectiveGradcrackerMaxJobsPerTerm)} current={String(effectiveGradcrackerMaxJobsPerTerm)}
/> />
@@ -59,5 +72,5 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,10 +1,9 @@
import { describe, it, expect } from "vitest" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { render, screen, fireEvent } from "@testing-library/react" import { fireEvent, render, screen } from "@testing-library/react";
import { useForm, FormProvider } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { Accordion } from "@/components/ui/accordion" import { Accordion } from "@/components/ui/accordion";
import { JobspySection } from "./JobspySection" import { JobspySection } from "./JobspySection";
import { UpdateSettingsInput } from "@shared/settings-schema"
const JobspyHarness = () => { const JobspyHarness = () => {
const methods = useForm<UpdateSettingsInput>({ const methods = useForm<UpdateSettingsInput>({
@@ -15,15 +14,18 @@ const JobspyHarness = () => {
jobspyHoursOld: 72, jobspyHoursOld: 72,
jobspyCountryIndeed: "UK", jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true, jobspyLinkedinFetchDescription: true,
} },
}) });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}> <Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection <JobspySection
values={{ values={{
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] }, sites: {
default: ["indeed", "linkedin"],
effective: ["indeed", "linkedin"],
},
location: { default: "UK", effective: "UK" }, location: { default: "UK", effective: "UK" },
resultsWanted: { default: 200, effective: 200 }, resultsWanted: { default: 200, effective: 200 },
hoursOld: { default: 72, effective: 72 }, hoursOld: { default: 72, effective: 72 },
@@ -35,39 +37,38 @@ const JobspyHarness = () => {
/> />
</Accordion> </Accordion>
</FormProvider> </FormProvider>
) );
} };
describe("JobspySection", () => { describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => { it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />) render(<JobspyHarness />);
const indeedCheckbox = screen.getByLabelText("Indeed") const indeedCheckbox = screen.getByLabelText("Indeed");
const linkedinCheckbox = screen.getByLabelText("LinkedIn") const linkedinCheckbox = screen.getByLabelText("LinkedIn");
expect(indeedCheckbox).toBeChecked() expect(indeedCheckbox).toBeChecked();
expect(linkedinCheckbox).toBeChecked() expect(linkedinCheckbox).toBeChecked();
fireEvent.click(indeedCheckbox) fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).not.toBeChecked() expect(indeedCheckbox).not.toBeChecked();
expect(linkedinCheckbox).toBeChecked() expect(linkedinCheckbox).toBeChecked();
fireEvent.click(indeedCheckbox) fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).toBeChecked() expect(indeedCheckbox).toBeChecked();
}) });
it("clamps numeric inputs to allowed ranges", () => { it("clamps numeric inputs to allowed ranges", () => {
render(<JobspyHarness />) render(<JobspyHarness />);
const numericInputs = screen.getAllByRole("spinbutton") const numericInputs = screen.getAllByRole("spinbutton");
const resultsWantedInput = numericInputs[0] const resultsWantedInput = numericInputs[0];
const hoursOldInput = numericInputs[1] const hoursOldInput = numericInputs[1];
fireEvent.change(resultsWantedInput, { target: { value: "1001" } }) fireEvent.change(resultsWantedInput, { target: { value: "1001" } });
expect(resultsWantedInput).toHaveValue(1000) expect(resultsWantedInput).toHaveValue(1000);
fireEvent.change(hoursOldInput, { target: { value: "0" } }) fireEvent.change(hoursOldInput, { target: { value: "0" } });
expect(hoursOldInput).toHaveValue(1) expect(hoursOldInput).toHaveValue(1);
}) });
}) });
@@ -1,19 +1,28 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext, Controller } from "react-hook-form" import type { JobspyValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type React from "react";
import { Checkbox } from "@/components/ui/checkbox" import { Controller, useFormContext } from "react-hook-form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
import { Separator } from "@/components/ui/separator" AccordionContent,
import { UpdateSettingsInput } from "@shared/settings-schema" AccordionItem,
import type { JobspyValues } from "@client/pages/settings/types" AccordionTrigger,
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" } from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
type JobspySectionProps = { type JobspySectionProps = {
values: JobspyValues values: JobspyValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
const JOBSPY_INDEED_COUNTRIES = [ const JOBSPY_INDEED_COUNTRIES = [
"argentina", "argentina",
@@ -95,15 +104,15 @@ const JOBSPY_INDEED_COUNTRIES = [
"vietnam", "vietnam",
"usa/ca", "usa/ca",
"worldwide", "worldwide",
] ];
const COUNTRY_ALIASES: Record<string, string> = { const COUNTRY_ALIASES: Record<string, string> = {
uk: "united kingdom", uk: "united kingdom",
us: "united states", us: "united states",
usa: "united states", usa: "united states",
"türkiye": "turkey", türkiye: "turkey",
"czech republic": "czechia", "czech republic": "czechia",
} };
const COUNTRY_LABELS: Record<string, string> = { const COUNTRY_LABELS: Record<string, string> = {
"united kingdom": "United Kingdom", "united kingdom": "United Kingdom",
@@ -111,21 +120,22 @@ const COUNTRY_LABELS: Record<string, string> = {
"usa/ca": "USA/CA", "usa/ca": "USA/CA",
turkey: "Turkey", turkey: "Turkey",
czechia: "Czechia", czechia: "Czechia",
} };
const normalizeCountryValue = (value: string) => COUNTRY_ALIASES[value] ?? value const normalizeCountryValue = (value: string) =>
COUNTRY_ALIASES[value] ?? value;
const formatCountryLabel = (value: string) => const formatCountryLabel = (value: string) =>
COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase()) COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase());
const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from( const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from(
new Map( new Map(
JOBSPY_INDEED_COUNTRIES.map((country) => { JOBSPY_INDEED_COUNTRIES.map((country) => {
const normalized = normalizeCountryValue(country) const normalized = normalizeCountryValue(country);
return [normalized, normalized] return [normalized, normalized];
}) }),
).values() ).values(),
) );
export const JobspySection: React.FC<JobspySectionProps> = ({ export const JobspySection: React.FC<JobspySectionProps> = ({
values, values,
@@ -139,8 +149,12 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
hoursOld, hoursOld,
countryIndeed, countryIndeed,
linkedinFetchDescription, linkedinFetchDescription,
} = values } = values;
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>() const {
control,
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="jobspy" className="border rounded-lg px-4"> <AccordionItem value="jobspy" className="border rounded-lg px-4">
@@ -159,22 +173,30 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
render={({ field }) => ( render={({ field }) => (
<Checkbox <Checkbox
id="site-indeed" id="site-indeed"
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')} checked={
field.value?.includes("indeed") ??
sites.default.includes("indeed")
}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const current = field.value ?? sites.default const current = field.value ?? sites.default;
let next = [...current] let next = [...current];
if (checked) { if (checked) {
if (!next.includes('indeed')) next.push('indeed') if (!next.includes("indeed")) next.push("indeed");
} else { } else {
next = next.filter(s => s !== 'indeed') next = next.filter((s) => s !== "indeed");
} }
field.onChange(next) field.onChange(next);
}} }}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
/> />
)} )}
/> />
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label> <label
htmlFor="site-indeed"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Indeed
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Controller <Controller
@@ -183,31 +205,45 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
render={({ field }) => ( render={({ field }) => (
<Checkbox <Checkbox
id="site-linkedin" id="site-linkedin"
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')} checked={
field.value?.includes("linkedin") ??
sites.default.includes("linkedin")
}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const current = field.value ?? sites.default const current = field.value ?? sites.default;
let next = [...current] let next = [...current];
if (checked) { if (checked) {
if (!next.includes('linkedin')) next.push('linkedin') if (!next.includes("linkedin")) next.push("linkedin");
} else { } else {
next = next.filter(s => s !== 'linkedin') next = next.filter((s) => s !== "linkedin");
} }
field.onChange(next) field.onChange(next);
}} }}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
/> />
)} )}
/> />
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label> <label
htmlFor="site-linkedin"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
LinkedIn
</label>
</div> </div>
</div> </div>
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>} {errors.jobspySites && (
<p className="text-xs text-destructive">
{errors.jobspySites.message}
</p>
)}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape. Select which sites JobSpy should scrape.
</div> </div>
<div className="flex gap-2 text-xs text-muted-foreground"> <div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span> <span>
<span>Default: {(sites.default || []).join(', ')}</span> Effective: {(sites.effective || []).join(", ") || "None"}
</span>
<span>Default: {(sites.default || []).join(", ")}</span>
</div> </div>
</div> </div>
@@ -218,7 +254,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
placeholder={location.default || "UK"} placeholder={location.default || "UK"}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.jobspyLocation?.message as string | undefined} error={errors.jobspyLocation?.message as string | undefined}
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'} helper={
'Location to search for jobs (e.g. "UK", "London", "Remote").'
}
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`} current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
/> />
@@ -236,16 +274,18 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
max: 1000, max: 1000,
value: field.value ?? resultsWanted.default, value: field.value ?? resultsWanted.default,
onChange: (event) => { onChange: (event) => {
const value = parseInt(event.target.value, 10) const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) { if (Number.isNaN(value)) {
field.onChange(null) field.onChange(null);
} else { } else {
field.onChange(Math.min(1000, Math.max(1, value))) field.onChange(Math.min(1000, Math.max(1, value)));
} }
}, },
}} }}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.jobspyResultsWanted?.message as string | undefined} error={
errors.jobspyResultsWanted?.message as string | undefined
}
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`} helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`} current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
/> />
@@ -266,11 +306,11 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
max: 720, max: 720,
value: field.value ?? hoursOld.default, value: field.value ?? hoursOld.default,
onChange: (event) => { onChange: (event) => {
const value = parseInt(event.target.value, 10) const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) { if (Number.isNaN(value)) {
field.onChange(null) field.onChange(null);
} else { } else {
field.onChange(Math.min(720, Math.max(1, value))) field.onChange(Math.min(720, Math.max(1, value)));
} }
}, },
}} }}
@@ -286,24 +326,33 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
name="jobspyCountryIndeed" name="jobspyCountryIndeed"
control={control} control={control}
render={({ field }) => { render={({ field }) => {
const currentValue = (field.value ?? countryIndeed.default ?? "").toLowerCase() const currentValue = (
const normalizedValue = normalizeCountryValue(currentValue) field.value ??
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(normalizedValue) countryIndeed.default ??
""
).toLowerCase();
const normalizedValue = normalizeCountryValue(currentValue);
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(
normalizedValue,
)
? normalizedValue ? normalizedValue
: "__default__" : "__default__";
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="jobspyCountryIndeed" className="text-sm font-medium"> <label
htmlFor="jobspyCountryIndeed"
className="text-sm font-medium"
>
Indeed Country Indeed Country
</label> </label>
<Select <Select
value={displayValue} value={displayValue}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "__default__") { if (value === "__default__") {
field.onChange(null) field.onChange(null);
} else { } else {
field.onChange(value) field.onChange(value);
} }
}} }}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
@@ -323,7 +372,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
</SelectContent> </SelectContent>
</Select> </Select>
{errors.jobspyCountryIndeed && ( {errors.jobspyCountryIndeed && (
<p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p> <p className="text-xs text-destructive">
{errors.jobspyCountryIndeed.message}
</p>
)} )}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Select one of JobSpy's supported Indeed country values. Select one of JobSpy's supported Indeed country values.
@@ -332,7 +383,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
{`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`} {`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
</div> </div>
</div> </div>
) );
}} }}
/> />
</div> </div>
@@ -360,16 +411,21 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
Fetch LinkedIn Description Fetch LinkedIn Description
</label> </label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data. If enabled, JobSpy will make extra requests to fetch full
descriptions. Slower but better data.
</p> </p>
<div className="flex gap-2 text-xs text-muted-foreground"> <div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span> <span>
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span> Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}
</span>
<span>
Default: {linkedinFetchDescription.default ? "Yes" : "No"}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,25 +1,37 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext } from "react-hook-form" import type { ModelValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type React from "react";
import { Separator } from "@/components/ui/separator" import { useFormContext } from "react-hook-form";
import { UpdateSettingsInput } from "@shared/settings-schema" import {
import type { ModelValues } from "@client/pages/settings/types" AccordionContent,
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type ModelSettingsSectionProps = { type ModelSettingsSectionProps = {
values: ModelValues values: ModelValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
values, values,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values const {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>() effective,
default: defaultModel,
scorer,
tailoring,
projectSelection,
} = values;
const {
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="model" className="border rounded-lg px-4"> <AccordionItem value="model" className="border rounded-lg px-4">
@@ -67,7 +79,9 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
inputProps={register("modelProjectSelection")} inputProps={register("modelProjectSelection")}
placeholder={effective || "inherit"} placeholder={effective || "inherit"}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.modelProjectSelection?.message as string | undefined} error={
errors.modelProjectSelection?.message as string | undefined
}
current={projectSelection || effective || "—"} current={projectSelection || effective || "—"}
/> />
</div> </div>
@@ -77,16 +91,22 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2"> <div className="grid gap-2 text-sm sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted-foreground">Global Effective</div> <div className="text-xs text-muted-foreground">
<div className="break-words font-mono text-xs">{effective || "—"}</div> Global Effective
</div>
<div className="break-words font-mono text-xs">
{effective || "—"}
</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Default (env)</div> <div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div> <div className="break-words font-mono text-xs">
{defaultModel || "—"}
</div>
</div> </div>
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,211 +1,296 @@
import React from "react" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { Controller, useFormContext } from "react-hook-form" import type { ResumeProjectCatalogItem } from "@shared/types";
import { AlertCircle, CheckCircle2 } from "lucide-react" import { AlertCircle, CheckCircle2 } from "lucide-react";
import type React from "react";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Controller, useFormContext } from "react-hook-form";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import {
import { Checkbox } from "@/components/ui/checkbox" AccordionContent,
import { Input } from "@/components/ui/input" AccordionItem,
import { Separator } from "@/components/ui/separator" AccordionTrigger,
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" } from "@/components/ui/accordion";
import { clampInt } from "@/lib/utils" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { ResumeProjectCatalogItem } from "@shared/types" import { Checkbox } from "@/components/ui/checkbox";
import { UpdateSettingsInput } from "@shared/settings-schema" import { Input } from "@/components/ui/input";
import { BaseResumeSelection } from "./BaseResumeSelection" import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { clampInt } from "@/lib/utils";
import { BaseResumeSelection } from "./BaseResumeSelection";
type ReactiveResumeSectionProps = { type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null rxResumeBaseResumeIdDraft: string | null;
setRxResumeBaseResumeIdDraft: (value: string | null) => void setRxResumeBaseResumeIdDraft: (value: string | null) => void;
// True when v4 credentials or v5 API key are configured. // True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean hasRxResumeAccess: boolean;
profileProjects: ResumeProjectCatalogItem[] profileProjects: ResumeProjectCatalogItem[];
lockedCount: number lockedCount: number;
maxProjectsTotal: number maxProjectsTotal: number;
isProjectsLoading: boolean isProjectsLoading: boolean;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft, rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft,
hasRxResumeAccess, hasRxResumeAccess,
profileProjects, profileProjects,
lockedCount, lockedCount,
maxProjectsTotal, maxProjectsTotal,
isProjectsLoading, isProjectsLoading,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>() const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4"> <AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4"> <AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span> <span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-4"> <AccordionContent className="pb-4">
<div className="space-y-4"> <div className="space-y-4">
{!hasRxResumeAccess ? ( {!hasRxResumeAccess ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle> <AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription> <AlertDescription>
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access. Configure RxResume credentials in settings (email + password) or
</AlertDescription> set <code>RXRESUME_API_KEY</code> to enable access.
</Alert> </AlertDescription>
) : ( </Alert>
<> ) : (
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20"> <>
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" /> <Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle> <CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-green-700 dark:text-green-400"> <AlertTitle className="text-green-800 dark:text-green-300">
Reactive Resume access is active. RxResume Access Ready
</AlertDescription> </AlertTitle>
</Alert> <AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
<BaseResumeSelection <BaseResumeSelection
value={rxResumeBaseResumeIdDraft} value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft} onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess} hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
/> />
<Separator /> <Separator />
<div className="space-y-4"> <div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? ( {!rxResumeBaseResumeIdDraft ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Choose a PDF to configure resume projects. Choose a PDF to configure resume projects.
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium">
Max projects to choose
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return;
const next = Number(event.target.value);
const clamped = clampInt(
next,
lockedCount,
maxProjectsTotal,
);
field.onChange({
...field.value,
maxProjects: clamped,
});
}}
disabled={
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
/>
)}
/>
{errors.resumeProjects?.maxProjects && (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message}
</p>
)}
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Project
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Visible in template
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Must Include
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
AI selectable
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(
field.value?.lockedProjectIds.includes(
project.id,
),
);
const aiSelectable = Boolean(
field.value?.aiSelectableProjectIds.includes(
project.id,
),
);
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">
{project.name || project.id}
</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date]
.filter(Boolean)
.join(" - ")}
</div>
</div> </div>
) : ( </TableCell>
<> <TableCell className="text-xs text-muted-foreground">
<div className="space-y-2"> {project.isVisibleInBase ? "Yes" : "No"}
<div className="text-sm font-medium"> </TableCell>
Max projects to choose <TableCell>
</div> <Checkbox
<Controller checked={locked}
name="resumeProjects" disabled={
control={control} isLoading ||
render={({ field }) => ( isSaving ||
<Input isProjectsLoading ||
type="number" !field.value
inputMode="numeric" }
min={lockedCount} onCheckedChange={(checked) => {
max={maxProjectsTotal} if (!field.value) return;
value={field.value?.maxProjects ?? 0} const isChecked = checked === true;
onChange={(event) => { const lockedIds =
if (!field.value) return field.value.lockedProjectIds.slice();
const next = Number(event.target.value) const selectableIds =
const clamped = clampInt(next, lockedCount, maxProjectsTotal) field.value.aiSelectableProjectIds.slice();
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message}
</p>
)}
</div>
<Controller if (isChecked) {
name="resumeProjects" if (!lockedIds.includes(project.id))
control={control} lockedIds.push(project.id);
render={({ field }) => ( const nextSelectable =
<Table> selectableIds.filter(
<TableHeader> (id) => id !== project.id,
<TableRow> );
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Project</TableHead> const minCap = lockedIds.length;
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Visible in template</TableHead> field.onChange({
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Must Include</TableHead> ...field.value,
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">AI selectable</TableHead> lockedProjectIds: lockedIds,
</TableRow> aiSelectableProjectIds:
</TableHeader> nextSelectable,
maxProjects: Math.max(
field.value.maxProjects,
minCap,
),
});
return;
}
<TableBody> const nextLocked = lockedIds.filter(
{profileProjects.map((project) => { (id) => id !== project.id,
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id)) );
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id)) if (!selectableIds.includes(project.id))
selectableIds.push(project.id);
return ( field.onChange({
<TableRow key={project.id}> ...field.value,
<TableCell> lockedProjectIds: nextLocked,
<div className="space-y-0.5"> aiSelectableProjectIds: selectableIds,
<div className="font-medium">{project.name || project.id}</div> maxProjects: clampInt(
<div className="text-xs text-muted-foreground"> field.value.maxProjects,
{[project.description, project.date].filter(Boolean).join(" - ")} nextLocked.length,
</div> maxProjectsTotal,
</div> ),
</TableCell> });
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell> }}
<TableCell> />
<Checkbox </TableCell>
checked={locked} <TableCell>
disabled={isLoading || isSaving || isProjectsLoading || !field.value} <Checkbox
onCheckedChange={(checked) => { checked={locked ? true : aiSelectable}
if (!field.value) return disabled={
const isChecked = checked === true locked ||
const lockedIds = field.value.lockedProjectIds.slice() isLoading ||
const selectableIds = field.value.aiSelectableProjectIds.slice() isSaving ||
isProjectsLoading ||
if (isChecked) { !field.value
if (!lockedIds.includes(project.id)) lockedIds.push(project.id) }
const nextSelectable = selectableIds.filter((id) => id !== project.id) onCheckedChange={(checked) => {
const minCap = lockedIds.length if (!field.value) return;
field.onChange({ const isChecked = checked === true;
...field.value, const selectableIds =
lockedProjectIds: lockedIds, field.value.aiSelectableProjectIds.slice();
aiSelectableProjectIds: nextSelectable, const nextSelectable = isChecked
maxProjects: Math.max(field.value.maxProjects, minCap), ? selectableIds.includes(project.id)
}) ? selectableIds
return : [...selectableIds, project.id]
} : selectableIds.filter(
(id) => id !== project.id,
const nextLocked = lockedIds.filter((id) => id !== project.id) );
if (!selectableIds.includes(project.id)) selectableIds.push(project.id) field.onChange({
field.onChange({ ...field.value,
...field.value, aiSelectableProjectIds:
lockedProjectIds: nextLocked, nextSelectable,
aiSelectableProjectIds: selectableIds, });
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal), }}
}) />
}} </TableCell>
/> </TableRow>
</TableCell> );
<TableCell> })}
<Checkbox </TableBody>
checked={locked ? true : aiSelectable} </Table>
disabled={locked || isLoading || isSaving || isProjectsLoading || !field.value} )}
onCheckedChange={(checked) => { />
if (!field.value) return </>
const isChecked = checked === true )}
const selectableIds = field.value.aiSelectableProjectIds.slice() </div>
const nextSelectable = isChecked </>
? selectableIds.includes(project.id) )}
? selectableIds </div>
: [...selectableIds, project.id] </AccordionContent>
: selectableIds.filter((id) => id !== project.id) </AccordionItem>
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable }) );
}} };
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,24 +1,31 @@
import React from "react" import type { SearchTermsValues } from "@client/pages/settings/types";
import { useFormContext, Controller } from "react-hook-form" import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Controller, useFormContext } from "react-hook-form";
import { Separator } from "@/components/ui/separator" import {
import { UpdateSettingsInput } from "@shared/settings-schema" AccordionContent,
import type { SearchTermsValues } from "@client/pages/settings/types" AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type SearchTermsSectionProps = { type SearchTermsSectionProps = {
values: SearchTermsValues values: SearchTermsValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
values, values,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values const { default: defaultSearchTerms, effective: effectiveSearchTerms } =
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>() values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="search-terms" className="border rounded-lg px-4"> <AccordionItem value="search-terms" className="border rounded-lg px-4">
@@ -35,15 +42,21 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
render={({ field }) => ( render={({ field }) => (
<textarea <textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')} value={
field.value
? field.value.join("\n")
: (defaultSearchTerms ?? []).join("\n")
}
onChange={(event) => { onChange={(event) => {
const text = event.target.value const text = event.target.value;
const terms = text.split('\n') const terms = text.split("\n");
field.onChange(terms) field.onChange(terms);
}} }}
onBlur={() => { onBlur={() => {
if (field.value) { if (field.value) {
field.onChange(field.value.map(t => t.trim()).filter(Boolean)) field.onChange(
field.value.map((t) => t.trim()).filter(Boolean),
);
} }
}} }}
placeholder="e.g. web developer" placeholder="e.g. web developer"
@@ -52,9 +65,14 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
/> />
)} )}
/> />
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>} {errors.searchTerms && (
<p className="text-xs text-destructive">
{errors.searchTerms.message}
</p>
)}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors. One term per line. Applies to UKVisaJobs and other supported
extractors.
</div> </div>
</div> </div>
@@ -63,15 +81,19 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2"> <div className="grid gap-2 text-sm sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted-foreground">Effective</div> <div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{(effectiveSearchTerms || []).join(', ') || "—"}</div> <div className="break-words font-mono text-xs">
{(effectiveSearchTerms || []).join(", ") || "—"}
</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Default (env)</div> <div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div> <div className="break-words font-mono text-xs">
{(defaultSearchTerms || []).join(", ") || "—"}
</div>
</div> </div>
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,17 +1,17 @@
import React from "react" import type React from "react";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
type SettingsInputProps = { type SettingsInputProps = {
label: string label: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement> inputProps: React.InputHTMLAttributes<HTMLInputElement>;
placeholder?: string placeholder?: string;
type?: React.HTMLInputTypeAttribute type?: React.HTMLInputTypeAttribute;
disabled?: boolean disabled?: boolean;
error?: string error?: string;
helper?: string helper?: string;
current?: string | null current?: string | null;
} };
export const SettingsInput: React.FC<SettingsInputProps> = ({ export const SettingsInput: React.FC<SettingsInputProps> = ({
label, label,
@@ -23,7 +23,7 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
helper, helper,
current, current,
}) => { }) => {
const id = inputProps.id || inputProps.name const id = inputProps.id || inputProps.name;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -32,7 +32,13 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
{label} {label}
</label> </label>
)} )}
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} /> <Input
{...inputProps}
id={id}
type={type}
placeholder={placeholder}
disabled={disabled}
/>
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
{helper && <div className="text-xs text-muted-foreground">{helper}</div>} {helper && <div className="text-xs text-muted-foreground">{helper}</div>}
{current !== undefined && ( {current !== undefined && (
@@ -41,5 +47,5 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
</div> </div>
)} )}
</div> </div>
) );
} };
@@ -1,24 +1,33 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext, Controller } from "react-hook-form" import type { NumericSettingValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type React from "react";
import { UpdateSettingsInput } from "@shared/settings-schema" import { Controller, useFormContext } from "react-hook-form";
import type { NumericSettingValues } from "@client/pages/settings/types" import {
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
type UkvisajobsSectionProps = { type UkvisajobsSectionProps = {
values: NumericSettingValues values: NumericSettingValues;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
values, values,
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values const {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>() effective: effectiveUkvisajobsMaxJobs,
default: defaultUkvisajobsMaxJobs,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4"> <AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
@@ -41,11 +50,11 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
max: 1000, max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs, value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => { onChange: (event) => {
const value = parseInt(event.target.value, 10) const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) { if (Number.isNaN(value)) {
field.onChange(null) field.onChange(null);
} else { } else {
field.onChange(Math.min(1000, Math.max(1, value))) field.onChange(Math.min(1000, Math.max(1, value)));
} }
}, },
}} }}
@@ -59,5 +68,5 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { useForm, FormProvider } from "react-hook-form" import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { Accordion } from "@/components/ui/accordion" import { Accordion } from "@/components/ui/accordion";
import { WebhooksSection } from "./WebhooksSection" import { WebhooksSection } from "./WebhooksSection";
import { UpdateSettingsInput } from "@shared/settings-schema"
const WebhooksHarness = () => { const WebhooksHarness = () => {
const methods = useForm<UpdateSettingsInput>({ const methods = useForm<UpdateSettingsInput>({
@@ -11,8 +10,8 @@ const WebhooksHarness = () => {
pipelineWebhookUrl: "https://pipeline.com", pipelineWebhookUrl: "https://pipeline.com",
jobCompleteWebhookUrl: "https://job.com", jobCompleteWebhookUrl: "https://job.com",
webhookSecret: "", webhookSecret: "",
} },
}) });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@@ -32,19 +31,21 @@ const WebhooksHarness = () => {
/> />
</Accordion> </Accordion>
</FormProvider> </FormProvider>
) );
} };
describe("WebhooksSection", () => { describe("WebhooksSection", () => {
it("renders both webhook sections and the secret", () => { it("renders both webhook sections and the secret", () => {
render(<WebhooksHarness />) render(<WebhooksHarness />);
expect(screen.getByText("Pipeline Status")).toBeInTheDocument() expect(screen.getByText("Pipeline Status")).toBeInTheDocument();
expect(screen.getByText("Job Completion")).toBeInTheDocument() expect(screen.getByText("Job Completion")).toBeInTheDocument();
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument() expect(
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument() screen.getByDisplayValue("https://pipeline.com"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument();
expect(screen.getByText("sec-********")).toBeInTheDocument() expect(screen.getByText("sec-********")).toBeInTheDocument();
}) });
}) });
@@ -1,20 +1,23 @@
import React from "react" import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { useFormContext } from "react-hook-form" import type { WebhookValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import type { UpdateSettingsInput } from "@shared/settings-schema";
import { Separator } from "@/components/ui/separator" import type React from "react";
import { UpdateSettingsInput } from "@shared/settings-schema" import { useFormContext } from "react-hook-form";
import type { WebhookValues } from "@client/pages/settings/types" import {
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" AccordionContent,
import { formatSecretHint } from "@client/pages/settings/utils" AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type WebhooksSectionProps = { type WebhooksSectionProps = {
pipelineWebhook: WebhookValues pipelineWebhook: WebhookValues;
jobCompleteWebhook: WebhookValues jobCompleteWebhook: WebhookValues;
webhookSecretHint: string | null webhookSecretHint: string | null;
isLoading: boolean isLoading: boolean;
isSaving: boolean isSaving: boolean;
} };
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
pipelineWebhook, pipelineWebhook,
@@ -23,7 +26,10 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>() const {
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return ( return (
<AccordionItem value="webhooks" className="border rounded-lg px-4"> <AccordionItem value="webhooks" className="border rounded-lg px-4">
@@ -55,7 +61,9 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
inputProps={register("jobCompleteWebhookUrl")} inputProps={register("jobCompleteWebhookUrl")}
placeholder={jobCompleteWebhook.default || "https://..."} placeholder={jobCompleteWebhook.default || "https://..."}
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
error={errors.jobCompleteWebhookUrl?.message as string | undefined} error={
errors.jobCompleteWebhookUrl?.message as string | undefined
}
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`} helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
current={jobCompleteWebhook.effective || "—"} current={jobCompleteWebhook.effective || "—"}
/> />
@@ -75,5 +83,5 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
) );
} };
@@ -2,17 +2,24 @@
* Settings page constants. * Settings page constants.
*/ */
import type { JobStatus } from "@shared/types" import type { JobStatus } from "@shared/types";
/** All available job statuses for clearing */ /** All available job statuses for clearing */
export const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired'] export const ALL_JOB_STATUSES: JobStatus[] = [
"discovered",
"processing",
"ready",
"applied",
"skipped",
"expired",
];
/** Status descriptions for UI */ /** Status descriptions for UI */
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = { export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed', discovered: "Crawled but not processed",
processing: 'Currently generating resume', processing: "Currently generating resume",
ready: 'PDF generated, waiting for user to apply', ready: "PDF generated, waiting for user to apply",
applied: 'User marked as applied', applied: "User marked as applied",
skipped: 'User skipped this job', skipped: "User skipped this job",
expired: 'Deadline passed', expired: "Deadline passed",
} };
+30 -30
View File
@@ -1,40 +1,40 @@
export type EffectiveDefault<T> = { export type EffectiveDefault<T> = {
effective: T effective: T;
default: T default: T;
} };
export type ModelValues = EffectiveDefault<string> & { export type ModelValues = EffectiveDefault<string> & {
scorer: string scorer: string;
tailoring: string tailoring: string;
projectSelection: string projectSelection: string;
} };
export type WebhookValues = EffectiveDefault<string> export type WebhookValues = EffectiveDefault<string>;
export type NumericSettingValues = EffectiveDefault<number> export type NumericSettingValues = EffectiveDefault<number>;
export type SearchTermsValues = EffectiveDefault<string[]> export type SearchTermsValues = EffectiveDefault<string[]>;
export type DisplayValues = EffectiveDefault<boolean> export type DisplayValues = EffectiveDefault<boolean>;
export type JobspyValues = { export type JobspyValues = {
sites: EffectiveDefault<string[]> sites: EffectiveDefault<string[]>;
location: EffectiveDefault<string> location: EffectiveDefault<string>;
resultsWanted: EffectiveDefault<number> resultsWanted: EffectiveDefault<number>;
hoursOld: EffectiveDefault<number> hoursOld: EffectiveDefault<number>;
countryIndeed: EffectiveDefault<string> countryIndeed: EffectiveDefault<string>;
linkedinFetchDescription: EffectiveDefault<boolean> linkedinFetchDescription: EffectiveDefault<boolean>;
} };
export type EnvSettingsValues = { export type EnvSettingsValues = {
readable: { readable: {
rxresumeEmail: string rxresumeEmail: string;
ukvisajobsEmail: string ukvisajobsEmail: string;
basicAuthUser: string basicAuthUser: string;
} };
private: { private: {
openrouterApiKeyHint: string | null openrouterApiKeyHint: string | null;
rxresumePasswordHint: string | null rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null ukvisajobsPasswordHint: string | null;
basicAuthPasswordHint: string | null basicAuthPasswordHint: string | null;
webhookSecretHint: string | null webhookSecretHint: string | null;
} };
basicAuthActive: boolean basicAuthActive: boolean;
} };
@@ -2,15 +2,19 @@
* Settings page helpers. * Settings page helpers.
*/ */
import { arraysEqual } from "@/lib/utils" import type { ResumeProjectsSettings } from "@shared/types";
import type { ResumeProjectsSettings } from "@shared/types" import { arraysEqual } from "@/lib/utils";
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) { export function resumeProjectsEqual(
a: ResumeProjectsSettings,
b: ResumeProjectsSettings,
) {
return ( return (
a.maxProjects === b.maxProjects && a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) && arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
) );
} }
export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set") export const formatSecretHint = (hint: string | null) =>
hint ? `${hint}********` : "Not set";
+14 -14
View File
@@ -1,12 +1,12 @@
"use client" "use client";
import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion";
import * as AccordionPrimitive from "@radix-ui/react-accordion" import { ChevronDown } from "lucide-react";
import { ChevronDown } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef< const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>, React.ElementRef<typeof AccordionPrimitive.Item>,
@@ -17,8 +17,8 @@ const AccordionItem = React.forwardRef<
className={cn("border-b", className)} className={cn("border-b", className)}
{...props} {...props}
/> />
)) ));
AccordionItem.displayName = "AccordionItem" AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef< const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>, React.ElementRef<typeof AccordionPrimitive.Trigger>,
@@ -29,7 +29,7 @@ const AccordionTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className className,
)} )}
{...props} {...props}
> >
@@ -37,8 +37,8 @@ const AccordionTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
)) ));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef< const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>, React.ElementRef<typeof AccordionPrimitive.Content>,
@@ -51,8 +51,8 @@ const AccordionContent = React.forwardRef<
> >
<div className={cn("pb-4 pt-0", className)}>{children}</div> <div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
)) ));
AccordionContent.displayName = AccordionPrimitive.Content.displayName AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+29 -30
View File
@@ -1,14 +1,13 @@
import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as React from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils" const AlertDialog = AlertDialogPrimitive.Root;
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef< const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@@ -17,13 +16,13 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
@@ -35,13 +34,13 @@ const AlertDialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
)) ));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ const AlertDialogHeader = ({
className, className,
@@ -50,12 +49,12 @@ const AlertDialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
AlertDialogHeader.displayName = "AlertDialogHeader" AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ const AlertDialogFooter = ({
className, className,
@@ -64,12 +63,12 @@ const AlertDialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
AlertDialogFooter.displayName = "AlertDialogFooter" AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef< const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ElementRef<typeof AlertDialogPrimitive.Title>,
@@ -80,8 +79,8 @@ const AlertDialogTitle = React.forwardRef<
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
)) ));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef< const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ElementRef<typeof AlertDialogPrimitive.Description>,
@@ -92,9 +91,9 @@ const AlertDialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
AlertDialogDescription.displayName = AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef< const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ElementRef<typeof AlertDialogPrimitive.Action>,
@@ -105,8 +104,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
)) ));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef< const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@@ -117,12 +116,12 @@ const AlertDialogCancel = React.forwardRef<
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0", "mt-2 sm:mt-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { export {
AlertDialog, AlertDialog,
@@ -136,4 +135,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+46 -46
View File
@@ -1,59 +1,59 @@
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => ( >(({ className, variant, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
role="alert" role="alert"
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
)) ));
Alert.displayName = "Alert" Alert.displayName = "Alert";
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement> React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} {...props}
/> />
)) ));
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+14 -15
View File
@@ -1,8 +1,8 @@
import * as React from "react" import { Slot } from "@radix-ui/react-slot";
import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -11,8 +11,7 @@ const buttonVariants = cva(
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90", "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: destructive: "bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
"bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
outline: outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
@@ -31,27 +30,27 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };
+23 -16
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border bg-card text-card-foreground shadow", "rounded-xl border bg-card text-card-foreground shadow",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = "Card";
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+11 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { Check } from "lucide-react";
import { Check } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -12,16 +12,17 @@ const Checkbox = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className className,
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}> <CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)) ));
Checkbox.displayName = "Checkbox" Checkbox.displayName = "Checkbox";
export { Checkbox }
export { Checkbox };
+23 -23
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Drawer = ({ const Drawer = ({
shouldScaleBackground = true, shouldScaleBackground = true,
@@ -11,14 +11,14 @@ const Drawer = ({
shouldScaleBackground={shouldScaleBackground} shouldScaleBackground={shouldScaleBackground}
{...props} {...props}
/> />
) );
Drawer.displayName = "Drawer" Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef< const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ElementRef<typeof DrawerPrimitive.Overlay>,
@@ -29,8 +29,8 @@ const DrawerOverlay = React.forwardRef<
className={cn("fixed inset-0 z-50 bg-black/80", className)} className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} {...props}
/> />
)) ));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef< const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>, React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -42,7 +42,7 @@ const DrawerContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className className,
)} )}
{...props} {...props}
> >
@@ -50,8 +50,8 @@ const DrawerContent = React.forwardRef<
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
)) ));
DrawerContent.displayName = "DrawerContent" DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ const DrawerHeader = ({
className, className,
@@ -61,8 +61,8 @@ const DrawerHeader = ({
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
DrawerHeader.displayName = "DrawerHeader" DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ const DrawerFooter = ({
className, className,
@@ -72,8 +72,8 @@ const DrawerFooter = ({
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
DrawerFooter.displayName = "DrawerFooter" DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef< const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>, React.ElementRef<typeof DrawerPrimitive.Title>,
@@ -83,12 +83,12 @@ const DrawerTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef< const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>, React.ElementRef<typeof DrawerPrimitive.Description>,
@@ -99,8 +99,8 @@ const DrawerDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export { export {
Drawer, Drawer,
@@ -113,4 +113,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };
@@ -1,25 +1,25 @@
import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight, Circle } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -27,15 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -45,12 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -62,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -81,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -96,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -108,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -120,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -131,22 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -157,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@@ -169,9 +175,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
@@ -189,5 +195,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };
+36 -37
View File
@@ -1,11 +1,10 @@
"use client" "use client";
import { useMemo } from "react" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils" import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
@@ -14,11 +13,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
@@ -34,11 +33,11 @@ function FieldLegend({
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -47,11 +46,11 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva(
@@ -75,8 +74,8 @@ const fieldVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} },
) );
function Field({ function Field({
className, className,
@@ -91,7 +90,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -100,11 +99,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({
@@ -118,11 +117,11 @@ function FieldLabel({
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -131,11 +130,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -146,11 +145,11 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5", "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
@@ -158,7 +157,7 @@ function FieldSeparator({
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
@@ -166,7 +165,7 @@ function FieldSeparator({
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className,
)} )}
{...props} {...props}
> >
@@ -180,7 +179,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@@ -189,33 +188,33 @@ function FieldError({
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors) { if (!errors) {
return null return null;
} }
if (errors?.length === 1 && errors[0]?.message) { if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message return errors[0].message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map( {errors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
@@ -227,7 +226,7 @@ function FieldError({
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@@ -241,4 +240,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };
+8 -9
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@@ -9,15 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = "Input";
export { Input }
export { Input };
+10 -10
View File
@@ -1,14 +1,14 @@
"use client" "use client";
import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label";
import * as LabelPrimitive from "@radix-ui/react-label" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
) );
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
@@ -20,7 +20,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />
)) ));
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName;
export { Label } export { Label };
+8 -8
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
@@ -13,7 +13,7 @@ const Progress = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20", "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className className,
)} )}
{...props} {...props}
> >
@@ -22,7 +22,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)) ));
Progress.displayName = ProgressPrimitive.Root.displayName Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress } export { Progress };
+12 -12
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { Circle } from "lucide-react";
import { Circle } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef< const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>, React.ElementRef<typeof RadioGroupPrimitive.Root>,
@@ -14,9 +14,9 @@ const RadioGroup = React.forwardRef<
{...props} {...props}
ref={ref} ref={ref}
/> />
) );
}) });
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef< const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>, React.ElementRef<typeof RadioGroupPrimitive.Item>,
@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
<Circle className="h-3.5 w-3.5 fill-primary" /> <Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) );
}) });
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem };
+130 -130
View File
@@ -1,159 +1,159 @@
"use client" "use client";
import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select";
import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton > </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => ( >(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)} className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
SelectGroup, SelectGroup,
SelectValue, SelectValue,
SelectTrigger, SelectTrigger,
SelectContent, SelectContent,
SelectLabel, SelectLabel,
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} };
+9 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
>( >(
( (
{ className, orientation = "horizontal", decorative = true, ...props }, { className, orientation = "horizontal", decorative = true, ...props },
ref ref,
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}
@@ -18,12 +18,12 @@ const Separator = React.forwardRef<
className={cn( className={cn(
"shrink-0 bg-border", "shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className,
)} )}
{...props} {...props}
/> />
) ),
) );
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator } export { Separator };
+27 -27
View File
@@ -1,17 +1,17 @@
import * as React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog";
import * as SheetPrimitive from "@radix-ui/react-dialog" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -20,13 +20,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
@@ -44,8 +44,8 @@ const sheetVariants = cva(
defaultVariants: { defaultVariants: {
side: "right", side: "right",
}, },
} },
) );
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@@ -69,8 +69,8 @@ const SheetContent = React.forwardRef<
{children} {children}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ));
SheetContent.displayName = SheetPrimitive.Content.displayName SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ const SheetHeader = ({
className, className,
@@ -79,12 +79,12 @@ const SheetHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetHeader.displayName = "SheetHeader" SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ const SheetFooter = ({
className, className,
@@ -93,12 +93,12 @@ const SheetFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetFooter.displayName = "SheetFooter" SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@@ -109,8 +109,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)} className={cn("text-lg font-semibold text-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetTitle.displayName = SheetPrimitive.Title.displayName SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@@ -121,8 +121,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetDescription.displayName = SheetPrimitive.Description.displayName SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { export {
Sheet, Sheet,
@@ -135,4 +135,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+5 -5
View File
@@ -1,6 +1,6 @@
import { Toaster as Sonner } from "sonner" import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner> type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
return ( return (
@@ -20,7 +20,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}} }}
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };
+51 -44
View File
@@ -1,27 +1,28 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>( const Table = React.forwardRef<
({ className, ...props }, ref) => ( HTMLTableElement,
<div className="w-full overflow-auto"> React.HTMLAttributes<HTMLTableElement>
<table >(({ className, ...props }, ref) => (
ref={ref} <div className="w-full overflow-auto">
className={cn("w-full caption-bottom text-sm", className)} <table
{...props} ref={ref}
/> className={cn("w-full caption-bottom text-sm", className)}
</div> {...props}
) />
) </div>
Table.displayName = "Table" ));
Table.displayName = "Table";
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)) ));
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@@ -32,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
)) ));
TableBody.displayName = "TableBody" TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@@ -41,25 +42,29 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props} {...props}
/> />
)) ));
TableFooter.displayName = "TableFooter" TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>( const TableRow = React.forwardRef<
({ className, ...props }, ref) => ( HTMLTableRowElement,
<tr React.HTMLAttributes<HTMLTableRowElement>
ref={ref} >(({ className, ...props }, ref) => (
className={cn( <tr
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", ref={ref}
className className={cn(
)} "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
{...props} className,
/> )}
) {...props}
) />
TableRow.displayName = "TableRow" ));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -69,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = "TableHead" TableHead.displayName = "TableHead";
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -82,11 +87,14 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)} className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props} {...props}
/> />
)) ));
TableCell.displayName = "TableCell" TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
@@ -97,8 +105,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
TableCaption.displayName = "TableCaption" TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
@@ -109,5 +117,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };
+14 -14
View File
@@ -1,9 +1,9 @@
import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -28,12 +28,12 @@ const TabsTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TabsContent.displayName = TabsPrimitive.Content.displayName TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };
+7 -7
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Textarea = React.forwardRef< const Textarea = React.forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
@@ -10,13 +10,13 @@ const Textarea = React.forwardRef<
<textarea <textarea
className={cn( className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
}) });
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea";
export { Textarea } export { Textarea };
+23 -23
View File
@@ -1,32 +1,32 @@
"use client" "use client";
import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => ( >(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
)) ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+12 -7
View File
@@ -1,17 +1,19 @@
import { clsx, type ClassValue } from "clsx" import type { Job } from "@shared/types";
import { twMerge } from "tailwind-merge" import { type ClassValue, clsx } from "clsx";
import type { Job } from "@shared/types" import { twMerge } from "tailwind-merge";
// --- CSS --- // --- CSS ---
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
// --- Dates --- // --- Dates ---
export const formatDate = (dateStr?: string | null) => { export const formatDate = (dateStr?: string | null) => {
if (!dateStr) return null; if (!dateStr) return null;
try { try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); const normalized = dateStr.includes("T")
? dateStr
: dateStr.replace(" ", "T");
const parsed = new Date(normalized); const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr; if (Number.isNaN(parsed.getTime())) return dateStr;
return parsed.toLocaleDateString("en-GB", { return parsed.toLocaleDateString("en-GB", {
@@ -27,7 +29,9 @@ export const formatDate = (dateStr?: string | null) => {
export const formatDateTime = (dateStr?: string | null) => { export const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null; if (!dateStr) return null;
try { try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); const normalized = dateStr.includes("T")
? dateStr
: dateStr.replace(" ", "T");
const parsed = new Date(normalized); const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr; if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", { const date = parsed.toLocaleDateString("en-GB", {
@@ -79,7 +83,8 @@ export const stripHtml = (value: string) =>
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); export const safeFilenamePart = (value: string) =>
value.replace(/[^a-z0-9]/gi, "_");
// --- Comparisons & Math --- // --- Comparisons & Math ---
export function arraysEqual(a: string[], b: string[]) { export function arraysEqual(a: string[], b: string[]) {
+1 -1
View File
@@ -1 +1 @@
export { apiRouter } from './routes.js'; export { apiRouter } from "./routes.js";
+21 -21
View File
@@ -2,27 +2,27 @@
* API routes for the orchestrator. * API routes for the orchestrator.
*/ */
import { Router } from 'express'; import { Router } from "express";
import { jobsRouter } from './routes/jobs.js'; import { databaseRouter } from "./routes/database.js";
import { settingsRouter } from './routes/settings.js'; import { jobsRouter } from "./routes/jobs.js";
import { pipelineRouter } from './routes/pipeline.js'; import { manualJobsRouter } from "./routes/manual-jobs.js";
import { manualJobsRouter } from './routes/manual-jobs.js'; import { onboardingRouter } from "./routes/onboarding.js";
import { ukVisaJobsRouter } from './routes/ukvisajobs.js'; import { pipelineRouter } from "./routes/pipeline.js";
import { webhookRouter } from './routes/webhook.js'; import { profileRouter } from "./routes/profile.js";
import { profileRouter } from './routes/profile.js'; import { settingsRouter } from "./routes/settings.js";
import { databaseRouter } from './routes/database.js'; import { ukVisaJobsRouter } from "./routes/ukvisajobs.js";
import { visaSponsorsRouter } from './routes/visa-sponsors.js'; import { visaSponsorsRouter } from "./routes/visa-sponsors.js";
import { onboardingRouter } from './routes/onboarding.js'; import { webhookRouter } from "./routes/webhook.js";
export const apiRouter = Router(); export const apiRouter = Router();
apiRouter.use('/jobs', jobsRouter); apiRouter.use("/jobs", jobsRouter);
apiRouter.use('/settings', settingsRouter); apiRouter.use("/settings", settingsRouter);
apiRouter.use('/pipeline', pipelineRouter); apiRouter.use("/pipeline", pipelineRouter);
apiRouter.use('/manual-jobs', manualJobsRouter); apiRouter.use("/manual-jobs", manualJobsRouter);
apiRouter.use('/ukvisajobs', ukVisaJobsRouter); apiRouter.use("/ukvisajobs", ukVisaJobsRouter);
apiRouter.use('/webhook', webhookRouter); apiRouter.use("/webhook", webhookRouter);
apiRouter.use('/profile', profileRouter); apiRouter.use("/profile", profileRouter);
apiRouter.use('/database', databaseRouter); apiRouter.use("/database", databaseRouter);
apiRouter.use('/visa-sponsors', visaSponsorsRouter); apiRouter.use("/visa-sponsors", visaSponsorsRouter);
apiRouter.use('/onboarding', onboardingRouter); apiRouter.use("/onboarding", onboardingRouter);

Some files were not shown because too many files have changed in this diff Show More