ran check:fix in orchestrator
This commit is contained in:
parent
ac02a5fd1d
commit
5c2eef2fc8
7
biome.json
Normal file
7
biome.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
}
|
||||
}
|
||||
@ -90,4 +90,4 @@
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from './client';
|
||||
export * from "./client";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
|
||||
Let’s get your workspace ready. Add your keys and resume once,
|
||||
then the pipeline can run end-to-end.
|
||||
</AlertDialogDescription>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [] },
|
||||
];
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user