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
biome.json Normal file
View File

@ -0,0 +1,7 @@
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}

View File

@ -90,4 +90,4 @@
"vite": "^6.0.3",
"vitest": "^4.0.16"
}
}
}

View File

@ -7,11 +7,11 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate";
import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
import { OnboardingGate } from "./components/OnboardingGate";
export const App: React.FC = () => {
const location = useLocation();

View File

@ -2,40 +2,40 @@
* API client for the orchestrator backend.
*/
import { trackEvent } from "@/lib/analytics";
import type {
Job,
ApiResponse,
JobsListResponse,
PipelineStatusResponse,
JobSource,
AppSettings,
ResumeProjectsSettings,
ResumeProjectCatalogItem,
UkVisaJobsSearchResponse,
UkVisaJobsImportResponse,
CreateJobInput,
Job,
JobSource,
JobsListResponse,
ManualJobDraft,
ManualJobInferenceResponse,
ManualJobFetchResponse,
ManualJobInferenceResponse,
PipelineStatusResponse,
ProfileStatusResponse,
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
UkVisaJobsImportResponse,
UkVisaJobsSearchResponse,
ValidationResult,
VisaSponsor,
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
VisaSponsor,
ResumeProfile,
ProfileStatusResponse,
ValidationResult,
} from '../../shared/types';
import { trackEvent } from "@/lib/analytics";
} from "../../shared/types";
const API_BASE = '/api';
const API_BASE = "/api";
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
options?: RequestInit,
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options?.headers,
},
});
@ -47,12 +47,14 @@ async function fetchApi<T>(
data = JSON.parse(text);
} catch {
// If the response is not JSON, it's likely an HTML error page
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?`);
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?`,
);
}
if (!data.success) {
throw new Error(data.error || 'API request failed');
throw new Error(data.error || "API request failed");
}
return data.data as T;
@ -60,7 +62,7 @@ async function fetchApi<T>(
// Jobs API
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}`);
}
@ -70,61 +72,67 @@ export async function getJob(id: string): Promise<Job> {
export async function updateJob(
id: string,
update: Partial<Job>
update: Partial<Job>,
): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`, {
method: 'PATCH',
method: "PATCH",
body: JSON.stringify(update),
});
}
export async function processJob(id: string, options?: { force?: boolean }): Promise<Job> {
const query = options?.force ? '?force=1' : '';
export async function processJob(
id: string,
options?: { force?: boolean },
): Promise<Job> {
const query = options?.force ? "?force=1" : "";
return fetchApi<Job>(`/jobs/${id}/process${query}`, {
method: 'POST',
method: "POST",
});
}
export async function rescoreJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/rescore`, {
method: 'POST',
method: "POST",
});
}
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
const query = options?.force ? '?force=1' : '';
export async function summarizeJob(
id: string,
options?: { force?: boolean },
): Promise<Job> {
const query = options?.force ? "?force=1" : "";
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
method: 'POST',
method: "POST",
});
}
export async function generateJobPdf(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/generate-pdf`, {
method: 'POST',
method: "POST",
});
}
export async function checkSponsor(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
method: 'POST',
method: "POST",
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
method: "POST",
});
}
export async function skipJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/skip`, {
method: 'POST',
method: "POST",
});
}
// Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>('/pipeline/status');
return fetchApi<PipelineStatusResponse>("/pipeline/status");
}
export async function runPipeline(config?: {
@ -132,8 +140,8 @@ export async function runPipeline(config?: {
minSuitabilityScore?: number;
sources?: JobSource[];
}): Promise<{ message: string }> {
return fetchApi<{ message: string }>('/pipeline/run', {
method: 'POST',
return fetchApi<{ message: string }>("/pipeline/run", {
method: "POST",
body: JSON.stringify(config || {}),
});
}
@ -144,13 +152,13 @@ export async function searchUkVisaJobs(input: {
page?: number;
}): Promise<UkVisaJobsSearchResponse> {
if (input.searchTerm?.trim()) {
trackEvent('ukvisajobs_search', {
trackEvent("ukvisajobs_search", {
searchTerm: input.searchTerm.trim(),
page: input.page ?? 1,
});
}
return fetchApi<UkVisaJobsSearchResponse>('/ukvisajobs/search', {
method: 'POST',
return fetchApi<UkVisaJobsSearchResponse>("/ukvisajobs/search", {
method: "POST",
body: JSON.stringify(input),
});
}
@ -158,8 +166,8 @@ export async function searchUkVisaJobs(input: {
export async function importUkVisaJobs(input: {
jobs: CreateJobInput[];
}): Promise<UkVisaJobsImportResponse> {
return fetchApi<UkVisaJobsImportResponse>('/ukvisajobs/import', {
method: 'POST',
return fetchApi<UkVisaJobsImportResponse>("/ukvisajobs/import", {
method: "POST",
body: JSON.stringify(input),
});
}
@ -168,8 +176,8 @@ export async function importUkVisaJobs(input: {
export async function fetchJobFromUrl(input: {
url: string;
}): Promise<ManualJobFetchResponse> {
return fetchApi<ManualJobFetchResponse>('/manual-jobs/fetch', {
method: 'POST',
return fetchApi<ManualJobFetchResponse>("/manual-jobs/fetch", {
method: "POST",
body: JSON.stringify(input),
});
}
@ -177,8 +185,8 @@ export async function fetchJobFromUrl(input: {
export async function inferManualJob(input: {
jobDescription: string;
}): Promise<ManualJobInferenceResponse> {
return fetchApi<ManualJobInferenceResponse>('/manual-jobs/infer', {
method: 'POST',
return fetchApi<ManualJobInferenceResponse>("/manual-jobs/infer", {
method: "POST",
body: JSON.stringify(input),
});
}
@ -186,8 +194,8 @@ export async function inferManualJob(input: {
export async function importManualJob(input: {
job: ManualJobDraft;
}): Promise<Job> {
return fetchApi<Job>('/manual-jobs/import', {
method: 'POST',
return fetchApi<Job>("/manual-jobs/import", {
method: "POST",
body: JSON.stringify(input),
});
}
@ -197,23 +205,27 @@ let settingsPromise: Promise<AppSettings> | null = null;
export async function getSettings(): Promise<AppSettings> {
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
// but coalesce simultaneous requests.
setTimeout(() => {
settingsPromise = null;
}, 100);
});
return settingsPromise;
}
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> {
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
export async function getProfileProjects(): Promise<
ResumeProjectCatalogItem[]
> {
return fetchApi<ResumeProjectCatalogItem[]>("/profile/projects");
}
export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogItem[]> {
export async function getResumeProjectsCatalog(): Promise<
ResumeProjectCatalogItem[]
> {
try {
const settings = await getSettings();
if (settings.rxresumeBaseResumeId) {
@ -227,85 +239,94 @@ export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogIt
}
export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile');
return fetchApi<ResumeProfile>("/profile");
}
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status');
return fetchApi<ProfileStatusResponse>("/profile/status");
}
export async function refreshProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile/refresh', {
method: 'POST',
return fetchApi<ResumeProfile>("/profile/refresh", {
method: "POST",
});
}
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
method: 'POST',
export async function validateOpenrouter(
apiKey?: string,
): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/openrouter", {
method: "POST",
body: JSON.stringify({ apiKey }),
});
}
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
method: 'POST',
export async function validateRxresume(
email?: string,
password?: string,
): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export async function validateResumeConfig(): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/resume');
return fetchApi<ValidationResult>("/onboarding/validate/resume");
}
export async function updateSettings(update: {
model?: string | null
modelScorer?: string | null
modelTailoring?: string | null
modelProjectSelection?: string | null
pipelineWebhookUrl?: string | null
jobCompleteWebhookUrl?: string | null
resumeProjects?: ResumeProjectsSettings | null
ukvisajobsMaxJobs?: number | null
gradcrackerMaxJobsPerTerm?: number | null
searchTerms?: string[] | null
jobspyLocation?: string | null
jobspyResultsWanted?: number | null
jobspyHoursOld?: number | null
jobspyCountryIndeed?: string | null
jobspySites?: string[] | null
jobspyLinkedinFetchDescription?: boolean | null
showSponsorInfo?: boolean | null
openrouterApiKey?: string | null
rxresumeEmail?: string | null
rxresumePassword?: string | null
basicAuthUser?: string | null
basicAuthPassword?: string | null
ukvisajobsEmail?: string | null
ukvisajobsPassword?: string | null
webhookSecret?: string | null
rxresumeBaseResumeId?: string | null
model?: string | null;
modelScorer?: string | null;
modelTailoring?: string | null;
modelProjectSelection?: string | null;
pipelineWebhookUrl?: string | null;
jobCompleteWebhookUrl?: string | null;
resumeProjects?: ResumeProjectsSettings | null;
ukvisajobsMaxJobs?: number | null;
gradcrackerMaxJobsPerTerm?: number | null;
searchTerms?: string[] | null;
jobspyLocation?: string | null;
jobspyResultsWanted?: number | null;
jobspyHoursOld?: number | null;
jobspyCountryIndeed?: string | null;
jobspySites?: string[] | null;
jobspyLinkedinFetchDescription?: boolean | null;
showSponsorInfo?: boolean | null;
openrouterApiKey?: string | null;
rxresumeEmail?: string | null;
rxresumePassword?: string | null;
basicAuthUser?: string | null;
basicAuthPassword?: string | null;
ukvisajobsEmail?: string | null;
ukvisajobsPassword?: string | null;
webhookSecret?: string | null;
rxresumeBaseResumeId?: string | null;
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',
return fetchApi<AppSettings>("/settings", {
method: "PATCH",
body: JSON.stringify(update),
});
}
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;
}
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[] }>(
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
{ signal }
{ signal },
);
return data.projects;
}
// Database API
export async function clearDatabase(): Promise<{
message: string;
@ -316,8 +337,8 @@ export async function clearDatabase(): Promise<{
message: string;
jobsDeleted: number;
runsDeleted: number;
}>('/database', {
method: 'DELETE',
}>("/database", {
method: "DELETE",
});
}
@ -329,13 +350,13 @@ export async function deleteJobsByStatus(status: string): Promise<{
message: string;
count: number;
}>(`/jobs/status/${status}`, {
method: 'DELETE',
method: "DELETE",
});
}
// Visa Sponsors API
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
return fetchApi<VisaSponsorStatusResponse>('/visa-sponsors/status');
return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");
}
export async function searchVisaSponsors(input: {
@ -344,20 +365,24 @@ export async function searchVisaSponsors(input: {
minScore?: number;
}): Promise<VisaSponsorSearchResponse> {
if (input.query?.trim()) {
trackEvent('visa_sponsor_search', {
trackEvent("visa_sponsor_search", {
query: input.query.trim(),
limit: input.limit,
minScore: input.minScore,
});
}
return fetchApi<VisaSponsorSearchResponse>('/visa-sponsors/search', {
method: 'POST',
return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function getVisaSponsorOrganization(name: string): Promise<VisaSponsor[]> {
return fetchApi<VisaSponsor[]>(`/visa-sponsors/organization/${encodeURIComponent(name)}`);
export async function getVisaSponsorOrganization(
name: string,
): Promise<VisaSponsor[]> {
return fetchApi<VisaSponsor[]>(
`/visa-sponsors/organization/${encodeURIComponent(name)}`,
);
}
export async function updateVisaSponsorList(): Promise<{
@ -367,8 +392,8 @@ export async function updateVisaSponsorList(): Promise<{
return fetchApi<{
message: string;
status: VisaSponsorStatusResponse;
}>('/visa-sponsors/update', {
method: 'POST',
}>("/visa-sponsors/update", {
method: "POST",
});
}

View File

@ -1 +1 @@
export * from './client';
export * from "./client";

View File

@ -1,5 +1,5 @@
import React from "react";
import { Sparkles } from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils";
import type { Job } from "../../shared/types";
@ -8,8 +8,8 @@ interface FitAssessmentProps {
className?: string;
}
export const FitAssessment: React.FC<FitAssessmentProps> = ({
job,
export const FitAssessment: React.FC<FitAssessmentProps> = ({
job,
className,
}) => {
if (!job.suitabilityReason) return null;

View File

@ -2,7 +2,6 @@
* Header component with logo and pipeline trigger.
*/
import React from "react";
import {
Briefcase,
ChevronDown,
@ -14,6 +13,7 @@ import {
Settings,
Shield,
} from "lucide-react";
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
@ -56,7 +56,12 @@ export const Header: React.FC<HeaderProps> = ({
const location = useLocation();
const [sheetOpen, setSheetOpen] = React.useState(false);
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
const orderedSources: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
@ -75,23 +80,21 @@ export const Header: React.FC<HeaderProps> = ({
};
return (
<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='flex items-center gap-3'>
<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="flex items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant='ghost' size='icon'>
<Menu className='h-5 w-5' />
<span className='sr-only'>Open navigation menu</span>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='left' className='w-64'>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>
JobOps
</SheetTitle>
<SheetTitle>JobOps</SheetTitle>
</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 }) => (
<Link
key={to}
@ -103,7 +106,7 @@ export const Header: React.FC<HeaderProps> = ({
: "text-muted-foreground"
}`}
>
<Icon className='h-4 w-4' />
<Icon className="h-4 w-4" />
{label}
</Link>
))}
@ -112,49 +115,51 @@ export const Header: React.FC<HeaderProps> = ({
</Sheet>
<Link
to='/'
className='flex items-center gap-3 hover:opacity-80 transition-opacity'
to="/"
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
src='/favicon.png'
alt='Job Ops Logo'
className='h-full w-full object-contain'
src="/favicon.png"
alt="Job Ops Logo"
className="h-full w-full object-contain"
/>
</div>
<div className='leading-tight'>
<div className='text-sm font-semibold tracking-tight'>Job Ops</div>
<div className='text-xs text-muted-foreground'>Orchestrator</div>
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight">
Job Ops
</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div>
</Link>
</div>
<div className='flex flex-wrap items-center gap-1.5'>
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant='outline'
size='sm'
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
>
<RefreshCcw className='h-4 w-4' />
<span className='hidden sm:inline'>Refresh</span>
<RefreshCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refresh</span>
</Button>
<div>
<Button
size='sm'
size="sm"
onClick={onRunPipeline}
disabled={isPipelineRunning}
className='rounded-r-none'
className="rounded-r-none"
>
{isPipelineRunning ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<Loader2 className="h-4 w-4 animate-spin" />
Running...
</>
) : (
<>
<Play className='h-4 w-4' />
<Play className="h-4 w-4" />
Run Pipeline
</>
)}
@ -163,18 +168,15 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size='sm'
size="sm"
disabled={isPipelineRunning}
className='rounded-l-none border-l border-primary-foreground/20'
aria-label='Select pipeline sources'
className="rounded-l-none border-l border-primary-foreground/20"
aria-label="Select pipeline sources"
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='w-56'
>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (

View File

@ -1,104 +1,122 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { JobHeader } from "./JobHeader";
import { useSettings } from "../hooks/useSettings";
import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
import { useSettings } from "../hooks/useSettings";
import { JobHeader } from "./JobHeader";
// Mock useSettings
vi.mock("../hooks/useSettings", () => ({
useSettings: vi.fn(),
useSettings: vi.fn(),
}));
// Mock api
vi.mock("../api", () => ({
checkSponsor: vi.fn(),
checkSponsor: vi.fn(),
}));
// Mock Tooltip components to simplify testing
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
}));
const mockJob: Job = {
id: "job-1",
title: "Software Engineer",
employer: "Tech Corp",
location: "London",
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
sponsorMatchScore: null,
sponsorMatchNames: null,
// Other fields...
id: "job-1",
title: "Software Engineer",
employer: "Tech Corp",
location: "London",
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
sponsorMatchScore: null,
sponsorMatchNames: null,
// Other fields...
} as Job;
describe("JobHeader", () => {
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
showSponsorInfo: true,
});
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
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", () => {
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();
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />);
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,
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
});
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(
screen.queryByText("Check Sponsorship Status"),
).not.toBeInTheDocument();
});
});

View File

@ -1,13 +1,21 @@
import React, { useMemo, useState } from "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 { 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 type { Job, JobStatus } from "../../shared/types";
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
import { useSettings } from "../hooks/useSettings";
import {
defaultStatusToken,
statusTokens,
} from "../pages/orchestrator/constants";
interface JobHeaderProps {
job: Job;
@ -93,7 +101,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
) : (
<Search className="h-2 w-2" />
)}
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
<span>
{isChecking ? "Checking..." : "Check Sponsorship Status"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
@ -109,9 +119,23 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
}
const getStatus = (s: number) => {
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
if (s >= 95)
return {
label: "Confirmed Sponsor",
dot: "bg-emerald-500",
color: "text-emerald-400",
};
if (s >= 80)
return {
label: "Potential Sponsor",
dot: "bg-amber-500",
color: "text-amber-400",
};
return {
label: "Sponsor Not Found",
dot: "bg-slate-500",
color: "text-slate-400",
};
};
const status = getStatus(score);
@ -122,7 +146,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
<Tooltip delayDuration={0}>
<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={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}
</span>
</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 deadline = formatDate(job.deadline);
@ -149,12 +179,17 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSpo
{/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">{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">
<span>{job.employer}</span>
</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]}
</Badge>
</div>

View File

@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ManualImportSheet } from "./ManualImportSheet";
import * as api from "../api";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { ManualImportSheet } from "./ManualImportSheet";
vi.mock("../api", () => ({
fetchJobFromUrl: vi.fn(),
@ -38,19 +37,29 @@ describe("ManualImportSheet", () => {
vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any);
render(
<ManualImportSheet open onOpenChange={onOpenChange} onImported={onImported} />
<ManualImportSheet
open
onOpenChange={onOpenChange}
onImported={onImported}
/>,
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
screen.getByPlaceholderText(
"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 }));
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");
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());
fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), {
@ -76,7 +85,7 @@ describe("ManualImportSheet", () => {
"Job imported",
expect.objectContaining({
description: expect.any(String),
})
}),
);
});
@ -89,12 +98,14 @@ describe("ManualImportSheet", () => {
});
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
screen.getByPlaceholderText(
"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 }));
@ -103,9 +114,12 @@ describe("ManualImportSheet", () => {
const importButton = screen.getByRole("button", { name: /import job/i });
expect(importButton).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText("e.g. Junior Backend Engineer"), {
target: { value: "QA Engineer" },
});
fireEvent.change(
screen.getByPlaceholderText("e.g. Junior Backend Engineer"),
{
target: { value: "QA Engineer" },
},
);
fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), {
target: { value: "Acme Labs" },
});
@ -116,22 +130,28 @@ describe("ManualImportSheet", () => {
it("returns to the paste step when inference fails", async () => {
const rawDescription = "Backend role description.";
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
vi.mocked(api.inferManualJob).mockRejectedValue(
new Error("Inference failed"),
);
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: rawDescription } }
screen.getByPlaceholderText(
"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 }));
await screen.findByText("Inference failed");
expect(screen.getByRole("button", { name: /analyze jd/i })).toBeInTheDocument();
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();
});
@ -142,17 +162,25 @@ describe("ManualImportSheet", () => {
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();
render(
<ManualImportSheet open onOpenChange={onOpenChange} onImported={vi.fn()} />
<ManualImportSheet
open
onOpenChange={onOpenChange}
onImported={vi.fn()}
/>,
);
fireEvent.change(
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
{ target: { value: "Backend Engineer role." } }
screen.getByPlaceholderText(
"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 }));
@ -161,7 +189,7 @@ describe("ManualImportSheet", () => {
fireEvent.click(screen.getByRole("button", { name: /import job/i }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Import failed")
expect(toast.error).toHaveBeenCalledWith("Import failed"),
);
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
@ -170,20 +198,24 @@ describe("ManualImportSheet", () => {
describe("URL fetch functionality", () => {
it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => {
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
// Initially should show Paste button
expect(screen.getByRole("button", { name: /paste/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /paste/i }),
).toBeInTheDocument();
// Enter a URL
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
{ target: { value: "https://example.com/job" } },
);
// 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 () => {
@ -201,13 +233,13 @@ describe("ManualImportSheet", () => {
});
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
// Enter a URL
fireEvent.change(
screen.getByPlaceholderText("https://example.com/job-posting"),
{ target: { value: "https://example.com/job" } }
{ target: { value: "https://example.com/job" } },
);
// Click Fetch
@ -224,8 +256,12 @@ describe("ManualImportSheet", () => {
});
// Check inferred values are shown
expect(screen.getByPlaceholderText("e.g. Junior Backend Engineer")).toHaveValue("Software Engineer");
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue("Acme Corp");
expect(
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 () => {
@ -241,12 +277,12 @@ describe("ManualImportSheet", () => {
});
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
fireEvent.change(
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 }));
@ -258,22 +294,28 @@ describe("ManualImportSheet", () => {
});
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(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
fireEvent.change(
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 }));
await screen.findByText("Failed to fetch URL");
// 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 () => {
@ -281,22 +323,28 @@ describe("ManualImportSheet", () => {
content: "Job content",
url: "https://example.com/job",
});
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
vi.mocked(api.inferManualJob).mockRejectedValue(
new Error("Inference failed"),
);
render(
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
);
fireEvent.change(
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 }));
await screen.findByText("Inference failed");
// 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();
});
});
});

View File

@ -2,8 +2,16 @@
* Manual job import flow (paste JD -> infer -> review -> import).
*/
import React, { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ClipboardPaste, FileText, Link, Loader2, Sparkles } from "lucide-react";
import {
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 { Button } from "@/components/ui/button";
@ -18,8 +26,8 @@ import {
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import * as api from "../api";
import type { ManualJobDraft } from "../../shared/types";
import * as api from "../api";
type ManualImportStep = "paste" | "loading" | "review";
@ -57,7 +65,10 @@ const emptyDraft: ManualJobDraftState = {
starting: "",
};
const normalizeDraft = (draft?: ManualJobDraft | null, jd?: string): ManualJobDraftState => ({
const normalizeDraft = (
draft?: ManualJobDraft | null,
jd?: string,
): ManualJobDraftState => ({
...emptyDraft,
title: draft?.title ?? "",
employer: draft?.employer ?? "",
@ -136,7 +147,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
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(() => {
if (step !== "review") return false;
return (
@ -163,7 +175,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
// Automatically proceed to analysis
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
const normalized = normalizeDraft(inferResponse.job);
@ -176,7 +190,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setWarning(inferResponse.warning ?? null);
setStep("review");
} 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);
setIsFetching(false);
setStep("paste");
@ -193,7 +208,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setError(null);
setWarning(null);
setStep("loading");
const response = await api.inferManualJob({ jobDescription: rawDescription });
const response = await api.inferManualJob({
jobDescription: rawDescription,
});
const normalized = normalizeDraft(response.job, rawDescription.trim());
// Preserve the fetched URL if we fetched from a URL
if (draft.jobUrl && !normalized.jobUrl) {
@ -203,7 +220,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
setWarning(response.warning ?? null);
setStep("review");
} 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);
setStep("paste");
}
@ -222,7 +242,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
await onImported(created.id);
onOpenChange(false);
} 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);
} finally {
setIsImporting(false);
@ -241,7 +262,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
Manual Import
</SheetTitle>
<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>
</SheetHeader>
@ -306,7 +328,11 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
) : (
<ClipboardPaste className="h-4 w-4" />
)}
{isFetching ? "Fetching..." : fetchUrl.trim() ? "Fetch" : "Paste"}
{isFetching
? "Fetching..."
: fetchUrl.trim()
? "Fetch"
: "Paste"}
</Button>
</div>
</div>
@ -347,7 +373,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
{step === "loading" && (
<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" />
<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">
Extracting title, company, location, and other details.
</p>
@ -380,106 +408,197 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<div className="grid gap-3 sm:grid-cols-2">
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
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"
/>
</div>
<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
value={draft.jobUrl}
onChange={(event) => setDraft((prev) => ({ ...prev, jobUrl: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<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
value={draft.applicationLink}
onChange={(event) => setDraft((prev) => ({ ...prev, applicationLink: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({
...prev,
applicationLink: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
@ -491,7 +610,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</label>
<Textarea
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"
placeholder="Paste the job description..."
/>
@ -502,7 +626,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<Button
onClick={handleImport}
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />

View File

@ -1,119 +1,154 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Check } from "lucide-react"
import { toast } from "sonner"
import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
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"
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 }
type ValidationState = ValidationResult & { checked: boolean };
export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
const [isSavingEnv, setIsSavingEnv] = useState(false)
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false)
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [currentStep, setCurrentStep] = useState<string | null>(null)
const {
settings,
isLoading: settingsLoading,
refreshSettings,
} = useSettings();
const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
const [openrouterValidation, setOpenrouterValidation] =
useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
{
valid: false,
message: null,
checked: false,
},
);
const [baseResumeValidation, setBaseResumeValidation] =
useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(null)
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [rxresumeEmail, setRxresumeEmail] = useState("");
const [rxresumePassword, setRxresumePassword] = useState("");
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
string | null
>(null);
const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true)
setIsValidatingOpenrouter(true);
try {
const result = await api.validateOpenrouter(apiKey)
setOpenrouterValidation({ ...result, checked: true })
return result
const result = await api.validateOpenrouter(apiKey);
setOpenrouterValidation({ ...result, checked: true });
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "OpenRouter validation failed"
const result = { valid: false, message }
setOpenrouterValidation({ ...result, checked: true })
return result
const message =
error instanceof Error ? error.message : "OpenRouter validation failed";
const result = { valid: false, message };
setOpenrouterValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingOpenrouter(false)
setIsValidatingOpenrouter(false);
}
}, [])
}, []);
const validateRxresume = useCallback(async (email?: string, password?: string) => {
setIsValidatingRxresume(true)
try {
const result = await api.validateRxresume(email, password)
setRxresumeValidation({ ...result, checked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : "RxResume validation failed"
const result = { valid: false, message }
setRxresumeValidation({ ...result, checked: true })
return result
} finally {
setIsValidatingRxresume(false)
}
}, [])
const validateRxresume = useCallback(
async (email?: string, password?: string) => {
setIsValidatingRxresume(true);
try {
const result = await api.validateRxresume(email, password);
setRxresumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "RxResume validation failed";
const result = { valid: false, message };
setRxresumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingRxresume(false);
}
},
[],
);
const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true)
setIsValidatingBaseResume(true);
try {
const result = await api.validateResumeConfig()
setBaseResumeValidation({ ...result, checked: true })
return result
const result = await api.validateResumeConfig();
setBaseResumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Base resume validation failed"
const result = { valid: false, message }
setBaseResumeValidation({ ...result, checked: true })
return result
const message =
error instanceof Error
? error.message
: "Base resume validation failed";
const result = { valid: false, message };
setBaseResumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingBaseResume(false)
setIsValidatingBaseResume(false);
}
}, [])
}, []);
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
const shouldOpen = Boolean(settings && !settingsLoading)
&& !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid)
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const shouldOpen =
Boolean(settings && !settingsLoading) &&
!(
openrouterValidation.valid &&
rxresumeValidation.valid &&
baseResumeValidation.valid
);
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
: undefined
: undefined;
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail
: undefined
: undefined;
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings.rxresumePasswordHint)
: undefined
: undefined;
useEffect(() => {
if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null)
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
}
}, [settings])
}, [settings]);
const steps = useMemo(
() => [
@ -139,189 +174,236 @@ export const OnboardingGate: React.FC = () => {
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(() => {
if (!shouldOpen) return
if (!shouldOpen) return;
if (!currentStep && defaultStep) {
setCurrentStep(defaultStep)
setCurrentStep(defaultStep);
}
}, [currentStep, defaultStep, shouldOpen])
}, [currentStep, defaultStep, shouldOpen]);
const runAllValidations = useCallback(async () => {
if (!settings) return
if (!settings) return;
const results = await Promise.allSettled([
validateOpenrouter(),
validateRxresume(),
validateBaseResume(),
])
]);
const failed = results.find((result) => result.status === "rejected")
const failed = results.find((result) => result.status === "rejected");
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null
const message = reason instanceof Error ? reason.message : "Validation checks failed"
toast.error(message)
const reason = failed.status === "rejected" ? failed.reason : null;
const message =
reason instanceof Error ? reason.message : "Validation checks failed";
toast.error(message);
}
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume])
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume]);
useEffect(() => {
if (!settings || settingsLoading) return
if (openrouterValidation.checked || rxresumeValidation.checked || baseResumeValidation.checked) return
void runAllValidations()
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations])
if (!settings || settingsLoading) return;
if (
openrouterValidation.checked ||
rxresumeValidation.checked ||
baseResumeValidation.checked
)
return;
void runAllValidations();
}, [
settings,
settingsLoading,
openrouterValidation.checked,
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
]);
const handleRefresh = async () => {
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
const failed = results.find((result) => result.status === "rejected")
const results = await Promise.allSettled([
refreshSettings(),
runAllValidations(),
]);
const failed = results.find((result) => result.status === "rejected");
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null
const message = reason instanceof Error ? reason.message : "Failed to refresh setup"
toast.error(message)
const reason = failed.status === "rejected" ? failed.reason : null;
const message =
reason instanceof Error ? reason.message : "Failed to refresh setup";
toast.error(message);
}
}
};
const handleSaveOpenrouter = async (): Promise<boolean> => {
const openrouterValue = openrouterApiKey.trim()
const openrouterValue = openrouterApiKey.trim();
if (!openrouterValue && !hasOpenrouterKey) {
toast.info("Add your OpenRouter API key to continue")
return false
toast.info("Add your OpenRouter API key to continue");
return false;
}
try {
const validation = await validateOpenrouter(openrouterValue || undefined)
const validation = await validateOpenrouter(openrouterValue || undefined);
if (!validation.valid) {
toast.error(validation.message || "OpenRouter validation failed")
return false
toast.error(validation.message || "OpenRouter validation failed");
return false;
}
if (openrouterValue) {
setIsSavingEnv(true)
await api.updateSettings({ openrouterApiKey: openrouterValue })
await refreshSettings()
setOpenrouterApiKey("")
setIsSavingEnv(true);
await api.updateSettings({ openrouterApiKey: openrouterValue });
await refreshSettings();
setOpenrouterApiKey("");
}
toast.success("OpenRouter connected")
return true
toast.success("OpenRouter connected");
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key"
toast.error(message)
return false
const message =
error instanceof Error
? error.message
: "Failed to save OpenRouter key";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false)
setIsSavingEnv(false);
}
}
};
const handleSaveRxresume = async (): Promise<boolean> => {
const emailValue = rxresumeEmail.trim()
const passwordValue = rxresumePassword.trim()
const missing: string[] = []
const emailValue = rxresumeEmail.trim();
const passwordValue = rxresumePassword.trim();
const missing: string[] = [];
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
if (!hasRxresumePassword && !passwordValue)
missing.push("RxResume password");
if (missing.length > 0) {
toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`,
})
return false
});
return false;
}
try {
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
const validation = await validateRxresume(
emailValue || undefined,
passwordValue || undefined,
);
if (!validation.valid) {
toast.error(validation.message || "RxResume validation failed")
return false
toast.error(validation.message || "RxResume validation failed");
return false;
}
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
if (emailValue) update.rxresumeEmail = emailValue
if (passwordValue) update.rxresumePassword = passwordValue
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {};
if (emailValue) update.rxresumeEmail = emailValue;
if (passwordValue) update.rxresumePassword = passwordValue;
if (Object.keys(update).length > 0) {
setIsSavingEnv(true)
await api.updateSettings(update)
await refreshSettings()
setRxresumePassword("")
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setRxresumePassword("");
}
toast.success("RxResume connected")
return true
toast.success("RxResume connected");
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save RxResume credentials"
toast.error(message)
return false
const message =
error instanceof Error
? error.message
: "Failed to save RxResume credentials";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false)
setIsSavingEnv(false);
}
}
};
const handleSaveBaseResume = async (): Promise<boolean> => {
if (!rxresumeBaseResumeId) {
toast.info("Select a base resume to continue")
return false
toast.info("Select a base resume to continue");
return false;
}
try {
setIsSavingEnv(true)
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId })
const validation = await validateBaseResume()
setIsSavingEnv(true);
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId });
const validation = await validateBaseResume();
if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed")
return false
toast.error(validation.message || "Base resume validation failed");
return false;
}
await refreshSettings()
toast.success("Base resume set")
return true
await refreshSettings();
toast.success("Base resume set");
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save base resume"
toast.error(message)
return false
const message =
error instanceof Error ? error.message : "Failed to save base resume";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false)
setIsSavingEnv(false);
}
}
};
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
const completedSteps = steps.filter((step) => step.complete).length
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume
const canGoBack = stepIndex > 0
const primaryLabel = currentStep === "openrouter"
? (openrouterValidation.valid ? "Revalidate" : "Validate")
: currentStep === "rxresume"
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
: currentStep === "baseresume"
? (baseResumeValidation.valid ? "Revalidate" : "Validate")
const resolvedStepIndex = currentStep
? steps.findIndex((step) => step.id === currentStep)
: 0;
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0;
const completedSteps = steps.filter((step) => step.complete).length;
const progressValue =
steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0;
const isBusy =
isSavingEnv ||
settingsLoading ||
isValidatingOpenrouter ||
isValidatingRxresume ||
isValidatingBaseResume;
const canGoBack = stepIndex > 0;
const primaryLabel =
currentStep === "openrouter"
? openrouterValidation.valid
? "Revalidate"
: "Validate"
: currentStep === "rxresume"
? rxresumeValidation.valid
? "Revalidate"
: "Validate"
: currentStep === "baseresume"
? baseResumeValidation.valid
? "Revalidate"
: "Validate"
: "Validate";
const handlePrimaryAction = async () => {
if (!currentStep) return
if (!currentStep) return;
if (currentStep === "openrouter") {
await handleSaveOpenrouter()
return
await handleSaveOpenrouter();
return;
}
if (currentStep === "rxresume") {
await handleSaveRxresume()
return
await handleSaveRxresume();
return;
}
if (currentStep === "baseresume") {
await handleSaveBaseResume()
return
await handleSaveBaseResume();
return;
}
}
};
const handleBack = () => {
if (!canGoBack) return
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep)
}
if (!canGoBack) return;
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep);
};
if (!shouldOpen || !currentStep) return null
if (!shouldOpen || !currentStep) return null;
return (
<AlertDialog open>
@ -333,22 +415,23 @@ export const OnboardingGate: React.FC = () => {
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Lets get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
Lets get your workspace ready. Add your keys and resume once,
then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
<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">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isComplete = step.complete
const isActive = step.id === currentStep;
const isComplete = step.complete;
return (
<FieldLabel
key={step.id}
className={cn(
"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
@ -356,7 +439,9 @@ export const OnboardingGate: React.FC = () => {
disabled={step.disabled}
className={cn(
"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">
@ -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",
isComplete
? "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>
</Field>
</TabsTrigger>
</FieldLabel>
)
);
})}
</TabsList>
<TabsContent value="openrouter" className="space-y-4 pt-6">
<div>
<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>
<SettingsInput
label="OpenRouter API key"
@ -403,8 +494,12 @@ export const OnboardingGate: React.FC = () => {
<TabsContent value="rxresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Link your RxResume account</p>
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
<p className="text-sm font-semibold">
Link your RxResume account
</p>
<p className="text-xs text-muted-foreground">
Used to export tailored PDFs.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
@ -423,7 +518,8 @@ export const OnboardingGate: React.FC = () => {
inputProps={{
name: "rxresumePassword",
value: rxresumePassword,
onChange: (event) => setRxresumePassword(event.target.value),
onChange: (event) =>
setRxresumePassword(event.target.value),
}}
type="password"
placeholder="Enter password"
@ -435,9 +531,12 @@ export const OnboardingGate: React.FC = () => {
<TabsContent value="baseresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Select your template resume</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 className="text-sm font-semibold">
Select your template resume
</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>
</div>
<BaseResumeSelection
@ -447,11 +546,14 @@ export const OnboardingGate: React.FC = () => {
disabled={isSavingEnv}
/>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
<Button
variant="outline"
onClick={handleBack}
disabled={!canGoBack || isBusy}
>
Back
</Button>
<div className="flex items-center gap-2">
@ -467,8 +569,8 @@ export const OnboardingGate: React.FC = () => {
<Progress value={progressValue} className="h-2" />
<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
we will take a look.{" "}
Friendly heads-up: pipelines can be slow or a little flaky in alpha.
If anything feels off, open a GitHub issue and we will take a look.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
@ -482,5 +584,5 @@ export const OnboardingGate: React.FC = () => {
</div>
</AlertDialogContent>
</AlertDialog>
)
}
);
};

View File

@ -2,8 +2,9 @@
* Live pipeline progress display component.
*/
import React, { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -12,7 +13,14 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface PipelineProgress {
step: "idle" | "crawling" | "importing" | "scoring" | "processing" | "completed" | "failed";
step:
| "idle"
| "crawling"
| "importing"
| "scoring"
| "processing"
| "completed"
| "failed";
message: string;
detail?: string;
crawlingListPagesProcessed: number;
@ -61,9 +69,12 @@ const stepBadgeClasses: Record<PipelineProgress["step"], string> = {
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 [isConnected, setIsConnected] = useState(false);
@ -74,9 +85,11 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
case "crawling": {
if (progress.crawlingListPagesTotal > 0) {
return clamp(
(progress.crawlingListPagesProcessed / progress.crawlingListPagesTotal) * 15,
(progress.crawlingListPagesProcessed /
progress.crawlingListPagesTotal) *
15,
0,
15
15,
);
}
if (progress.crawlingListPagesProcessed > 0) return 8;
@ -87,9 +100,10 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
case "scoring": {
if (progress.jobsScored > 0) {
return clamp(
20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
20 +
(progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
20,
50
50,
);
}
return 25;
@ -99,7 +113,7 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
return clamp(
50 + (progress.jobsProcessed / progress.totalToProcess) * 50,
50,
100
100,
);
}
return 55;
@ -151,7 +165,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
const step = progress?.step ?? "idle";
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 (
<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 min-w-0 flex-wrap items-center gap-2">
<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]}
</Badge>
<span className="truncate text-xs text-muted-foreground">
@ -180,7 +199,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
<CardContent className="space-y-4">
<div className="space-y-1">
<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>
{showStats && (
@ -190,46 +211,73 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
{step === "crawling" ? (
<>
<div>
<div className="text-xs text-muted-foreground">Sources</div>
<div className="text-xs text-muted-foreground">
Sources
</div>
<div className="tabular-nums">
{progress.crawlingListPagesProcessed}
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ""}
{progress.crawlingListPagesTotal > 0
? `/${progress.crawlingListPagesTotal}`
: ""}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Pages</div>
<div className="tabular-nums">
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
{progress.crawlingJobPagesProcessed}/
{Math.max(progress.crawlingJobPagesEnqueued, 0)}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Enqueued</div>
<div className="tabular-nums">{progress.crawlingJobPagesEnqueued}</div>
<div className="text-xs text-muted-foreground">
Enqueued
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesEnqueued}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Skipped</div>
<div className="tabular-nums">{progress.crawlingJobPagesSkipped}</div>
<div className="text-xs text-muted-foreground">
Skipped
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesSkipped}
</div>
</div>
</>
) : (
<>
<div>
<div className="text-xs text-muted-foreground">Discovered</div>
<div className="tabular-nums">{progress.jobsDiscovered}</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="text-xs text-muted-foreground">
Discovered
</div>
<div className="tabular-nums">
{progress.totalToProcess > 0 ? `${progress.jobsProcessed}/${progress.totalToProcess}` : progress.jobsProcessed}
{progress.jobsDiscovered}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">To process</div>
<div className="tabular-nums">{progress.totalToProcess}</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">
{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>
</>
)}

View File

@ -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 { ReadyPanel } from "./ReadyPanel";
import type React from "react";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
import * as api from "../api";
import { toast } from "sonner";
import { ReadyPanel } from "./ReadyPanel";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
<button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children}
</button>
),
@ -126,14 +136,12 @@ describe("ReadyPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render(
<ReadyPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
<ReadyPanel 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"));
expect(onJobUpdated).toHaveBeenCalled();

View File

@ -1,32 +1,38 @@
/**
* ReadyPanel - Optimized "shipping lane" view for Ready jobs.
*
*
* Designed for a single, fast, repeatable workflow: verify download apply mark applied.
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
*
*
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Briefcase,
Building2,
CheckCircle2,
ChevronUp,
Copy,
Download,
Edit2,
ExternalLink,
FileText,
FolderKanban,
Loader2,
MoreHorizontal,
RefreshCcw,
Undo2,
Copy,
Edit2,
XCircle,
Briefcase,
Building2,
FolderKanban,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -35,19 +41,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
import * as api from "../api";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { useProfile } from "../hooks/useProfile";
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";
@ -103,7 +103,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const selectedProjectNames = useMemo(() => {
if (!catalog.length || !selectedProjectIds.length) return [];
return selectedProjectIds
.map(id => catalog.find(p => p.id === id)?.name)
.map((id) => catalog.find((p) => p.id === id)?.name)
.filter(Boolean) as string[];
}, [catalog, selectedProjectIds]);
@ -140,7 +140,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
duration: 6000,
});
} 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);
} finally {
setIsMarkingApplied(false);
@ -160,7 +161,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
setRecentlyApplied(null);
await onJobUpdated();
} 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);
}
},
@ -176,14 +178,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
toast.success("PDF regenerated");
await onJobUpdated();
} 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);
} finally {
setIsRegenerating(false);
}
}, [job, onJobUpdated]);
const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]);
const handleRescore = useCallback(
() => rescoreJob(job?.id),
[job?.id, rescoreJob],
);
const handleSkip = useCallback(async () => {
if (!job) return;
@ -222,7 +228,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
await onJobUpdated();
setMode("ready");
} 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);
} finally {
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">
<FileText className="h-5 w-5 text-muted-foreground" />
</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]">
Select a Ready job to view its application kit and take action.
</p>
@ -275,7 +284,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<div className="pb-4 border-b border-border/40">
<div className="grid gap-2 sm:grid-cols-2">
{/* 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">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">View PDF</span>
@ -283,7 +296,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button>
{/* 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
href={pdfHref}
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
@ -294,7 +311,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button>
{/* 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">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Open Job Listing</span>
@ -338,7 +359,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</div>
<div className="min-w-0 flex-1 text-left">
<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>
@ -385,15 +408,16 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
onSelect={handleRegenerate}
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"}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleRescore}
disabled={isRescoring}
>
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
<DropdownMenuItem onSelect={handleRescore} disabled={isRescoring}>
<RefreshCcw
className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")}
/>
{isRescoring ? "Recalculating..." : "Recalculate match"}
</DropdownMenuItem>

View File

@ -2,7 +2,7 @@
* Suitability score display component.
*/
import React from "react";
import type React from "react";
import { Progress } from "@/components/ui/progress";
@ -18,8 +18,9 @@ export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
return (
<div className="flex items-center gap-2">
<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>
);
};

View File

@ -2,7 +2,6 @@
* Stats dashboard showing job counts by status.
*/
import React from "react";
import {
CheckCircle2,
Clock,
@ -11,6 +10,7 @@ import {
Sparkles,
XCircle,
} from "lucide-react";
import type React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { JobStatus } from "../../shared/types";
@ -74,4 +74,3 @@ export const Stats: React.FC<StatsProps> = ({ stats }) => {
</Card>
);
};

View File

@ -2,8 +2,8 @@
* Status badge component.
*/
import React from "react";
import { Loader2 } from "lucide-react";
import type React from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@ -24,12 +24,18 @@ const statusLabels: Record<JobStatus, string> = {
const statusStyles: Record<
JobStatus,
{ variant: "default" | "secondary" | "destructive" | "outline"; className?: string }
{
variant: "default" | "secondary" | "destructive" | "outline";
className?: string;
}
> = {
discovered: { variant: "secondary" },
processing: { variant: "secondary" },
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" },
expired: { variant: "outline", className: "text-muted-foreground" },
};
@ -44,4 +50,3 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
</Badge>
);
};

View File

@ -1,4 +1,4 @@
import React from "react";
import type React from "react";
import { cn } from "@/lib/utils";
import type { Job } from "../../shared/types";
@ -7,11 +7,19 @@ interface TailoredSummaryProps {
className?: string;
}
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({ job, className }) => {
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({
job,
className,
}) => {
if (!job.tailoredSummary) return null;
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">
Tailored Summary
</div>

View File

@ -1,13 +1,20 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react";
import {
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 { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import * as api from "../api";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
import * as api from "../api";
interface TailoringEditorProps {
job: Job;
@ -26,7 +33,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
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 [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
@ -45,9 +54,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
return false;
}, [selectedIds, savedSelectedIds]);
const isDirty = summary !== (job.tailoredSummary || "") ||
jobDescription !== (job.jobDescription || "") ||
hasSelectionDiff;
const isDirty =
summary !== (job.tailoredSummary || "") ||
jobDescription !== (job.jobDescription || "") ||
hasSelectionDiff;
useEffect(() => {
onDirtyChange?.(isDirty);
@ -56,10 +66,12 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
useEffect(() => {
// Load project catalog
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
// Set initial selection
if (job.selectedProjectIds) {
setSelectedIds(new Set(job.selectedProjectIds.split(',').filter(Boolean)));
setSelectedIds(
new Set(job.selectedProjectIds.split(",").filter(Boolean)),
);
}
setJobDescription(job.jobDescription || "");
}, [job.selectedProjectIds, job.jobDescription]);
@ -119,7 +131,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
setSummary(updatedJob.tailoredSummary || "");
setJobDescription(updatedJob.jobDescription || "");
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");
await onUpdate();
@ -138,7 +152,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
setIsGeneratingPdf(true);
// Save current state first to ensure PDF uses latest
await saveChanges({ showToast: false });
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
await onUpdate();
@ -164,7 +178,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
disabled={isSummarizing || isGeneratingPdf || isSaving}
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
</Button>
<Button
@ -173,15 +191,21 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
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
</Button>
</div>
</div>
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<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
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}
@ -210,7 +234,8 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<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>
)}
</div>
@ -231,7 +256,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<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>
</div>
))}
@ -239,10 +266,19 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
</div>
<div className="flex justify-end border-t pt-4">
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
Save Selection
</Button>
<Button
variant="ghost"
size="sm"
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>

View File

@ -1,5 +1,5 @@
import React from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import type React from "react";
interface CollapsibleSectionProps {
isOpen: boolean;
@ -15,16 +15,16 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
children,
}) => {
return (
<div className='space-y-2'>
<div className="space-y-2">
<button
type='button'
type="button"
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 ? (
<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}
</button>

View File

@ -1,5 +1,13 @@
import React, { useMemo, useState } from "react";
import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react";
import {
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 {
@ -9,9 +17,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { FitAssessment, JobHeader, TailoredSummary } from "..";
import type { Job } from "../../../shared/types";
import { FitAssessment, JobHeader, TailoredSummary } from "..";
import { CollapsibleSection } from "./CollapsibleSection";
import { getPlainDescription } from "./helpers";
@ -39,46 +46,43 @@ export const DecideMode: React.FC<DecideModeProps> = ({
const description = useMemo(
() => getPlainDescription(job.jobDescription),
[job.jobDescription]
[job.jobDescription],
);
return (
<div className='flex flex-col h-full'>
<div className='space-y-4 pb-4'>
<JobHeader
job={job}
onCheckSponsor={onCheckSponsor}
/>
<div className="flex flex-col h-full">
<div className="space-y-4 pb-4">
<JobHeader 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
variant='outline'
size='default'
variant="outline"
size="default"
onClick={onSkip}
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 ? (
<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
</Button>
<Button
size='default'
size="default"
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
</Button>
</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} />
<TailoredSummary job={job} />
@ -87,17 +91,17 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onToggle={() => setShowDescription((prev) => !prev)}
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'>
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'>
<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">
{description}
</p>
</div>
</CollapsibleSection>
</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>
<DropdownMenuTrigger asChild>
<Button
@ -111,21 +115,25 @@ export const DecideMode: React.FC<DecideModeProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<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"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{jobLink ? (
<div className='flex justify-center'>
<div className="flex justify-center">
<a
href={jobLink}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors'
target="_blank"
rel="noopener noreferrer"
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
</a>
</div>

View File

@ -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 { DiscoveredPanel } from "./DiscoveredPanel";
import type React from "react";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types";
import * as api from "../../api";
import { toast } from "sonner";
import { DiscoveredPanel } from "./DiscoveredPanel";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
<button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children}
</button>
),
@ -123,10 +133,12 @@ describe("DiscoveredPanel", () => {
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-2"));
expect(onJobUpdated).toHaveBeenCalled();

View File

@ -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 * as api from "../../api";
import type { Job } from "../../../shared/types";
import * as api from "../../api";
import { useRescoreJob } from "../../hooks/useRescoreJob";
import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState";
import { ProcessingState } from "./ProcessingState";
import { TailorMode } from "./TailorMode";
import { useRescoreJob } from "../../hooks/useRescoreJob";
type PanelMode = "decide" | "tailor";
@ -82,7 +82,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
}
return (
<div className='h-full'>
<div className="h-full">
{mode === "decide" ? (
<DecideMode
job={job}

View File

@ -1,16 +1,16 @@
import React from "react";
import { Sparkles } from "lucide-react";
import type React from "react";
export const EmptyState: React.FC = () => {
return (
<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'>
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
<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">
<Sparkles className="h-4 w-4 text-muted-foreground/50" />
</div>
<div className='text-sm font-medium text-muted-foreground'>
<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 job from the list to see details and decide whether to tailor.
</p>
</div>

View File

@ -1,14 +1,14 @@
import React from "react";
import { Loader2 } from "lucide-react";
import type React from "react";
export const ProcessingState: React.FC = () => {
return (
<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' />
<div className='text-sm font-medium text-foreground/80'>
<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" />
<div className="text-sm font-medium text-foreground/80">
Processing job...
</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.
</p>
</div>

View File

@ -1,5 +1,5 @@
import React from "react";
import { AlertTriangle } from "lucide-react";
import type React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
@ -23,22 +23,22 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
const tooManyProjects = selectedIds.size > maxProjects;
return (
<div className='space-y-2'>
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-xs font-medium text-muted-foreground">
Selected Projects
</label>
{tooManyProjects && (
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
<AlertTriangle className='h-3 w-3' />
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className="h-3 w-3" />
Max {maxProjects} recommended
</span>
)}
</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 ? (
<div className='text-xs text-muted-foreground text-center py-4'>
<div className="text-xs text-muted-foreground text-center py-4">
Loading projects...
</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",
selectedIds.has(project.id)
? "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)}
>
@ -58,11 +58,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
checked={selectedIds.has(project.id)}
onCheckedChange={() => onToggle(project.id)}
disabled={disabled}
className='mt-0.5'
className="mt-0.5"
/>
<div className='flex-1 min-w-0'>
<div className='font-medium truncate'>{project.name}</div>
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{project.name}</div>
<div className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
{project.description}
</div>
</div>

View File

@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "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 { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
import * as api from "../../api";
import { CollapsibleSection } from "./CollapsibleSection";
import { ProjectSelector } from "./ProjectSelector";
@ -16,7 +16,7 @@ interface TailorModeProps {
onFinalize: () => void;
isFinalizing: boolean;
/** Variant controls the finalize button text. Default is 'discovered'. */
variant?: 'discovered' | 'ready';
variant?: "discovered" | "ready";
}
export const TailorMode: React.FC<TailorModeProps> = ({
@ -24,11 +24,13 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onBack,
onFinalize,
isFinalizing,
variant = 'discovered',
variant = "discovered",
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
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 saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
@ -67,7 +69,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
if (!savedIds.has(id)) return true;
}
return false;
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
}, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedIds,
]);
useEffect(() => {
if (hasChanges && draftStatus === "saved") {
@ -105,7 +114,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
return next;
});
},
[isGenerating, isFinalizing]
[isGenerating, isFinalizing],
);
const handleGenerateWithAI = async () => {
@ -125,7 +134,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
setJobDescription(updatedJob.jobDescription || "");
if (updatedJob.selectedProjectIds) {
setSelectedIds(
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
);
}
setDraftStatus("saved");
@ -165,67 +174,69 @@ export const TailorMode: React.FC<TailorModeProps> = ({
const disableInputs = isGenerating || isFinalizing || isSaving;
return (
<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 h-full">
<div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<button
type='button'
type="button"
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
</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" && (
<>
<Loader2 className='h-3 w-3 animate-spin' />
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</>
)}
{draftStatus === "saved" && !hasChanges && (
<>
<Check className='h-3 w-3 text-emerald-400' />
<Check className="h-3 w-3 text-emerald-400" />
Saved
</>
)}
{draftStatus === "unsaved" && (
<span className='text-amber-400'>Unsaved changes</span>
<span className="text-amber-400">Unsaved changes</span>
)}
</div>
</div>
<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='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
<span className='text-xs font-medium text-amber-300'>
<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="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
<span className="text-xs font-medium text-amber-300">
Draft tailoring for this role
</span>
</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.
</p>
</div>
<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-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>
<div className='text-xs font-medium'>Need help getting started?</div>
<div className='text-[10px] text-muted-foreground'>
<div className="text-xs font-medium">
Need help getting started?
</div>
<div className="text-[10px] text-muted-foreground">
AI can draft a summary and select projects for you
</div>
</div>
<Button
size='sm'
variant='outline'
size="sm"
variant="outline"
onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing}
className='h-8 w-full text-xs sm:w-auto'
className="h-8 w-full text-xs sm:w-auto"
>
{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
</Button>
@ -236,29 +247,29 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "Edit"} job description`}
>
<div className='space-y-1'>
<label className='text-[10px] font-medium text-muted-foreground/70'>
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground/70">
Edit to help AI tailoring
</label>
<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}
onChange={(event) => setJobDescription(event.target.value)}
placeholder='The raw job description...'
placeholder="The raw job description..."
disabled={disableInputs}
/>
</div>
</CollapsibleSection>
<div className='space-y-2'>
<label className='text-xs font-medium text-muted-foreground'>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Tailored Summary
</label>
<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}
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}
/>
</div>
@ -272,35 +283,40 @@ export const TailorMode: React.FC<TailorModeProps> = ({
/>
</div>
<Separator className='opacity-50 my-4' />
<Separator className="opacity-50 my-4" />
<div className='space-y-2'>
<div className="space-y-2">
{!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'>
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
<p className="text-[10px] text-center text-muted-foreground">
Add a summary and select at least one project to{" "}
{variant === "ready" ? "regenerate" : "finalize"}.
</p>
)}
<Button
onClick={handleFinalize}
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 ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{variant === "ready"
? "Regenerating PDF..."
: "Finalizing & generating PDF..."}
</>
) : (
<>
<Check className='mr-2 h-4 w-4' />
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
<Check className="mr-2 h-4 w-4" />
{variant === "ready"
? "Regenerate PDF"
: "Finalize & Move to Ready"}
</>
)}
</Button>
<p className='text-[10px] text-center text-muted-foreground/70'>
{variant === 'ready'
? 'This will save your changes and regenerate the tailored PDF.'
: 'This will generate your tailored PDF and move the job to Ready.'}
<p className="text-[10px] text-center text-muted-foreground/70">
{variant === "ready"
? "This will save your changes and regenerate the tailored PDF."
: "This will generate your tailored PDF and move the job to Ready."}
</p>
</div>
</div>

View File

@ -1,13 +1,13 @@
export { Header } from './Header';
export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator';
export { JobHeader } from './JobHeader';
export { TailoredSummary } from './TailoredSummary';
export { FitAssessment } from './FitAssessment';
export { PipelineProgress } from './PipelineProgress';
export { TailoringEditor } from './TailoringEditor';
export { DiscoveredPanel } from './discovered-panel';
export { ReadyPanel } from './ReadyPanel';
export { ManualImportSheet } from './ManualImportSheet';
export * from './layout';
export { DiscoveredPanel } from "./discovered-panel";
export { FitAssessment } from "./FitAssessment";
export { Header } from "./Header";
export { JobHeader } from "./JobHeader";
export * from "./layout";
export { ManualImportSheet } from "./ManualImportSheet";
export { PipelineProgress } from "./PipelineProgress";
export { ReadyPanel } from "./ReadyPanel";
export { ScoreIndicator } from "./ScoreIndicator";
export { Stats } from "./Stats";
export { StatusBadge } from "./StatusBadge";
export { TailoredSummary } from "./TailoredSummary";
export { TailoringEditor } from "./TailoringEditor";

View File

@ -2,9 +2,17 @@
* 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 { Briefcase, Home, LucideIcon, Menu, Settings, Shield } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -81,9 +89,16 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
onClick={() => handleNavClick(to)}
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",
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"
: "text-muted-foreground"
: "text-muted-foreground",
)}
>
<NavIcon className="h-4 w-4" />
@ -109,9 +124,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
{statusIndicator}
</div>
<div className="flex items-center gap-2">
{actions}
</div>
<div className="flex items-center gap-2">{actions}</div>
</div>
</header>
);
@ -126,7 +139,10 @@ interface StatusIndicatorProps {
variant?: "amber" | "emerald" | "sky";
}
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant = "amber" }) => {
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
label,
variant = "amber",
}) => {
const colorMap = {
amber: "border-amber-500/30 bg-amber-500/10 text-amber-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
className={cn(
"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}
</span>
);
@ -160,8 +181,16 @@ interface SplitLayoutProps {
className?: string;
}
export const SplitLayout: React.FC<SplitLayoutProps> = ({ children, className }) => (
<section className={cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]", className)}>
export const SplitLayout: React.FC<SplitLayoutProps> = ({
children,
className,
}) => (
<section
className={cn(
"grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]",
className,
)}
>
{children}
</section>
);
@ -177,11 +206,27 @@ interface ListPanelProps {
className?: string;
}
export const ListPanel: React.FC<ListPanelProps> = ({ children, header, footer, 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>}
export const ListPanel: React.FC<ListPanelProps> = ({
children,
header,
footer,
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>
);
@ -196,14 +241,19 @@ interface ListItemProps {
className?: string;
}
export const ListItem: React.FC<ListItemProps> = ({ selected, onClick, children, className }) => (
export const ListItem: React.FC<ListItemProps> = ({
selected,
onClick,
children,
className,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
selected ? "bg-muted/40" : "hover:bg-muted/30",
className
className,
)}
aria-pressed={selected}
>
@ -221,12 +271,16 @@ interface DetailPanelProps {
sticky?: boolean;
}
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => (
export const DetailPanel: React.FC<DetailPanelProps> = ({
children,
className,
sticky = true,
}) => (
<div
className={cn(
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
sticky && "lg:sticky lg:top-24 lg:self-start",
className
className,
)}
>
{children}
@ -244,11 +298,18 @@ interface EmptyStateProps {
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">
{Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />}
<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>}
</div>
);
@ -269,7 +330,10 @@ const getScoreTokens = (score: number) => {
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) {
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))}%` }}
/>
</div>
{showLabel && <span className="tabular-nums text-foreground">{score}%</span>}
{showLabel && (
<span className="tabular-nums text-foreground">{score}%</span>
)}
</div>
);
};
@ -304,7 +370,14 @@ export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
children,
}) => (
<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>
);
@ -318,8 +391,16 @@ interface SectionCardProps {
className?: string;
}
export const SectionCard: React.FC<SectionCardProps> = ({ children, className }) => (
<section className={cn("rounded-xl border border-border/60 bg-card/40 p-4", className)}>
export const SectionCard: React.FC<SectionCardProps> = ({
children,
className,
}) => (
<section
className={cn(
"rounded-xl border border-border/60 bg-card/40 p-4",
className,
)}
>
{children}
</section>
);
@ -334,7 +415,12 @@ interface PageMainProps {
}
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}
</main>
);

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import * as api from '../api';
import type { ResumeProfile } from '../../shared/types';
import { useEffect, useState } from "react";
import type { ResumeProfile } from "../../shared/types";
import * as api from "../api";
let profileCache: ResumeProfile | 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;
/**
@ -12,80 +14,84 @@ let isFetching = false;
* Caches the result to avoid re-fetching.
*/
export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError);
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError);
useEffect(() => {
if (profileCache) {
setProfile(profileCache);
}
if (profileError) {
setError(profileError);
}
useEffect(() => {
if (profileCache) {
setProfile(profileCache);
}
if (profileError) {
setError(profileError);
}
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
setProfile(newProfile);
setError(newError);
};
subscribers.add(handleUpdate);
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;
}
const handleUpdate = (
newProfile: ResumeProfile | null,
newError: Error | null,
) => {
setProfile(newProfile);
setError(newError);
};
return {
profile,
error,
isLoading: !profile && isFetching && !error,
personName: profile?.basics?.name || 'Resume',
refreshProfile,
subscribers.add(handleUpdate);
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 {
profile,
error,
isLoading: !profile && isFetching && !error,
personName: profile?.basics?.name || "Resume",
refreshProfile,
};
}
/** @internal For testing only */
export function _resetProfileCache() {
profileCache = null;
profileError = null;
isFetching = false;
subscribers.clear();
profileCache = null;
profileError = null;
isFetching = false;
subscribers.clear();
}

View File

@ -1,9 +1,8 @@
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 { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { useRescoreJob } from "./useRescoreJob";
vi.mock("../api", () => ({
rescoreJob: vi.fn(),

View File

@ -16,7 +16,10 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
toast.success("Match recalculated");
await onJobUpdated();
} 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);
} finally {
setIsRescoring(false);

View File

@ -1,80 +1,80 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useSettings, _resetSettingsCache } from './useSettings';
import * as api from '../api';
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { _resetSettingsCache, useSettings } from "./useSettings";
vi.mock('../api', () => ({
getSettings: vi.fn(),
vi.mock("../api", () => ({
getSettings: vi.fn(),
}));
describe('useSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
_resetSettingsCache();
describe("useSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
_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 () => {
const mockSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValue(mockSettings);
expect(result.current.showSponsorInfo).toBe(false);
expect(api.getSettings).toHaveBeenCalledTimes(1);
});
const { result } = renderHook(() => useSettings());
it("uses default values when settings are null", async () => {
(api.getSettings as any).mockResolvedValue(null);
// Should start in loading state
expect(result.current.settings).toBeNull();
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settings).toEqual(mockSettings);
});
await waitFor(() => {
// settings is null, so showSponsorInfo should default to true
expect(result.current.showSponsorInfo).toBe(true);
});
});
expect(result.current.showSponsorInfo).toBe(false);
expect(api.getSettings).toHaveBeenCalledTimes(1);
it("provides a refresh function that updates settings", async () => {
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 () => {
(api.getSettings as any).mockResolvedValue(null);
const { result } = renderHook(() => useSettings());
await waitFor(() => {
// settings is null, so showSponsorInfo should default to true
expect(result.current.showSponsorInfo).toBe(true);
});
let refreshed;
await waitFor(async () => {
refreshed = await result.current.refreshSettings();
});
it('provides a refresh function that updates settings', async () => {
const initialSettings = { showSponsorInfo: true };
const updatedSettings = { showSponsorInfo: false };
expect(refreshed).toEqual(updatedSettings);
expect(result.current.settings).toEqual(updatedSettings);
expect(result.current.showSponsorInfo).toBe(false);
});
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
it("handles errors when fetching settings", async () => {
const mockError = new Error("Failed to fetch");
(api.getSettings as any).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings());
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings);
});
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);
await waitFor(() => {
expect(result.current.error).toEqual(mockError);
});
it('handles errors when fetching settings', async () => {
const mockError = new Error('Failed to fetch');
(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();
});
expect(result.current.isLoading).toBe(false);
expect(result.current.settings).toBeNull();
});
});

View File

@ -1,87 +1,93 @@
import { useEffect, useState } from 'react';
import type { AppSettings } from '../../shared/types';
import * as api from '../api';
import { useEffect, useState } from "react";
import type { AppSettings } from "../../shared/types";
import * as api from "../api";
let settingsCache: AppSettings | 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;
export function useSettings() {
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
const [error, setError] = useState<Error | null>(settingsError);
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
const [error, setError] = useState<Error | null>(settingsError);
useEffect(() => {
if (settingsCache) {
setSettings(settingsCache);
}
if (settingsError) {
setError(settingsError);
}
useEffect(() => {
if (settingsCache) {
setSettings(settingsCache);
}
if (settingsError) {
setError(settingsError);
}
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
setSettings(newSettings);
setError(newError);
};
subscribers.add(handleUpdate);
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;
}
const handleUpdate = (
newSettings: AppSettings | null,
newError: Error | null,
) => {
setSettings(newSettings);
setError(newError);
};
return {
settings,
error,
isLoading: !settings && isFetching && !error,
showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings,
subscribers.add(handleUpdate);
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 {
settings,
error,
isLoading: !settings && isFetching && !error,
showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings,
};
}
/** @internal For testing only */
export function _resetSettingsCache() {
settingsCache = null;
settingsError = null;
isFetching = false;
subscribers.clear();
settingsCache = null;
settingsError = null;
isFetching = false;
subscribers.clear();
}

View File

@ -1,13 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import '../index.css';
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "../index.css";
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
</React.StrictMode>,
);

View File

@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { OrchestratorPage } from "./OrchestratorPage";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants";
const jobFixture: Job = {
@ -138,8 +137,12 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
<button onClick={() => onSearchQueryChange("test search")}>
Set Search
</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>
Set Sort
</button>
</div>
),
}));
@ -149,13 +152,27 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({
}));
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 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
</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
</button>
</div>
@ -168,7 +185,9 @@ vi.mock("../components", () => ({
const LocationWatcher = () => {
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", () => {
@ -177,7 +196,9 @@ describe("OrchestratorPage", () => {
});
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(
<MemoryRouter initialEntries={["/ready"]}>
@ -186,7 +207,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("To Discovered"));
@ -194,7 +215,9 @@ describe("OrchestratorPage", () => {
});
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(
<MemoryRouter initialEntries={["/all"]}>
@ -203,7 +226,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
// 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", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
@ -230,15 +255,19 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
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", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
@ -247,15 +276,19 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
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", () => {
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
window.matchMedia = createMatchMedia(
false,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
@ -263,7 +296,7 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
@ -274,7 +307,9 @@ describe("OrchestratorPage", () => {
});
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(
<MemoryRouter initialEntries={["/ready"]}>
@ -282,14 +317,16 @@ describe("OrchestratorPage", () => {
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
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(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
@ -297,11 +334,13 @@ describe("OrchestratorPage", () => {
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByTestId("location").textContent).not.toContain("source=ukvisajobs");
expect(screen.getByTestId("location").textContent).not.toContain(
"source=ukvisajobs",
);
});
});
});

View File

@ -2,18 +2,18 @@
* Orchestrator layout with a split list/detail experience.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useSettings } from "@client/hooks/useSettings";
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 { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { ManualImportSheet } from "../components";
import * as api from "../api";
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 { DEFAULT_SORT } from "./orchestrator/constants";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
@ -22,8 +22,11 @@ import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { useSettings } from "@client/hooks/useSettings";
import { getEnabledSources, getJobCounts, getSourcesWithJobs } from "./orchestrator/utils";
import {
getEnabledSources,
getJobCounts,
getSourcesWithJobs,
} from "./orchestrator/utils";
export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
@ -43,7 +46,9 @@ export const OrchestratorPage: React.FC = () => {
(newTab: string, newJobId?: string | null, isReplace = false) => {
const search = searchParams.toString();
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, searchParams],
@ -65,7 +70,8 @@ export const OrchestratorPage: React.FC = () => {
};
// 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") => {
setSearchParams(
(prev) => {
@ -88,7 +94,10 @@ export const OrchestratorPage: React.FC = () => {
const setSort = (newSort: JobSort) => {
setSearchParams(
(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");
} else {
prev.set("sort", `${newSort.key}-${newSort.direction}`);
@ -107,12 +116,13 @@ export const OrchestratorPage: React.FC = () => {
}
}, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
);
const setActiveTab = (newTab: FilterTab) => {
@ -124,15 +134,35 @@ export const OrchestratorPage: React.FC = () => {
};
const { settings } = useSettings();
const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData();
const enabledSources = useMemo(() => getEnabledSources(settings ?? null), [settings]);
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(enabledSources);
const {
jobs,
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 sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
() =>
selectedJobId
? (jobs.find((job) => job.id === selectedJobId) ?? null)
: null,
[jobs, selectedJobId],
);
@ -175,7 +205,8 @@ export const OrchestratorPage: React.FC = () => {
}, 5000);
} catch (error) {
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);
}
};
@ -237,20 +268,23 @@ export const OrchestratorPage: React.FC = () => {
return (
<>
<OrchestratorHeader
navOpen={navOpen}
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
enabledSources={enabledSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<OrchestratorHeader
navOpen={navOpen}
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
enabledSources={enabledSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<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 */}
<section className="space-y-4">
@ -307,7 +341,9 @@ export const OrchestratorPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}>
<DrawerContent className="max-h-[90vh]">
<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>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close

View File

@ -1,18 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { SettingsPage } from "./SettingsPage"
import * as api from "../api"
import { toast } from "sonner"
import type { AppSettings } from "@shared/types"
import type { AppSettings } from "@shared/types";
import {
fireEvent,
render,
screen,
waitFor,
within,
} 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", () => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(),
}))
}));
vi.mock("sonner", () => ({
toast: {
@ -20,7 +25,7 @@ vi.mock("sonner", () => ({
error: vi.fn(),
info: vi.fn(),
},
}))
}));
const baseSettings: AppSettings = {
model: "google/gemini-3-flash-preview",
@ -104,146 +109,173 @@ const baseSettings: AppSettings = {
ukvisajobsPasswordHint: null,
webhookSecretHint: null,
basicAuthActive: false,
}
};
const renderPage = () => {
return render(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsPage />
</MemoryRouter>
)
}
</MemoryRouter>,
);
};
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
it("saves trimmed model overrides", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
overrideModel: "gpt-4",
model: "gpt-4",
})
});
renderPage()
renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
fireEvent.change(modelInput, { target: { value: " gpt-4 " } })
const modelField =
screen.getByText("Override model").parentElement ??
screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
const saveButton = screen.getByRole("button", { name: /^save$/i })
await waitFor(() => expect(saveButton).toBeEnabled())
const saveButton = screen.getByRole("button", { name: /^save$/i });
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.objectContaining({
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 () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage()
renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
const modelField =
screen.getByText("Override model").parentElement ??
screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
// Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
// 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)
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
})
const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled();
});
it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({
message: "",
count: 2,
});
renderPage()
renderPage();
const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i })
fireEvent.click(dangerTrigger)
const dangerTrigger = await screen.findByRole("button", {
name: /danger zone/i,
});
fireEvent.click(dangerTrigger);
const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i })
fireEvent.click(clearSelectedButton)
const clearSelectedButton = await screen.findByRole("button", {
name: /clear selected/i,
});
fireEvent.click(clearSelectedButton);
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
fireEvent.click(confirmButton)
const confirmButton = await screen.findByRole("button", {
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(
"Jobs cleared",
expect.objectContaining({
description: "Deleted 2 jobs: 2 discovered",
})
)
})
}),
);
});
it("enables save button when model is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled();
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelInput = screen.getByLabelText(/override model/i)
fireEvent.change(modelInput, { target: { value: "new-model" } })
expect(saveButton).toBeEnabled()
})
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/override model/i);
fireEvent.change(modelInput, { target: { value: "new-model" } });
expect(saveButton).toBeEnabled();
});
it("enables save button when numeric setting is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i });
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
fireEvent.click(visaTrigger)
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
fireEvent.change(maxJobsInput, { target: { value: "100" } })
expect(saveButton).toBeEnabled()
})
const visaTrigger = await screen.findByRole("button", {
name: /ukvisajobs extractor/i,
});
fireEvent.click(visaTrigger);
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 () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i });
const displayTrigger = await screen.findByRole("button", { name: /display settings/i })
fireEvent.click(displayTrigger)
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i)
fireEvent.click(sponsorCheckbox)
expect(saveButton).toBeEnabled()
})
const displayTrigger = await screen.findByRole("button", {
name: /display settings/i,
});
fireEvent.click(displayTrigger);
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 () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i });
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
fireEvent.click(envTrigger)
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
fireEvent.click(authCheckbox)
expect(saveButton).toBeEnabled()
})
const envTrigger = await screen.findByRole("button", {
name: /environment & accounts/i,
});
fireEvent.click(envTrigger);
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 () => {
// Initial state: Basic Auth is active
@ -252,32 +284,34 @@ describe("SettingsPage", () => {
basicAuthActive: true,
basicAuthUser: "admin",
basicAuthPasswordHint: "pass",
}
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
};
vi.mocked(api.getSettings).mockResolvedValue(activeSettings);
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings);
renderPage()
renderPage();
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
fireEvent.click(envTrigger)
const envTrigger = await screen.findByRole("button", {
name: /environment & accounts/i,
});
fireEvent.click(envTrigger);
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
expect(authCheckbox).toBeChecked()
const authCheckbox = screen.getByLabelText(/enable basic authentication/i);
expect(authCheckbox).toBeChecked();
// Disable it
fireEvent.click(authCheckbox)
expect(authCheckbox).not.toBeChecked()
fireEvent.click(authCheckbox);
expect(authCheckbox).not.toBeChecked();
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeEnabled()
fireEvent.click(saveButton)
const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeEnabled();
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
basicAuthUser: null,
basicAuthPassword: null,
})
)
})
})
}),
);
});
});

View File

@ -1,27 +1,35 @@
import React, { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { PageHeader } from "@client/components/layout"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "@client/api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "@client/pages/settings/utils"
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
import { JobspySection } from "@client/pages/settings/components/JobspySection"
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"
import * as api from "@client/api";
import { PageHeader } from "@client/components/layout";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection";
import { JobspySection } from "@client/pages/settings/components/JobspySection";
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
import { resumeProjectsEqual } from "@client/pages/settings/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import {
type UpdateSettingsInput,
updateSettingsSchema,
} from "@shared/settings-schema";
import type {
AppSettings,
JobStatus,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
} 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 = {
model: "",
@ -51,7 +59,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: false,
}
};
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null,
@ -81,7 +89,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
ukvisajobsPassword: null,
webhookSecret: null,
enableBasicAuth: undefined,
}
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "",
@ -111,71 +119,86 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: data.basicAuthActive,
})
});
const normalizeString = (value: string | null | undefined) => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
const normalizePrivateInput = (value: string | null | undefined) => {
const trimmed = value?.trim()
if (trimmed === "") return null
return trimmed || undefined
}
const trimmed = value?.trim();
if (trimmed === "") return null;
return trimmed || undefined;
};
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left, right)
}
const isSameStringList = (
left: string[] | null | undefined,
right: string[] | null | undefined,
) => {
if (!left && !right) return true;
if (!left || !right) return false;
return arraysEqual(left, right);
};
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left.slice().sort(), right.slice().sort())
}
const isSameSortedStringList = (
left: string[] | null | undefined,
right: string[] | null | undefined,
) => {
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) =>
value === defaultValue ? null : value ?? null
value === defaultValue ? null : (value ?? null);
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameStringList(value, defaultValue) ? null : value ?? null
const nullIfSameList = (
value: string[] | null | undefined,
defaultValue: string[],
) => (isSameStringList(value, defaultValue) ? null : (value ?? null));
const normalizeResumeProjectsForCatalog = (
catalog: ResumeProjectCatalogItem[],
current: ResumeProjectsSettings | null
current: 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 ?? {
maxProjects: 0,
lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id),
lockedProjectIds: catalog
.filter((project) => project.isVisibleInBase)
.map((project) => project.id),
aiSelectableProjectIds: [],
}
};
const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id))
const lockedSet = new Set(lockedProjectIds)
const aiSelectableProjectIds = (current
? base.aiSelectableProjectIds
: catalog.map((project) => project.id)
const lockedProjectIds = base.lockedProjectIds.filter((id) =>
allowed.has(id),
);
const lockedSet = new Set(lockedProjectIds);
const aiSelectableProjectIds = (
current ? base.aiSelectableProjectIds : catalog.map((project) => project.id)
)
.filter((id) => allowed.has(id))
.filter((id) => !lockedSet.has(id))
const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw))
.filter((id) => !lockedSet.has(id));
const maxProjectsRaw = Number.isFinite(base.maxProjects)
? base.maxProjects
: 0;
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
const maxProjects = Math.min(
catalog.length,
Math.max(lockedProjectIds.length, maxProjectsInt, 3)
)
return { maxProjects, lockedProjectIds, aiSelectableProjectIds }
}
Math.max(lockedProjectIds.length, maxProjectsInt, 3),
);
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
};
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameSortedStringList(value, defaultValue) ? null : value ?? null
const nullIfSameSortedList = (
value: string[] | null | undefined,
defaultValue: string[],
) => (isSameSortedStringList(value, defaultValue) ? null : (value ?? null));
const getDerivedSettings = (settings: AppSettings | null) => {
const profileProjects = settings?.profileProjects ?? []
const profileProjects = settings?.profileProjects ?? [];
return {
model: {
@ -254,23 +277,30 @@ const getDerivedSettings = (settings: AppSettings | null) => {
profileProjects,
maxProjectsTotal: profileProjects.length,
}
}
};
};
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<ResumeProjectCatalogItem[] | null>(null)
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false)
const [settings, setSettings] = useState<AppSettings | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
"discovered",
]);
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<
string | null
>(null);
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<
ResumeProjectCatalogItem[] | null
>(null);
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
useState(false);
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES,
})
});
const {
handleSubmit,
@ -279,94 +309,99 @@ export const SettingsPage: React.FC = () => {
setValue,
getValues,
watch,
formState: { isDirty, errors, isValid, dirtyFields }
} = methods
formState: { isDirty, errors, isValid, dirtyFields },
} = methods;
const hasRxResumeAccess = Boolean(
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint
)
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
);
useEffect(() => {
let isMounted = true
setIsLoading(true)
let isMounted = true;
setIsLoading(true);
api
.getSettings()
.then((data) => {
if (!isMounted) return
setSettings(data)
reset(mapSettingsToForm(data))
if (!isMounted) return;
setSettings(data);
reset(mapSettingsToForm(data));
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
toast.error(message)
const message =
error instanceof Error ? error.message : "Failed to load settings";
toast.error(message);
})
.finally(() => {
if (!isMounted) return
setIsLoading(false)
})
if (!isMounted) return;
setIsLoading(false);
});
return () => {
isMounted = false
}
}, [reset])
isMounted = false;
};
}, [reset]);
useEffect(() => {
if (!settings) return
const storedId = settings.rxresumeBaseResumeId ?? null
setRxResumeBaseResumeIdDraft(storedId)
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false })
setRxResumeProjectsOverride(null)
}, [settings, setValue])
if (!settings) return;
const storedId = settings.rxresumeBaseResumeId ?? null;
setRxResumeBaseResumeIdDraft(storedId);
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
setRxResumeProjectsOverride(null);
}, [settings, setValue]);
useEffect(() => {
let isMounted = true
const controller = new AbortController()
let isMounted = true;
const controller = new AbortController();
if (!rxResumeBaseResumeIdDraft) {
setRxResumeProjectsOverride(null)
setRxResumeProjectsOverride(null);
return () => {
isMounted = false
controller.abort()
}
isMounted = false;
controller.abort();
};
}
if (!hasRxResumeAccess) return () => {
isMounted = false
controller.abort()
}
if (!hasRxResumeAccess)
return () => {
isMounted = false;
controller.abort();
};
setIsFetchingRxResumeProjects(true)
setIsFetchingRxResumeProjects(true);
api
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
.then((projects) => {
if (!isMounted) return
setRxResumeProjectsOverride(projects)
if (!isMounted) return;
setRxResumeProjectsOverride(projects);
const normalized = normalizeResumeProjectsForCatalog(
projects,
getValues("resumeProjects") ?? null
)
getValues("resumeProjects") ?? null,
);
if (normalized) {
setValue("resumeProjects", normalized, { shouldDirty: true })
setValue("resumeProjects", normalized, { shouldDirty: true });
}
})
.catch((error) => {
if (!isMounted || error.name === 'AbortError') return
const message = error instanceof Error ? error.message : "Failed to load RxResume projects"
toast.error(message)
setRxResumeProjectsOverride(null)
if (!isMounted || error.name === "AbortError") return;
const message =
error instanceof Error
? error.message
: "Failed to load RxResume projects";
toast.error(message);
setRxResumeProjectsOverride(null);
})
.finally(() => {
if (!isMounted) return
setIsFetchingRxResumeProjects(false)
})
if (!isMounted) return;
setIsFetchingRxResumeProjects(false);
});
return () => {
isMounted = false
controller.abort()
}
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
isMounted = false;
controller.abort();
};
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]);
const derived = getDerivedSettings(settings)
const derived = getDerivedSettings(settings);
const {
model,
pipelineWebhook,
@ -380,79 +415,87 @@ export const SettingsPage: React.FC = () => {
defaultResumeProjects,
profileProjects,
maxProjectsTotal,
} = derived
} = derived;
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
const watchedValues = watch()
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
const watchedValues = watch();
const lockedCount =
watchedValues.resumeProjects?.lockedProjectIds.length ?? 0;
const canSave = isDirty && isValid
const canSave = isDirty && isValid;
const onSave = async (data: UpdateSettingsInput) => {
if (!settings) return
if (!settings) return;
if (data.enableBasicAuth && !settings.basicAuthActive) {
const password = data.basicAuthPassword?.trim() ?? ""
const password = data.basicAuthPassword?.trim() ?? "";
if (!password) {
setError("basicAuthPassword", {
type: "manual",
message: "Password is required when basic auth is enabled",
})
return
});
return;
}
}
try {
setIsSaving(true)
setIsSaving(true);
// Prepare payload: nullify if equal to default
const resumeProjectsData = data.resumeProjects
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
? null
: resumeProjectsData
const resumeProjectsData = data.resumeProjects;
const resumeProjectsOverride =
resumeProjectsData &&
defaultResumeProjects &&
resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)
? null
: resumeProjectsData;
const envPayload: Partial<UpdateSettingsInput> = {}
const envPayload: Partial<UpdateSettingsInput> = {};
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
}
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
}
if (data.enableBasicAuth === false) {
envPayload.basicAuthUser = null
envPayload.basicAuthPassword = null
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) {
envPayload.basicAuthUser = null;
envPayload.basicAuthPassword = null;
} else if (
dirtyFields.enableBasicAuth ||
dirtyFields.basicAuthUser ||
dirtyFields.basicAuthPassword
) {
// If enabling basic auth or changing either field, ensure we send at least the username
// to keep the pair consistent in the backend.
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
envPayload.basicAuthUser = normalizeString(data.basicAuthUser);
if (dirtyFields.basicAuthPassword) {
const value = normalizePrivateInput(data.basicAuthPassword)
if (value !== undefined) envPayload.basicAuthPassword = value
const value = normalizePrivateInput(data.basicAuthPassword);
if (value !== undefined) envPayload.basicAuthPassword = value;
}
}
if (dirtyFields.openrouterApiKey) {
const value = normalizePrivateInput(data.openrouterApiKey)
if (value !== undefined) envPayload.openrouterApiKey = value
const value = normalizePrivateInput(data.openrouterApiKey);
if (value !== undefined) envPayload.openrouterApiKey = value;
}
if (dirtyFields.rxresumePassword) {
const value = normalizePrivateInput(data.rxresumePassword)
if (value !== undefined) envPayload.rxresumePassword = value
const value = normalizePrivateInput(data.rxresumePassword);
if (value !== undefined) envPayload.rxresumePassword = value;
}
if (dirtyFields.ukvisajobsPassword) {
const value = normalizePrivateInput(data.ukvisajobsPassword)
if (value !== undefined) envPayload.ukvisajobsPassword = value
const value = normalizePrivateInput(data.ukvisajobsPassword);
if (value !== undefined) envPayload.ukvisajobsPassword = value;
}
if (dirtyFields.webhookSecret) {
const value = normalizePrivateInput(data.webhookSecret)
if (value !== undefined) envPayload.webhookSecret = value
const value = normalizePrivateInput(data.webhookSecret);
if (value !== undefined) envPayload.webhookSecret = value;
}
const payload: UpdateSettingsInput = {
@ -464,108 +507,135 @@ export const SettingsPage: React.FC = () => {
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
ukvisajobsMaxJobs: nullIfSame(
data.ukvisajobsMaxJobs,
ukvisajobs.default,
),
gradcrackerMaxJobsPerTerm: nullIfSame(
data.gradcrackerMaxJobsPerTerm,
gradcracker.default,
),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.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),
jobspyLocation: nullIfSame(
data.jobspyLocation,
jobspy.location.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(
data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default
jobspy.linkedinFetchDescription.default,
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
...envPayload,
}
};
// Remove virtual field because the backend doesn't expect it
// this exists only to toggle the UI
// 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)
setSettings(updated)
reset(mapSettingsToForm(updated))
toast.success("Settings saved")
const updated = await api.updateSettings(payload);
setSettings(updated);
reset(mapSettingsToForm(updated));
toast.success("Settings saved");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
toast.error(message)
const message =
error instanceof Error ? error.message : "Failed to save settings";
toast.error(message);
} finally {
setIsSaving(false)
setIsSaving(false);
}
}
};
const handleClearDatabase = async () => {
try {
setIsSaving(true)
const result = await api.clearDatabase()
toast.success("Database cleared", { description: `Deleted ${result.jobsDeleted} jobs.` })
setIsSaving(true);
const result = await api.clearDatabase();
toast.success("Database cleared", {
description: `Deleted ${result.jobsDeleted} jobs.`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to clear database"
toast.error(message)
const message =
error instanceof Error ? error.message : "Failed to clear database";
toast.error(message);
} finally {
setIsSaving(false)
setIsSaving(false);
}
}
};
const handleClearByStatuses = async () => {
if (statusesToClear.length === 0) {
toast.error("No statuses selected")
return
toast.error("No statuses selected");
return;
}
try {
setIsSaving(true)
let totalDeleted = 0
const results: string[] = []
setIsSaving(true);
let totalDeleted = 0;
const results: string[] = [];
for (const status of statusesToClear) {
const result = await api.deleteJobsByStatus(status)
totalDeleted += result.count
const result = await api.deleteJobsByStatus(status);
totalDeleted += result.count;
if (result.count > 0) {
results.push(`${result.count} ${status}`)
results.push(`${result.count} ${status}`);
}
}
if (totalDeleted > 0) {
toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
})
description: `Deleted ${totalDeleted} jobs: ${results.join(", ")}`,
});
} else {
toast.info("No jobs found", {
description: `No jobs with selected statuses found`,
})
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to clear jobs"
toast.error(message)
const message =
error instanceof Error ? error.message : "Failed to clear jobs";
toast.error(message);
} finally {
setIsSaving(false)
setIsSaving(false);
}
}
};
const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear(prev =>
setStatusesToClear((prev) =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
)
}
? prev.filter((s) => s !== status)
: [...prev, status],
);
};
const handleReset = async () => {
try {
setIsSaving(true)
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
setSettings(updated)
reset(mapSettingsToForm(updated))
toast.success("Reset to default")
setIsSaving(true);
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD);
setSettings(updated);
reset(mapSettingsToForm(updated));
toast.success("Reset to default");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
toast.error(message)
const message =
error instanceof Error ? error.message : "Failed to reset settings";
toast.error(message);
} finally {
setIsSaving(false)
setIsSaving(false);
}
}
};
return (
<FormProvider {...methods}>
@ -612,8 +682,8 @@ export const SettingsPage: React.FC = () => {
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={(value) => {
setRxResumeBaseResumeIdDraft(value)
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
setRxResumeBaseResumeIdDraft(value);
setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
}}
hasRxResumeAccess={hasRxResumeAccess}
profileProjects={effectiveProfileProjects}
@ -644,10 +714,17 @@ export const SettingsPage: React.FC = () => {
</Accordion>
<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"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || isSaving || !settings}
>
Reset to default
</Button>
</div>
@ -658,5 +735,5 @@ export const SettingsPage: React.FC = () => {
)}
</main>
</FormProvider>
)
}
);
};

View File

@ -2,7 +2,6 @@
* UK Visa Jobs search page.
*/
import React, { useEffect, useMemo, useState } from "react";
import {
Briefcase,
Calendar,
@ -21,15 +20,17 @@ import {
Settings,
Shield,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import {
Sheet,
SheetContent,
@ -38,10 +39,11 @@ import {
SheetTrigger,
} from "@/components/ui/sheet";
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
import * as api from "../api";
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;
@ -71,8 +73,10 @@ export const UkVisaJobsPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
);
useEffect(() => {
@ -81,7 +85,10 @@ export const UkVisaJobsPage: React.FC = () => {
return;
}
const firstKey = jobKey(results[0]);
if (!selectedJobId || !results.some((job) => jobKey(job) === selectedJobId)) {
if (
!selectedJobId ||
!results.some((job) => jobKey(job) === selectedJobId)
) {
setSelectedJobId(firstKey);
}
}, [results, selectedJobId]);
@ -116,7 +123,10 @@ export const UkVisaJobsPage: React.FC = () => {
}, [results]);
const selectedJob = useMemo(
() => (selectedJobId ? results.find((job) => jobKey(job) === selectedJobId) ?? null : null),
() =>
selectedJobId
? (results.find((job) => jobKey(job) === selectedJobId) ?? null)
: null,
[results, selectedJobId],
);
@ -129,7 +139,13 @@ export const UkVisaJobsPage: React.FC = () => {
};
}, [page, pageSize, totalJobs]);
const runSearch = async ({ term, pageNumber }: { term: string | null; pageNumber: number }) => {
const runSearch = async ({
term,
pageNumber,
}: {
term: string | null;
pageNumber: number;
}) => {
try {
setIsSearching(true);
setErrorMessage(null);
@ -147,7 +163,8 @@ export const UkVisaJobsPage: React.FC = () => {
toast.message("No UK Visa Jobs found for this search.");
}
} 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);
toast.error(message);
} finally {
@ -184,18 +201,23 @@ export const UkVisaJobsPage: React.FC = () => {
};
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;
try {
setIsImporting(true);
const response = await api.importUkVisaJobs({ jobs: selectedJobs });
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());
} 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);
} finally {
setIsImporting(false);
@ -208,12 +230,24 @@ export const UkVisaJobsPage: React.FC = () => {
return cleaned || "No description available.";
}, [selectedJob]);
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
const selectedPosted = selectedJob ? formatDate(selectedJob.datePosted) : null;
const selectedJobLink = selectedJob
? selectedJob.applicationLink || selectedJob.jobUrl
: "#";
const selectedDeadline = selectedJob
? formatDate(selectedJob.deadline)
: null;
const selectedPosted = selectedJob
? formatDate(selectedJob.datePosted)
: null;
const selectedCount = selectedJobIds.size;
const allSelected = results.length > 0 && results.every((job) => selectedJobIds.has(jobKey(job)));
const selectAllState = allSelected ? true : selectedCount > 0 ? "indeterminate" : false;
const allSelected =
results.length > 0 &&
results.every((job) => selectedJobIds.has(jobKey(job)));
const selectAllState = allSelected
? true
: selectedCount > 0
? "indeterminate"
: false;
const canGoPrev = page > 1;
const canGoNext = page < totalPages;
@ -227,14 +261,20 @@ export const UkVisaJobsPage: React.FC = () => {
const detailPanelContent = !selectedJob ? (
<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>
<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 className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
<div className="truncate text-base font-semibold">
{selectedJob.title}
</div>
<div className="text-sm text-muted-foreground">
{selectedJob.employer}
</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
UK Visa Jobs
@ -295,7 +335,12 @@ export const UkVisaJobsPage: React.FC = () => {
<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">
<ExternalLink className="h-4 w-4" />
View job
@ -344,9 +389,16 @@ export const UkVisaJobsPage: React.FC = () => {
}}
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",
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"
: "text-muted-foreground"
: "text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
@ -361,8 +413,12 @@ export const UkVisaJobsPage: React.FC = () => {
<Briefcase className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
<div className="text-xs text-muted-foreground">Live search console</div>
<div className="text-sm font-semibold tracking-tight">
UK Visa Jobs
</div>
<div className="text-xs text-muted-foreground">
Live search console
</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">
<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">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job title search term
@ -382,13 +441,22 @@ export const UkVisaJobsPage: React.FC = () => {
className="h-10"
/>
<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>
</div>
<div className="flex items-end">
<Button 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" />}
<Button
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"}
</Button>
</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>
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
Last run:{" "}
{lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
@ -416,7 +485,9 @@ export const UkVisaJobsPage: React.FC = () => {
<span>
Page {page} of {totalPages}
</span>
{lastSearchTerm && <span className="truncate">Term: {lastSearchTerm}</span>}
{lastSearchTerm && (
<span className="truncate">Term: {lastSearchTerm}</span>
)}
</div>
</div>
</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">
Run a search to fetch fresh UK Visa Jobs listings.
</p>
@ -456,7 +529,9 @@ export const UkVisaJobsPage: React.FC = () => {
checked={selectAllState}
onCheckedChange={(checked) => {
if (checked === true) {
setSelectedJobIds(new Set(results.map((job) => jobKey(job))));
setSelectedJobIds(
new Set(results.map((job) => jobKey(job))),
);
} else {
setSelectedJobIds(new Set());
}
@ -474,7 +549,11 @@ export const UkVisaJobsPage: React.FC = () => {
onClick={handleImportSelected}
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"}
</Button>
</div>
@ -483,7 +562,9 @@ export const UkVisaJobsPage: React.FC = () => {
const key = jobKey(job);
const isSelected = key === selectedJobId;
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 (
<div
@ -530,10 +611,16 @@ export const UkVisaJobsPage: React.FC = () => {
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
<div className="truncate text-sm font-semibold">
{job.title}
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
</div>
</div>
<div className="text-xs text-muted-foreground">
{description}
</div>
<div className="text-xs text-muted-foreground">{description}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
@ -556,12 +643,18 @@ export const UkVisaJobsPage: React.FC = () => {
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel}
</Badge>
)}
@ -573,7 +666,8 @@ export const UkVisaJobsPage: React.FC = () => {
</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">
<span>
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex}{" "}
of {totalJobs}
</span>
<div className="flex flex-wrap items-center gap-2">
<Button
@ -614,7 +708,9 @@ export const UkVisaJobsPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]">
<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>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close

View File

@ -3,7 +3,6 @@
* Allows searching the government's list of licensed visa sponsors.
*/
import React, { useEffect, useState, useCallback, useMemo } from "react";
import {
AlertCircle,
Building2,
@ -18,34 +17,38 @@ import {
Shield,
X,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
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 {
VisaSponsor,
VisaSponsorSearchResult,
VisaSponsorStatusResponse,
} from "../../shared/types";
import * as api from "../api";
import {
DetailPanel,
EmptyState,
ListItem,
ListPanel,
PageHeader,
PageMain,
ScoreMeter,
SplitLayout,
StatusIndicator,
} from "../components";
const getScoreTokens = (score: number) => {
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)
return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" };
if (score >= 50)
@ -67,8 +70,10 @@ export const VisaSponsorsPage: React.FC = () => {
const [isUpdating, setIsUpdating] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
);
// Fetch status on mount
@ -82,7 +87,8 @@ export const VisaSponsorsPage: React.FC = () => {
const data = await api.getVisaSponsorStatus();
setStatus(data);
} 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);
} finally {
setIsLoadingStatus(false);
@ -129,7 +135,10 @@ export const VisaSponsorsPage: React.FC = () => {
setOrgDetails([]);
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;
setSelectedOrg(firstOrg);
fetchOrgDetails(firstOrg);
@ -169,7 +178,8 @@ export const VisaSponsorsPage: React.FC = () => {
const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details);
} 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);
setOrgDetails([]);
} finally {
@ -203,8 +213,9 @@ export const VisaSponsorsPage: React.FC = () => {
};
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;
@ -233,7 +244,7 @@ export const VisaSponsorsPage: React.FC = () => {
<span
className={cn(
"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
@ -244,17 +255,20 @@ export const VisaSponsorsPage: React.FC = () => {
</div>
{/* Location */}
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
Location
{orgDetails.length > 0 &&
(orgDetails[0].townCity || orgDetails[0].county) && (
<div>
<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 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 */}
<div>
@ -273,7 +287,9 @@ export const VisaSponsorsPage: React.FC = () => {
</Badge>
</div>
<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}
</div>
</div>
@ -283,10 +299,13 @@ export const VisaSponsorsPage: React.FC = () => {
{/* Info box */}
<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">
This organisation is licensed by the UK Home Office to sponsor workers on the
routes listed above. An "A rating" means they're fully compliant.
This organisation is licensed by the UK Home Office to sponsor workers
on the routes listed above. An "A rating" means they're fully
compliant.
</p>
</div>
</div>
@ -298,7 +317,9 @@ export const VisaSponsorsPage: React.FC = () => {
icon={Shield}
title="Visa Sponsors"
subtitle="UK Register Search"
statusIndicator={isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined}
statusIndicator={
isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined
}
actions={
<>
{status && (
@ -356,7 +377,8 @@ export const VisaSponsorsPage: React.FC = () => {
)}
</div>
<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>
</div>
</section>
@ -383,7 +405,11 @@ export const VisaSponsorsPage: React.FC = () => {
title="No sponsor data available"
description="The visa sponsor list hasn't been downloaded yet."
action={
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}>
<Button
size="sm"
onClick={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -421,7 +447,9 @@ export const VisaSponsorsPage: React.FC = () => {
<ListItem
key={`${result.sponsor.organisationName}-${index}`}
selected={selectedOrg === result.sponsor.organisationName}
onClick={() => handleSelectOrg(result.sponsor.organisationName)}
onClick={() =>
handleSelectOrg(result.sponsor.organisationName)
}
className="gap-3"
>
<div className="flex-1 min-w-0">
@ -458,7 +486,9 @@ export const VisaSponsorsPage: React.FC = () => {
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]">
<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>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close

View File

@ -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 { JobDetailPanel } from "./JobDetailPanel";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types";
import * as api from "../../api";
import { JobDetailPanel } from "./JobDetailPanel";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
@ -19,7 +24,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
<button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children}
</button>
),
@ -154,14 +164,12 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
/>,
);
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
});
it("shows an empty state when no job is selected", () => {
render(
<JobDetailPanel
@ -171,7 +179,7 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
/>,
);
expect(screen.getByText("No job selected")).toBeInTheDocument();
@ -182,11 +190,13 @@ describe("JobDetailPanel", () => {
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })}
selectedJob={createJob({
jobDescription: "<p>Hello <strong>world</strong></p>",
})}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
/>,
);
expect(screen.getByText("Hello world")).toBeInTheDocument();
@ -204,7 +214,7 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()}
/>
/>,
);
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
@ -217,7 +227,9 @@ describe("JobDetailPanel", () => {
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
expect(api.updateJob).toHaveBeenCalledWith("job-1", {
jobDescription: "Updated description",
}),
);
expect(onJobUpdated).toHaveBeenCalled();
});
@ -234,12 +246,14 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()}
/>
/>,
);
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();
});
@ -255,10 +269,12 @@ describe("JobDetailPanel", () => {
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
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 });
fireEvent.click(skipItem);

View File

@ -1,4 +1,3 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CheckCircle2,
Copy,
@ -11,6 +10,8 @@ import {
Save,
XCircle,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
@ -25,14 +26,23 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils";
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
import {
copyTextToClipboard,
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 { TailoringEditor } from "../../components/TailoringEditor";
import { useProfile } from "../../hooks/useProfile";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import type { FilterTab } from "./constants";
interface JobDetailPanelProps {
@ -52,7 +62,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
onJobUpdated,
onSetActiveTab,
}) => {
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview");
const [detailTab, setDetailTab] = useState<
"overview" | "tailoring" | "description"
>("overview");
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedDescription, setEditedDescription] = useState("");
const [isSavingDescription, setIsSavingDescription] = useState(false);
@ -95,12 +107,15 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return;
try {
setIsSavingDescription(true);
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
await api.updateJob(selectedJob.id, {
jobDescription: editedDescription,
});
toast.success("Job description updated");
setIsEditingDescription(false);
await onJobUpdated();
} 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);
} finally {
setIsSavingDescription(false);
@ -113,7 +128,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
editedDescription !== (selectedJob.jobDescription || "");
const confirmAndSaveEdits = useCallback(
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => {
async ({
includeTailoring = true,
}: {
includeTailoring?: boolean;
} = {}) => {
const pendingDescription = hasUnsavedDescription;
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
@ -128,7 +147,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
try {
if (pendingDescription && selectedJob) {
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
await api.updateJob(selectedJob.id, {
jobDescription: editedDescription,
});
}
if (pendingTailoring) {
@ -140,20 +161,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
await saveTailoring();
}
} 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);
return false;
}
return true;
},
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob],
[
editedDescription,
hasUnsavedDescription,
hasUnsavedTailoring,
selectedJob,
],
);
const handleProcess = async () => {
if (!selectedJob) return;
try {
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true });
const shouldProceed = await confirmAndSaveEdits({
includeTailoring: true,
});
if (!shouldProceed) return;
setProcessingJobId(selectedJob.id);
@ -167,7 +196,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
}
await onJobUpdated();
} 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);
} finally {
setProcessingJobId(null);
@ -181,7 +211,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
toast.success("Marked as applied");
await onJobUpdated();
} 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);
}
};
@ -193,7 +224,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
toast.message("Job skipped");
await onJobUpdated();
} 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);
}
};
@ -202,7 +234,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return;
try {
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 {
toast.error("Could not copy job info");
}
@ -211,24 +245,32 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleJobMoved = useCallback(
(jobId: string) => {
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);
},
[activeJobs, onSelectJobId],
);
const selectedHasPdf = !!selectedJob?.pdfPath;
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
const selectedJobLink = selectedJob
? selectedJob.applicationLink || selectedJob.jobUrl
: "#";
const selectedPdfHref = selectedJob
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
: "#";
const canApply = selectedJob?.status === "ready";
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
const canProcess = selectedJob
? ["discovered", "ready"].includes(selectedJob.status)
: false;
const canSkip = selectedJob
? ["discovered", "ready"].includes(selectedJob.status)
: false;
const showReadyPdf = activeTab === "ready";
const showGeneratePdf = activeTab === "discovered";
const isProcessingSelected =
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
const isProcessingSelected = selectedJob
? processingJobId === selectedJob.id || selectedJob.status === "processing"
: false;
if (activeTab === "discovered") {
return (
@ -253,8 +295,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) {
return (
<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>
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
<div className="text-sm font-medium text-muted-foreground">
No job selected
</div>
<p className="text-xs text-muted-foreground/70">
Select a job to view details
</p>
</div>
);
}
@ -270,7 +316,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
/>
<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">
<ExternalLink className="h-3.5 w-3.5" />
View
@ -279,14 +330,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
{showReadyPdf &&
(selectedHasPdf ? (
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
<Button
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" />
PDF
</a>
</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" />
PDF
</Button>
@ -357,7 +422,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<>
{!showReadyPdf && (
<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" />
View PDF
</a>
@ -366,7 +435,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<DropdownMenuItem asChild>
<a
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" />
Download PDF
@ -389,11 +458,20 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</DropdownMenuContent>
</DropdownMenu>
</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">
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
<TabsTrigger value="overview" className="text-xs">
Overview
</TabsTrigger>
<TabsTrigger value="tailoring" className="text-xs">
Tailoring
</TabsTrigger>
<TabsTrigger value="description" className="text-xs">
Description
</TabsTrigger>
</TabsList>
<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>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
Discipline
</div>
<div className="text-foreground/80">
{selectedJob.disciplines || "-"}
</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
Function
</div>
<div className="text-foreground/80">
{selectedJob.jobFunction || "-"}
</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
Level
</div>
<div className="text-foreground/80">
{selectedJob.jobLevel || "-"}
</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
Type
</div>
<div className="text-foreground/80">
{selectedJob.jobType || "-"}
</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"
onClick={() => setDetailTab("description")}
>
View full description 
View full description 
</button>
</div>
</div>
@ -447,7 +541,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
onRegisterSave={(save) => {
saveTailoringRef.current = save;
}}
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
onBeforeGenerate={() =>
confirmAndSaveEdits({ includeTailoring: false })
}
/>
</TabsContent>
@ -499,14 +595,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
)}
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void copyTextToClipboard(selectedJob.jobDescription || "");
void copyTextToClipboard(
selectedJob.jobDescription || "",
);
toast.success("Copied raw description");
}}
>
@ -555,7 +658,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</div>
) : (
<div className="whitespace-pre-wrap leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{description}
</ReactMarkdown>
</div>
)}
</div>

View File

@ -1,8 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { JobListPanel } from "./JobListPanel";
import { describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types";
import { JobListPanel } from "./JobListPanel";
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
@ -76,7 +75,7 @@ describe("JobListPanel", () => {
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
/>
/>,
);
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
@ -92,11 +91,13 @@ describe("JobListPanel", () => {
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
/>
/>,
);
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", () => {
@ -109,7 +110,7 @@ describe("JobListPanel", () => {
activeTab="ready"
searchQuery="iOS"
onSelectJob={vi.fn()}
/>
/>,
);
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
@ -119,7 +120,11 @@ describe("JobListPanel", () => {
const onSelectJob = vi.fn();
const jobs = [
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(
@ -131,10 +136,12 @@ describe("JobListPanel", () => {
activeTab="ready"
searchQuery=""
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 }));
expect(onSelectJob).toHaveBeenCalledWith("job-2");

View File

@ -1,11 +1,11 @@
import React from "react";
import { Loader2 } from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils";
import type { Job } from "../../../shared/types";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
import type { FilterTab } from "./constants";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
interface JobListPanelProps {
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="text-base font-semibold">No jobs found</div>
<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>
</div>
) : (
@ -81,7 +83,11 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && <span className="before:content-['_in_']">{job.location}</span>}
{job.location && (
<span className="before:content-['_in_']">
{job.location}
</span>
)}
</div>
</div>

View File

@ -1,20 +1,26 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { OrchestratorFilters } from "./OrchestratorFilters";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters";
vi.mock("@/components/ui/dropdown-menu", () => {
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 {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
@ -23,11 +29,18 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
<button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuRadioGroup: ({
children,
@ -49,7 +62,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}) => {
const onValueChange = React.useContext(RadioGroupContext);
return (
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}>
<button
type="button"
role="menuitemradio"
onClick={() => onValueChange?.(value)}
>
{children}
</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 = {
activeTab: "ready" as FilterTab,
onTabChange: vi.fn(),
@ -90,7 +109,9 @@ describe("OrchestratorFilters", () => {
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
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");
});
@ -98,12 +119,19 @@ describe("OrchestratorFilters", () => {
const { props } = renderFilters();
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");
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
fireEvent.click(
await screen.findByRole("menuitem", { name: /Direction:/i }),
);
expect(props.onSortChange).toHaveBeenCalledWith({
key: "score",
direction: "asc",
});
});
it("only shows sources that exist in jobs", async () => {
@ -111,9 +139,17 @@ describe("OrchestratorFilters", () => {
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
expect(await screen.findByRole("menuitemradio", { name: /Gradcracker/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();
expect(
await screen.findByRole("menuitemradio", { name: /Gradcracker/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();
});
});

View File

@ -1,5 +1,5 @@
import React from "react";
import { ArrowUpDown, Filter, Search } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import {
@ -17,8 +17,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types";
import { defaultSortDirection, orderedFilterSources, sortLabels, tabs } from "./constants";
import type { FilterTab, JobSort } from "./constants";
import {
defaultSortDirection,
orderedFilterSources,
sortLabels,
tabs,
} from "./constants";
interface OrchestratorFiltersProps {
activeTab: FilterTab;
@ -45,104 +50,124 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
sort,
onSortChange,
}) => {
const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
const visibleSources = orderedFilterSources.filter((source) =>
sourcesWithJobs.includes(source),
);
return (
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
<span>{tab.label}</span>
{counts[tab.id] > 0 && (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
)}
</TabsTrigger>
))}
</TabsList>
<Tabs
value={activeTab}
onValueChange={(value) => onTabChange(value as FilterTab)}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="flex-1 flex items-center lg:flex-none gap-1.5"
>
<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="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" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="Search..."
className="h-8 pl-8 text-sm"
/>
<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">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="Search..."
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>
<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>
</Tabs>
);
</Tabs>
);
};

View File

@ -1,7 +1,7 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { OrchestratorHeader } from "./OrchestratorHeader";
@ -9,16 +9,32 @@ vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react");
return {
DropdownMenu: ({ 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>,
DropdownMenu: ({ 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" />,
DropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: (event: Event) => void }) => (
DropdownMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect?: (event: Event) => void;
}) => (
<button
type="button"
role="menuitem"
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}
onClick={() =>
onSelect?.({ preventDefault: () => {} } as unknown as Event)
}
>
{children}
</button>
@ -30,7 +46,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
}) => (
<button type="button" role="menuitemcheckbox" onClick={() => onCheckedChange?.(true)}>
<button
type="button"
role="menuitemcheckbox"
onClick={() => onCheckedChange?.(true)}
>
{children}
</button>
),
@ -39,13 +59,23 @@ vi.mock("@/components/ui/dropdown-menu", () => {
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ 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>,
SheetTrigger: ({ 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> = {
navOpen: false,
onNavOpenChange: vi.fn(),
@ -64,32 +94,52 @@ const renderHeader = (overrides: Partial<React.ComponentProps<typeof Orchestrato
...render(
<MemoryRouter>
<OrchestratorHeader {...props} />
</MemoryRouter>
</MemoryRouter>,
),
};
};
describe("OrchestratorHeader", () => {
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(screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i })).toBeInTheDocument();
expect(screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i })).not.toBeInTheDocument();
expect(
screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i }),
).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", () => {
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", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
expect(screen.queryByRole("menuitem", { name: /Gradcracker only/i })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i })).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Gradcracker only/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i }),
).not.toBeInTheDocument();
});
});

View File

@ -1,4 +1,3 @@
import React from "react";
import {
Briefcase,
ChevronDown,
@ -11,6 +10,7 @@ import {
Shield,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@ -66,9 +66,12 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
}) => {
const location = useLocation();
const navigate = useNavigate();
const visibleSources = orderedSources.filter((source) => enabledSources.includes(source));
const visibleSources = orderedSources.filter((source) =>
enabledSources.includes(source),
);
const allSourcesSelected =
visibleSources.length > 0 && visibleSources.every((source) => pipelineSources.includes(source));
visibleSources.length > 0 &&
visibleSources.every((source) => pipelineSources.includes(source));
return (
<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);
}}
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"
: "text-muted-foreground"
}`}
@ -117,7 +129,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<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>
</div>
@ -147,9 +161,15 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
disabled={isPipelineRunning}
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">
{isPipelineRunning ? `Running (${pipelineSources.length})` : `Run pipeline (${pipelineSources.length})`}
{isPipelineRunning
? `Running (${pipelineSources.length})`
: `Run pipeline (${pipelineSources.length})`}
</span>
</Button>
@ -172,7 +192,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))}
onCheckedChange={(checked) =>
onToggleSource(source, Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
>
{sourceLabel[source]}
@ -182,7 +204,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuCheckboxItem
checked={allSourcesSelected}
onCheckedChange={(checked) => {
onSetPipelineSources(checked ? visibleSources : visibleSources.slice(0, 1));
onSetPipelineSources(
checked ? visibleSources : visibleSources.slice(0, 1),
);
}}
onSelect={(event) => event.preventDefault()}
>

View File

@ -1,7 +1,6 @@
import React from "react";
import { PipelineProgress } from "../../components";
import type React from "react";
import type { JobStatus } from "../../../shared/types";
import { PipelineProgress } from "../../components";
interface OrchestratorSummaryProps {
stats: Record<JobStatus, number>;
@ -28,17 +27,33 @@ export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
{/* 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">
<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><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><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="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) && (
<>
<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>

View File

@ -1,12 +1,25 @@
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 orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
export const orderedSources: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
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: {
label: "Discovered",
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",
};
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: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
{
id: "discovered",
label: "Discovered",
statuses: ["discovered", "processing"],
},
{ id: "applied", label: "Applied", statuses: ["applied"] },
{ id: "all", label: "All Jobs", statuses: [] },
];

View File

@ -17,7 +17,9 @@ export const useFilteredJobs = (
if (activeTab === "ready") {
filtered = filtered.filter((job) => job.status === "ready");
} 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") {
filtered = filtered.filter((job) => job.status === "applied");
}

View File

@ -26,7 +26,8 @@ export const useOrchestratorData = () => {
setJobs(data.jobs);
setStats(data.byStatus);
} 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);
} finally {
setIsLoading(false);
@ -54,5 +55,13 @@ export const useOrchestratorData = () => {
return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]);
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus };
return {
jobs,
stats,
isLoading,
isPipelineRunning,
setIsPipelineRunning,
loadJobs,
checkPipelineStatus,
};
};

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
import { usePipelineSources } from "./usePipelineSources";
@ -10,7 +10,10 @@ describe("usePipelineSources", () => {
});
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;
@ -20,7 +23,10 @@ describe("usePipelineSources", () => {
});
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;
@ -30,7 +36,10 @@ describe("usePipelineSources", () => {
});
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;

View File

@ -3,30 +3,42 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { JobSource } from "../../../shared/types";
import {
DEFAULT_PIPELINE_SOURCES,
PIPELINE_SOURCES_STORAGE_KEY,
orderedSources,
PIPELINE_SOURCES_STORAGE_KEY,
} from "./constants";
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));
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
};
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[]) => {
const allowedSources = useMemo(() => resolveAllowedSources(enabledSources), [enabledSources]);
const allowedSources = useMemo(
() => resolveAllowedSources(enabledSources),
[enabledSources],
);
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
if (!raw) return normalizeSources(allowedSources, allowedSources);
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return normalizeSources(allowedSources, allowedSources);
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
if (!Array.isArray(parsed))
return normalizeSources(allowedSources, allowedSources);
const next = parsed.filter((value): value is JobSource =>
orderedSources.includes(value as JobSource),
);
return normalizeSources(next, allowedSources);
} catch {
return normalizeSources(allowedSources, allowedSources);
@ -42,22 +54,28 @@ export const usePipelineSources = (enabledSources?: JobSource[]) => {
useEffect(() => {
try {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
localStorage.setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(pipelineSources),
);
} catch {
// Ignore localStorage errors
}
}, [pipelineSources]);
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
if (!allowedSources.includes(source)) return;
setPipelineSources((current) => {
const next = checked
? Array.from(new Set([...current, source]))
: current.filter((value) => value !== source);
const toggleSource = useCallback(
(source: JobSource, checked: boolean) => {
if (!allowedSources.includes(source)) return;
setPipelineSources((current) => {
const next = checked
? Array.from(new Set([...current, source]))
: current.filter((value) => value !== source);
return next.length === 0 ? current : next;
});
}, [allowedSources]);
return next.length === 0 ? current : next;
});
},
[allowedSources],
);
return { pipelineSources, setPipelineSources, toggleSource };
};

View File

@ -1,6 +1,6 @@
import type { AppSettings, Job, JobSource } from "../../../shared/types";
import { orderedFilterSources, orderedSources } from "./constants";
import type { FilterTab, JobSort } from "./constants";
import { orderedFilterSources, orderedSources } from "./constants";
const dateValue = (value: string | null) => {
if (!value) return null;
@ -8,7 +8,8 @@ const dateValue = (value: string | 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;
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) {
if (job.status === "ready") byTab.ready += 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;
@ -97,13 +99,15 @@ export const getSourcesWithJobs = (jobs: Job[]): JobSource[] => {
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];
const enabled: JobSource[] = [];
const jobspySites = settings.jobspySites ?? [];
const hasUkVisaJobsAuth = Boolean(
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint,
);
for (const source of orderedSources) {

View File

@ -1,107 +1,122 @@
import React, { useEffect, useState } from "react"
import { RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import * as api from "@client/api"
import * as api from "@client/api";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type BaseResumeSelectionProps = {
value: string | null
onValueChange: (value: string | null) => void
hasRxResumeAccess: boolean
disabled?: boolean
isLoading?: boolean
}
value: string | null;
onValueChange: (value: string | null) => void;
hasRxResumeAccess: boolean;
disabled?: boolean;
isLoading?: boolean;
};
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
value,
onValueChange,
hasRxResumeAccess,
disabled = false,
isLoading = false,
value,
onValueChange,
hasRxResumeAccess,
disabled = false,
isLoading = false,
}) => {
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]);
const [isFetchingResumes, setIsFetchingResumes] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = async () => {
if (!hasRxResumeAccess) return
const fetchResumes = async () => {
if (!hasRxResumeAccess) return;
setIsFetchingResumes(true)
setFetchError(null)
try {
const data = await api.getRxResumes()
setResumes(data)
// Preselect if only one option is available and no value is currently set
if (data.length === 1 && !value) {
onValueChange(data[0].id)
}
} catch (error) {
setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes")
} finally {
setIsFetchingResumes(false)
}
setIsFetchingResumes(true);
setFetchError(null);
try {
const data = await api.getRxResumes();
setResumes(data);
// Preselect if only one option is available and no value is currently set
if (data.length === 1 && !value) {
onValueChange(data[0].id);
}
} catch (error) {
setFetchError(
error instanceof Error ? error.message : "Failed to fetch resumes",
);
} finally {
setIsFetchingResumes(false);
}
};
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes()
}
}, [hasRxResumeAccess])
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes();
}
}, [hasRxResumeAccess]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2"
>
<RefreshCw
className={`h-3 w-3 mr-1 ${isFetchingResumes ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<Select
value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes}
>
<SelectTrigger>
<SelectValue placeholder={resumes.length > 0 ? "Select a template resume..." : "No resumes found"} />
</SelectTrigger>
<SelectContent>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes}
>
<SelectTrigger>
<SelectValue
placeholder={
resumes.length > 0
? "Select a template resume..."
: "No resumes found"
}
/>
</SelectTrigger>
<SelectContent>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
<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{" "}
<a
href="https://rxresu.me"
target="_blank"
rel="noreferrer"
className="font-semibold underline underline-offset-2"
>
Reactive Resume website
</a>{" "}
first.
</div>
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
<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{" "}
<a
href="https://rxresu.me"
target="_blank"
rel="noreferrer"
className="font-semibold underline underline-offset-2"
>
Reactive Resume website
</a>{" "}
first.
</div>
)
}
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">{fetchError}</div>
)}
</div>
);
};

View File

@ -1,19 +1,27 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import type { JobStatus } from "@shared/types";
import { fireEvent, render, screen } from "@testing-library/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"
import { DangerZoneSection } from "./DangerZoneSection"
import type { JobStatus } from "@shared/types"
const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => {
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(initialStatuses)
const DangerZoneHarness = ({
initialStatuses = [] as JobStatus[],
onClear,
}: {
initialStatuses?: JobStatus[];
onClear?: () => void;
}) => {
const [statusesToClear, setStatusesToClear] =
useState<JobStatus[]>(initialStatuses);
const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear((prev) =>
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
)
}
prev.includes(status)
? prev.filter((s) => s !== status)
: [...prev, status],
);
};
return (
<Accordion type="multiple" defaultValue={["danger-zone"]}>
@ -26,33 +34,37 @@ const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { i
isSaving={false}
/>
</Accordion>
)
}
);
};
describe("DangerZoneSection", () => {
it("disables clear when no statuses are selected", () => {
render(<DangerZoneHarness initialStatuses={[]} />)
render(<DangerZoneHarness initialStatuses={[]} />);
const clearButton = screen.getByRole("button", { name: /clear selected/i })
expect(clearButton).toBeDisabled()
})
const clearButton = screen.getByRole("button", { name: /clear selected/i });
expect(clearButton).toBeDisabled();
});
it("toggles status selection and confirms clear", async () => {
const onClear = vi.fn()
render(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />)
const onClear = vi.fn();
render(
<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />,
);
const appliedButton = screen.getByRole("button", { name: /applied/i })
const clearButton = screen.getByRole("button", { name: /clear selected/i })
const appliedButton = screen.getByRole("button", { name: /applied/i });
const clearButton = screen.getByRole("button", { name: /clear selected/i });
expect(clearButton).toBeEnabled()
expect(clearButton).toBeEnabled();
fireEvent.click(clearButton)
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
fireEvent.click(confirmButton)
fireEvent.click(clearButton);
const confirmButton = await screen.findByRole("button", {
name: /clear 1 status/i,
});
fireEvent.click(confirmButton);
expect(onClear).toHaveBeenCalledTimes(1)
expect(onClear).toHaveBeenCalledTimes(1);
fireEvent.click(appliedButton)
expect(clearButton).toBeDisabled()
})
})
fireEvent.click(appliedButton);
expect(clearButton).toBeDisabled();
});
});

View File

@ -1,6 +1,15 @@
import React from "react"
import { AlertTriangle, Trash2 } from "lucide-react"
import {
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 {
AlertDialog,
AlertDialogAction,
@ -11,21 +20,18 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]
toggleStatusToClear: (status: JobStatus) => void
handleClearByStatuses: () => void
handleClearDatabase: () => void
isLoading: boolean
isSaving: boolean
}
statusesToClear: JobStatus[];
toggleStatusToClear: (status: JobStatus) => void;
handleClearByStatuses: () => void;
handleClearDatabase: () => void;
isLoading: boolean;
isSaving: boolean;
};
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
statusesToClear,
@ -36,18 +42,25 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
isSaving,
}) => {
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">
<div className="flex items-center gap-2 text-destructive">
<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>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4">
<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">
Select which job statuses you want to clear.
</div>
@ -55,7 +68,7 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status)
const isSelected = statusesToClear.includes(status);
return (
<button
key={status}
@ -63,22 +76,32 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onClick={() => toggleStatusToClear(status)}
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 ${
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 ${
isSelected ? 'border-destructive' : 'border-muted-foreground'
}`}>
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
<div
className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
isSelected
? "border-destructive"
: "border-muted-foreground"
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-destructive" />
)}
</div>
<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">
{STATUS_DESCRIPTIONS[status]}
</span>
</div>
</button>
)
);
})}
</div>
@ -87,7 +110,9 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0}
disabled={
isLoading || isSaving || statusesToClear.length === 0
}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length})
@ -97,14 +122,18 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
This action cannot be undone.
This will delete all jobs with the following statuses:{" "}
{statusesToClear.join(", ")}. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
<AlertDialogAction
onClick={handleClearByStatuses}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear {statusesToClear.length} status
{statusesToClear.length !== 1 ? "es" : ""}
</AlertDialogAction>
</AlertDialogFooter>
</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="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">
Delete all jobs and pipeline runs from the database.
</div>
</div>
<AlertDialog>
<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" />
Clear Database
</Button>
@ -131,12 +166,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
<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
</AlertDialogAction>
</AlertDialogFooter>
@ -146,5 +185,5 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
</div>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,81 +1,89 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { DisplayValues } from "@client/pages/settings/types"
import type { DisplayValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type DisplaySettingsSectionProps = {
values: DisplayValues
isLoading: boolean
isSaving: boolean
}
values: DisplayValues;
isLoading: boolean;
isSaving: boolean;
};
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
values,
isLoading,
isSaving,
values,
isLoading,
isSaving,
}) => {
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
const { control } = useFormContext<UpdateSettingsInput>()
const {
default: defaultShowSponsorInfo,
effective: effectiveShowSponsorInfo,
} = values;
const { control } = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Controller
name="showSponsorInfo"
control={control}
render={({ field }) => (
<Checkbox
id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => {
field.onChange(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="showSponsorInfo"
className="text-sm font-medium leading-none cursor-pointer"
>
Show visa sponsor information
</label>
<p className="text-xs text-muted-foreground">
Display a badge next to the employer name showing the match
percentage with the UK visa sponsor list. This helps identify
employers that are licensed to sponsor work visas.
</p>
</div>
</div>
return (
<AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Controller
name="showSponsorInfo"
control={control}
render={({ field }) => (
<Checkbox
id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => {
field.onChange(
checked === "indeterminate" ? null : checked === true,
);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="showSponsorInfo"
className="text-sm font-medium leading-none cursor-pointer"
>
Show visa sponsor information
</label>
<p className="text-xs text-muted-foreground">
Display a badge next to the employer name showing the match
percentage with the UK visa sponsor list. This helps identify
employers that are licensed to sponsor work visas.
</p>
</div>
</div>
<Separator />
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { Accordion } from "@/components/ui/accordion";
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection";
const EnvironmentSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({
@ -17,8 +16,8 @@ const EnvironmentSettingsHarness = () => {
basicAuthPassword: "",
webhookSecret: "",
enableBasicAuth: true,
}
})
},
});
return (
<FormProvider {...methods}>
@ -44,28 +43,28 @@ const EnvironmentSettingsHarness = () => {
/>
</Accordion>
</FormProvider>
)
}
);
};
describe("EnvironmentSettingsSection", () => {
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("visa@example.com")).toBeInTheDocument()
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument()
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument()
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument()
expect(screen.getByText("Not set")).toBeInTheDocument()
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
expect(screen.getByText("Not set")).toBeInTheDocument();
// Basic Auth
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked()
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked();
expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
// Sections
expect(screen.getByText("External Services")).toBeInTheDocument()
expect(screen.getByText("Service Accounts")).toBeInTheDocument()
expect(screen.getByText("Security")).toBeInTheDocument()
})
})
expect(screen.getByText("External Services")).toBeInTheDocument();
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument();
});
});

View File

@ -1,29 +1,35 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { EnvSettingsValues } from "@client/pages/settings/types"
import { formatSecretHint } from "@client/pages/settings/utils"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { EnvSettingsValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type EnvironmentSettingsSectionProps = {
values: EnvSettingsValues
isLoading: boolean
isSaving: boolean
}
values: EnvSettingsValues;
isLoading: boolean;
isSaving: boolean;
};
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { private: privateValues } = values
export const EnvironmentSettingsSection: React.FC<
EnvironmentSettingsSectionProps
> = ({ values, isLoading, isSaving }) => {
const {
register,
control,
watch,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
const { private: privateValues } = values;
const isBasicAuthEnabled = watch("enableBasicAuth")
const isBasicAuthEnabled = watch("enableBasicAuth");
return (
<AccordionItem value="environment" className="border rounded-lg px-4">
@ -34,7 +40,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
<div className="space-y-8">
{/* External Services */}
<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">
<SettingsInput
label="OpenRouter API key"
@ -52,8 +60,10 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
{/* Service Accounts */}
<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="text-sm font-semibold">RxResume</div>
<div className="grid gap-4 md:grid-cols-2">
@ -92,8 +102,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.ukvisajobsPassword?.message as string | undefined}
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
error={
errors.ukvisajobsPassword?.message as string | undefined
}
current={formatSecretHint(
privateValues.ukvisajobsPasswordHint,
)}
/>
</div>
</div>
@ -103,7 +117,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
{/* Security */}
<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">
<Controller
name="enableBasicAuth"
@ -146,8 +162,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.basicAuthPassword?.message as string | undefined}
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
error={
errors.basicAuthPassword?.message as string | undefined
}
current={formatSecretHint(
privateValues.basicAuthPasswordHint,
)}
/>
</div>
)}
@ -155,5 +175,5 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
</div>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,24 +1,33 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { NumericSettingValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
type GradcrackerSectionProps = {
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
values: NumericSettingValues;
isLoading: boolean;
isSaving: boolean;
};
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const {
effective: effectiveGradcrackerMaxJobsPerTerm,
default: defaultGradcrackerMaxJobsPerTerm,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
@ -41,16 +50,20 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null)
field.onChange(null);
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
field.onChange(Math.min(1000, Math.max(1, value)));
}
},
}}
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.`}
current={String(effectiveGradcrackerMaxJobsPerTerm)}
/>
@ -59,5 +72,5 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
</div>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,10 +1,9 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { JobspySection } from "./JobspySection"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { fireEvent, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { JobspySection } from "./JobspySection";
const JobspyHarness = () => {
const methods = useForm<UpdateSettingsInput>({
@ -15,15 +14,18 @@ const JobspyHarness = () => {
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
}
})
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
values={{
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
sites: {
default: ["indeed", "linkedin"],
effective: ["indeed", "linkedin"],
},
location: { default: "UK", effective: "UK" },
resultsWanted: { default: 200, effective: 200 },
hoursOld: { default: 72, effective: 72 },
@ -35,39 +37,38 @@ const JobspyHarness = () => {
/>
</Accordion>
</FormProvider>
)
}
);
};
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />)
render(<JobspyHarness />);
const indeedCheckbox = screen.getByLabelText("Indeed")
const linkedinCheckbox = screen.getByLabelText("LinkedIn")
const indeedCheckbox = screen.getByLabelText("Indeed");
const linkedinCheckbox = screen.getByLabelText("LinkedIn");
expect(indeedCheckbox).toBeChecked()
expect(linkedinCheckbox).toBeChecked()
expect(indeedCheckbox).toBeChecked();
expect(linkedinCheckbox).toBeChecked();
fireEvent.click(indeedCheckbox)
expect(indeedCheckbox).not.toBeChecked()
expect(linkedinCheckbox).toBeChecked()
fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).not.toBeChecked();
expect(linkedinCheckbox).toBeChecked();
fireEvent.click(indeedCheckbox)
expect(indeedCheckbox).toBeChecked()
})
fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).toBeChecked();
});
it("clamps numeric inputs to allowed ranges", () => {
render(<JobspyHarness />)
render(<JobspyHarness />);
const numericInputs = screen.getAllByRole("spinbutton")
const resultsWantedInput = numericInputs[0]
const hoursOldInput = numericInputs[1]
const numericInputs = screen.getAllByRole("spinbutton");
const resultsWantedInput = numericInputs[0];
const hoursOldInput = numericInputs[1];
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
expect(resultsWantedInput).toHaveValue(1000)
fireEvent.change(resultsWantedInput, { target: { value: "1001" } });
expect(resultsWantedInput).toHaveValue(1000);
fireEvent.change(hoursOldInput, { target: { value: "0" } })
expect(hoursOldInput).toHaveValue(1)
})
})
fireEvent.change(hoursOldInput, { target: { value: "0" } });
expect(hoursOldInput).toHaveValue(1);
});
});

View File

@ -1,19 +1,28 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } 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"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { JobspyValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { JobspyValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} 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 = {
values: JobspyValues
isLoading: boolean
isSaving: boolean
}
values: JobspyValues;
isLoading: boolean;
isSaving: boolean;
};
const JOBSPY_INDEED_COUNTRIES = [
"argentina",
@ -95,15 +104,15 @@ const JOBSPY_INDEED_COUNTRIES = [
"vietnam",
"usa/ca",
"worldwide",
]
];
const COUNTRY_ALIASES: Record<string, string> = {
uk: "united kingdom",
us: "united states",
usa: "united states",
"türkiye": "turkey",
türkiye: "turkey",
"czech republic": "czechia",
}
};
const COUNTRY_LABELS: Record<string, string> = {
"united kingdom": "United Kingdom",
@ -111,21 +120,22 @@ const COUNTRY_LABELS: Record<string, string> = {
"usa/ca": "USA/CA",
turkey: "Turkey",
czechia: "Czechia",
}
};
const normalizeCountryValue = (value: string) => COUNTRY_ALIASES[value] ?? value
const normalizeCountryValue = (value: string) =>
COUNTRY_ALIASES[value] ?? value;
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(
new Map(
JOBSPY_INDEED_COUNTRIES.map((country) => {
const normalized = normalizeCountryValue(country)
return [normalized, normalized]
})
).values()
)
const normalized = normalizeCountryValue(country);
return [normalized, normalized];
}),
).values(),
);
export const JobspySection: React.FC<JobspySectionProps> = ({
values,
@ -139,8 +149,12 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
hoursOld,
countryIndeed,
linkedinFetchDescription,
} = values
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
} = values;
const {
control,
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
@ -159,22 +173,30 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
render={({ field }) => (
<Checkbox
id="site-indeed"
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')}
checked={
field.value?.includes("indeed") ??
sites.default.includes("indeed")
}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
const current = field.value ?? sites.default;
let next = [...current];
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
if (!next.includes("indeed")) next.push("indeed");
} else {
next = next.filter(s => s !== 'indeed')
next = next.filter((s) => s !== "indeed");
}
field.onChange(next)
field.onChange(next);
}}
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 className="flex items-center space-x-2">
<Controller
@ -183,31 +205,45 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
render={({ field }) => (
<Checkbox
id="site-linkedin"
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')}
checked={
field.value?.includes("linkedin") ??
sites.default.includes("linkedin")
}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
const current = field.value ?? sites.default;
let next = [...current];
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
if (!next.includes("linkedin")) next.push("linkedin");
} else {
next = next.filter(s => s !== 'linkedin')
next = next.filter((s) => s !== "linkedin");
}
field.onChange(next)
field.onChange(next);
}}
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>
{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">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
<span>Default: {(sites.default || []).join(', ')}</span>
<span>
Effective: {(sites.effective || []).join(", ") || "None"}
</span>
<span>Default: {(sites.default || []).join(", ")}</span>
</div>
</div>
@ -218,7 +254,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
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 || "—"}`}
/>
@ -236,16 +274,18 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
max: 1000,
value: field.value ?? resultsWanted.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null)
field.onChange(null);
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
field.onChange(Math.min(1000, Math.max(1, value)));
}
},
}}
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.`}
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
/>
@ -266,11 +306,11 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
max: 720,
value: field.value ?? hoursOld.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null)
field.onChange(null);
} 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"
control={control}
render={({ field }) => {
const currentValue = (field.value ?? countryIndeed.default ?? "").toLowerCase()
const normalizedValue = normalizeCountryValue(currentValue)
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(normalizedValue)
const currentValue = (
field.value ??
countryIndeed.default ??
""
).toLowerCase();
const normalizedValue = normalizeCountryValue(currentValue);
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(
normalizedValue,
)
? normalizedValue
: "__default__"
: "__default__";
return (
<div className="space-y-2">
<label htmlFor="jobspyCountryIndeed" className="text-sm font-medium">
<label
htmlFor="jobspyCountryIndeed"
className="text-sm font-medium"
>
Indeed Country
</label>
<Select
value={displayValue}
onValueChange={(value) => {
if (value === "__default__") {
field.onChange(null)
field.onChange(null);
} else {
field.onChange(value)
field.onChange(value);
}
}}
disabled={isLoading || isSaving}
@ -323,7 +372,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
</SelectContent>
</Select>
{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">
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 || "—"}`}
</div>
</div>
)
);
}}
/>
</div>
@ -360,16 +411,21 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
Fetch LinkedIn Description
</label>
<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>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
<span>
Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}
</span>
<span>
Default: {linkedinFetchDescription.default ? "Yes" : "No"}
</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,25 +1,37 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { ModelValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { ModelValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type ModelSettingsSectionProps = {
values: ModelValues
isLoading: boolean
isSaving: boolean
}
values: ModelValues;
isLoading: boolean;
isSaving: boolean;
};
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const {
effective,
default: defaultModel,
scorer,
tailoring,
projectSelection,
} = values;
const {
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="model" className="border rounded-lg px-4">
@ -67,7 +79,9 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
inputProps={register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelProjectSelection?.message as string | undefined}
error={
errors.modelProjectSelection?.message as string | undefined
}
current={projectSelection || effective || "—"}
/>
</div>
@ -77,16 +91,22 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effective || "—"}</div>
<div className="text-xs text-muted-foreground">
Global Effective
</div>
<div className="break-words font-mono text-xs">
{effective || "—"}
</div>
</div>
<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>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,211 +1,296 @@
import React from "react"
import { Controller, useFormContext } from "react-hook-form"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { clampInt } from "@/lib/utils"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
import { BaseResumeSelection } from "./BaseResumeSelection"
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type { ResumeProjectCatalogItem } from "@shared/types";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
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 = {
rxResumeBaseResumeIdDraft: string | null
setRxResumeBaseResumeIdDraft: (value: string | null) => void
// True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
isProjectsLoading: boolean
isLoading: boolean
isSaving: boolean
}
rxResumeBaseResumeIdDraft: string | null;
setRxResumeBaseResumeIdDraft: (value: string | null) => void;
// True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean;
profileProjects: ResumeProjectCatalogItem[];
lockedCount: number;
maxProjectsTotal: number;
isProjectsLoading: boolean;
isLoading: boolean;
isSaving: boolean;
};
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
profileProjects,
lockedCount,
maxProjectsTotal,
isProjectsLoading,
isLoading,
isSaving,
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
profileProjects,
lockedCount,
maxProjectsTotal,
isProjectsLoading,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeAccess ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription>
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access.
</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" />
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeAccess ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription>
Configure RxResume credentials in settings (email + password) or
set <code>RXRESUME_API_KEY</code> to enable access.
</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" />
<AlertTitle className="text-green-800 dark:text-green-300">
RxResume Access Ready
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<Separator />
<Separator />
<div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? (
<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.
<div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? (
<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.
</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 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>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{project.isVisibleInBase ? "Yes" : "No"}
</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
const isChecked = checked === true;
const lockedIds =
field.value.lockedProjectIds.slice();
const selectableIds =
field.value.aiSelectableProjectIds.slice();
<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>
if (isChecked) {
if (!lockedIds.includes(project.id))
lockedIds.push(project.id);
const nextSelectable =
selectableIds.filter(
(id) => id !== project.id,
);
const minCap = lockedIds.length;
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds:
nextSelectable,
maxProjects: Math.max(
field.value.maxProjects,
minCap,
),
});
return;
}
<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>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || isProjectsLoading || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}
const nextLocked = lockedIds.filter(
(id) => id !== project.id,
);
if (!selectableIds.includes(project.id))
selectableIds.push(project.id);
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(
field.value.maxProjects,
nextLocked.length,
maxProjectsTotal,
),
});
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={
locked ||
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
const isChecked = checked === true;
const selectableIds =
field.value.aiSelectableProjectIds.slice();
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter(
(id) => id !== project.id,
);
field.onChange({
...field.value,
aiSelectableProjectIds:
nextSelectable,
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -1,24 +1,31 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { SearchTermsValues } from "@client/pages/settings/types"
import type { SearchTermsValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type SearchTermsSectionProps = {
values: SearchTermsValues
isLoading: boolean
isSaving: boolean
}
values: SearchTermsValues;
isLoading: boolean;
isSaving: boolean;
};
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { default: defaultSearchTerms, effective: effectiveSearchTerms } =
values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
@ -35,15 +42,21 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
render={({ field }) => (
<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"
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
value={
field.value
? field.value.join("\n")
: (defaultSearchTerms ?? []).join("\n")
}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n')
field.onChange(terms)
const text = event.target.value;
const terms = text.split("\n");
field.onChange(terms);
}}
onBlur={() => {
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"
@ -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">
One term per line. Applies to UKVisaJobs and other supported extractors.
One term per line. Applies to UKVisaJobs and other supported
extractors.
</div>
</div>
@ -63,15 +81,19 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
<div className="break-words font-mono text-xs">
{(effectiveSearchTerms || []).join(", ") || "—"}
</div>
</div>
<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>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -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 = {
label: string
inputProps: React.InputHTMLAttributes<HTMLInputElement>
placeholder?: string
type?: React.HTMLInputTypeAttribute
disabled?: boolean
error?: string
helper?: string
current?: string | null
}
label: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
placeholder?: string;
type?: React.HTMLInputTypeAttribute;
disabled?: boolean;
error?: string;
helper?: string;
current?: string | null;
};
export const SettingsInput: React.FC<SettingsInputProps> = ({
label,
@ -23,7 +23,7 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
helper,
current,
}) => {
const id = inputProps.id || inputProps.name
const id = inputProps.id || inputProps.name;
return (
<div className="space-y-2">
@ -32,7 +32,13 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
{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>}
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
{current !== undefined && (
@ -41,5 +47,5 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
</div>
)}
</div>
)
}
);
};

View File

@ -1,24 +1,33 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { NumericSettingValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
type UkvisajobsSectionProps = {
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
values: NumericSettingValues;
isLoading: boolean;
isSaving: boolean;
};
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const {
effective: effectiveUkvisajobsMaxJobs,
default: defaultUkvisajobsMaxJobs,
} = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
@ -41,11 +50,11 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null)
field.onChange(null);
} 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>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { WebhooksSection } from "./WebhooksSection"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { UpdateSettingsInput } from "@shared/settings-schema";
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { Accordion } from "@/components/ui/accordion";
import { WebhooksSection } from "./WebhooksSection";
const WebhooksHarness = () => {
const methods = useForm<UpdateSettingsInput>({
@ -11,8 +10,8 @@ const WebhooksHarness = () => {
pipelineWebhookUrl: "https://pipeline.com",
jobCompleteWebhookUrl: "https://job.com",
webhookSecret: "",
}
})
},
});
return (
<FormProvider {...methods}>
@ -32,19 +31,21 @@ const WebhooksHarness = () => {
/>
</Accordion>
</FormProvider>
)
}
);
};
describe("WebhooksSection", () => {
it("renders both webhook sections and the secret", () => {
render(<WebhooksHarness />)
render(<WebhooksHarness />);
expect(screen.getByText("Pipeline Status")).toBeInTheDocument()
expect(screen.getByText("Job Completion")).toBeInTheDocument()
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument()
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument()
expect(screen.getByText("sec-********")).toBeInTheDocument()
})
})
expect(screen.getByText("Pipeline Status")).toBeInTheDocument();
expect(screen.getByText("Job Completion")).toBeInTheDocument();
expect(
screen.getByDisplayValue("https://pipeline.com"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument();
expect(screen.getByText("sec-********")).toBeInTheDocument();
});
});

View File

@ -1,20 +1,23 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { WebhookValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type WebhooksSectionProps = {
pipelineWebhook: WebhookValues
jobCompleteWebhook: WebhookValues
webhookSecretHint: string | null
isLoading: boolean
isSaving: boolean
}
pipelineWebhook: WebhookValues;
jobCompleteWebhook: WebhookValues;
webhookSecretHint: string | null;
isLoading: boolean;
isSaving: boolean;
};
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
pipelineWebhook,
@ -23,7 +26,10 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const {
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="webhooks" className="border rounded-lg px-4">
@ -55,7 +61,9 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
inputProps={register("jobCompleteWebhookUrl")}
placeholder={jobCompleteWebhook.default || "https://..."}
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 || "—"}.`}
current={jobCompleteWebhook.effective || "—"}
/>
@ -75,5 +83,5 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
</div>
</AccordionContent>
</AccordionItem>
)
}
);
};

View File

@ -2,17 +2,24 @@
* Settings page constants.
*/
import type { JobStatus } from "@shared/types"
import type { JobStatus } from "@shared/types";
/** 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 */
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed',
processing: 'Currently generating resume',
ready: 'PDF generated, waiting for user to apply',
applied: 'User marked as applied',
skipped: 'User skipped this job',
expired: 'Deadline passed',
}
discovered: "Crawled but not processed",
processing: "Currently generating resume",
ready: "PDF generated, waiting for user to apply",
applied: "User marked as applied",
skipped: "User skipped this job",
expired: "Deadline passed",
};

View File

@ -1,40 +1,40 @@
export type EffectiveDefault<T> = {
effective: T
default: T
}
effective: T;
default: T;
};
export type ModelValues = EffectiveDefault<string> & {
scorer: string
tailoring: string
projectSelection: string
}
scorer: string;
tailoring: string;
projectSelection: string;
};
export type WebhookValues = EffectiveDefault<string>
export type NumericSettingValues = EffectiveDefault<number>
export type SearchTermsValues = EffectiveDefault<string[]>
export type DisplayValues = EffectiveDefault<boolean>
export type WebhookValues = EffectiveDefault<string>;
export type NumericSettingValues = EffectiveDefault<number>;
export type SearchTermsValues = EffectiveDefault<string[]>;
export type DisplayValues = EffectiveDefault<boolean>;
export type JobspyValues = {
sites: EffectiveDefault<string[]>
location: EffectiveDefault<string>
resultsWanted: EffectiveDefault<number>
hoursOld: EffectiveDefault<number>
countryIndeed: EffectiveDefault<string>
linkedinFetchDescription: EffectiveDefault<boolean>
}
sites: EffectiveDefault<string[]>;
location: EffectiveDefault<string>;
resultsWanted: EffectiveDefault<number>;
hoursOld: EffectiveDefault<number>;
countryIndeed: EffectiveDefault<string>;
linkedinFetchDescription: EffectiveDefault<boolean>;
};
export type EnvSettingsValues = {
readable: {
rxresumeEmail: string
ukvisajobsEmail: string
basicAuthUser: string
}
rxresumeEmail: string;
ukvisajobsEmail: string;
basicAuthUser: string;
};
private: {
openrouterApiKeyHint: string | null
rxresumePasswordHint: string | null
ukvisajobsPasswordHint: string | null
basicAuthPasswordHint: string | null
webhookSecretHint: string | null
}
basicAuthActive: boolean
}
openrouterApiKeyHint: string | null;
rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null;
basicAuthPasswordHint: string | null;
webhookSecretHint: string | null;
};
basicAuthActive: boolean;
};

View File

@ -2,15 +2,19 @@
* 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 (
a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
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";

View File

@ -1,12 +1,12 @@
"use client"
"use client";
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import * as AccordionPrimitive from "@radix-ui/react-accordion";
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<
React.ElementRef<typeof AccordionPrimitive.Item>,
@ -17,8 +17,8 @@ const AccordionItem = React.forwardRef<
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
@ -29,7 +29,7 @@ const AccordionTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
className,
)}
{...props}
>
@ -37,8 +37,8 @@ const AccordionTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
@ -51,8 +51,8 @@ const AccordionContent = React.forwardRef<
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

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"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@ -17,13 +16,13 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay
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",
className
className,
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
@ -35,13 +34,13 @@ const AlertDialogContent = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
@ -50,12 +49,12 @@ const AlertDialogHeader = ({
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
className,
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
@ -64,12 +63,12 @@ const AlertDialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -80,8 +79,8 @@ const AlertDialogTitle = React.forwardRef<
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
@ -92,9 +91,9 @@ const AlertDialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
@ -105,8 +104,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@ -117,12 +116,12 @@ const AlertDialogCancel = React.forwardRef<
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
className,
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
@ -136,4 +135,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};

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(
"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: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
"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: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };

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(
"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: {
variant: "default",
},
}
)
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
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(
"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: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
destructive: "bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
@ -31,27 +30,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

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<
HTMLDivElement,
@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
className,
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -12,16 +12,17 @@ const Checkbox = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...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" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = "Checkbox"
export { Checkbox }
));
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@ -1,7 +1,7 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
@ -11,14 +11,14 @@ const Drawer = ({
shouldScaleBackground={shouldScaleBackground}
{...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<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
@ -29,8 +29,8 @@ const DrawerOverlay = React.forwardRef<
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@ -42,7 +42,7 @@ const DrawerContent = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
className,
)}
{...props}
>
@ -50,8 +50,8 @@ const DrawerContent = React.forwardRef<
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
@ -61,8 +61,8 @@ const DrawerHeader = ({
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
@ -72,8 +72,8 @@ const DrawerFooter = ({
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
@ -83,12 +83,12 @@ const DrawerTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
@ -99,8 +99,8 @@ const DrawerDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
@ -113,4 +113,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
};

View File

@ -1,25 +1,25 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
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<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
@ -27,15 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
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",
inset && "pl-8",
className
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -45,12 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -62,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
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",
className
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
@ -81,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
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",
inset && "pl-8",
className
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -96,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
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",
className
className,
)}
checked={checked}
{...props}
@ -108,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -120,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
>
@ -131,22 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
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}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -157,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -169,9 +175,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
@ -189,5 +195,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View File

@ -1,11 +1,10 @@
"use client"
"use client";
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
@ -14,11 +13,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
className,
)}
{...props}
/>
)
);
}
function FieldLegend({
@ -34,11 +33,11 @@ function FieldLegend({
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
className,
)}
{...props}
/>
)
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -47,11 +46,11 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-group"
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",
className
className,
)}
{...props}
/>
)
);
}
const fieldVariants = cva(
@ -75,8 +74,8 @@ const fieldVariants = cva(
defaultVariants: {
orientation: "vertical",
},
}
)
},
);
function Field({
className,
@ -91,7 +90,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@ -100,11 +99,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
className,
)}
{...props}
/>
)
);
}
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",
"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",
className
className,
)}
{...props}
/>
)
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -131,11 +130,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
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",
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
className,
)}
{...props}
/>
)
);
}
function FieldSeparator({
@ -158,7 +157,7 @@ function FieldSeparator({
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
children?: React.ReactNode;
}) {
return (
<div
@ -166,7 +165,7 @@ function FieldSeparator({
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
className,
)}
{...props}
>
@ -180,7 +179,7 @@ function FieldSeparator({
</span>
)}
</div>
)
);
}
function FieldError({
@ -189,33 +188,33 @@ function FieldError({
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children
return children;
}
if (!errors) {
return null
return null;
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message
return errors[0].message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
);
}, [children, errors]);
if (!content) {
return null
return null;
}
return (
@ -227,7 +226,7 @@ function FieldError({
>
{content}
</div>
)
);
}
export {
@ -241,4 +240,4 @@ export {
FieldSet,
FieldContent,
FieldTitle,
}
};

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">>(
({ className, type, ...props }, ref) => {
@ -9,15 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type}
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",
className
className,
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -1,14 +1,14 @@
"use client"
"use client";
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as LabelPrimitive from "@radix-ui/react-label";
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(
"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<
React.ElementRef<typeof LabelPrimitive.Root>,
@ -20,7 +20,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };

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<
React.ElementRef<typeof ProgressPrimitive.Root>,
@ -13,7 +13,7 @@ const Progress = React.forwardRef<
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
className,
)}
{...props}
>
@ -22,7 +22,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress }
export { Progress };

View File

@ -1,8 +1,8 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
@ -14,9 +14,9 @@ const RadioGroup = React.forwardRef<
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
>
@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem }
export { RadioGroup, RadioGroupItem };

View File

@ -1,159 +1,159 @@
"use client"
"use client";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as SelectPrimitive from "@radix-ui/react-select";
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<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
<SelectPrimitive.Trigger
ref={ref}
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton >
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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",
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",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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",
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",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
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",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
<SelectPrimitive.Item
ref={ref}
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",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

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<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@ -9,7 +9,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@ -18,12 +18,12 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@ -1,17 +1,17 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
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<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -20,13 +20,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay
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",
className
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
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",
@ -44,8 +44,8 @@ const sheetVariants = cva(
defaultVariants: {
side: "right",
},
}
)
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@ -69,8 +69,8 @@ const SheetContent = React.forwardRef<
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
@ -79,12 +79,12 @@ const SheetHeader = ({
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
className,
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
@ -93,12 +93,12 @@ const SheetFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@ -109,8 +109,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@ -121,8 +121,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@ -135,4 +135,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

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) => {
return (
@ -20,7 +20,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };

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>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
)
Table.displayName = "Table"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@ -32,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@ -41,25 +42,29 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
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}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
)
TableRow.displayName = "TableRow"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
@ -69,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref}
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]",
className
className,
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
@ -82,11 +87,14 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
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}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@ -97,8 +105,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = "TableCaption";
export {
Table,
@ -109,5 +117,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

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<
React.ElementRef<typeof TabsPrimitive.List>,
@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@ -28,12 +28,12 @@ const TabsTrigger = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"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}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

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<
HTMLTextAreaElement,
@ -10,13 +10,13 @@ const Textarea = React.forwardRef<
<textarea
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",
className
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
);
});
Textarea.displayName = "Textarea";
export { Textarea }
export { Textarea };

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<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -1,17 +1,19 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { Job } from "@shared/types"
import type { Job } from "@shared/types";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// --- CSS ---
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
// --- Dates ---
export const formatDate = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const normalized = dateStr.includes("T")
? dateStr
: dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
return parsed.toLocaleDateString("en-GB", {
@ -27,7 +29,9 @@ export const formatDate = (dateStr?: string | null) => {
export const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const normalized = dateStr.includes("T")
? dateStr
: dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", {
@ -79,7 +83,8 @@ export const stripHtml = (value: string) =>
.replace(/\s+/g, " ")
.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 ---
export function arraysEqual(a: string[], b: string[]) {

View File

@ -1 +1 @@
export { apiRouter } from './routes.js';
export { apiRouter } from "./routes.js";

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