/** * Settings page. */ import React, { useEffect, useMemo, useState } from "react" import { AlertTriangle, Settings, Trash2 } from "lucide-react" import { toast } from "sonner" import { PageHeader } from "../components/layout" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 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 type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types" import * as api from "../api" /** All available job statuses for clearing */ const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired'] /** Status descriptions for UI */ const STATUS_DESCRIPTIONS: Record = { 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', } function arraysEqual(a: string[], b: string[]) { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false } return true } function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) { return ( a.maxProjects === b.maxProjects && arraysEqual(a.lockedProjectIds, b.lockedProjectIds) && arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) ) } function clampInt(value: number, min: number, max: number) { const int = Math.floor(value) if (Number.isNaN(int)) return min return Math.min(max, Math.max(min, int)) } export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [modelDraft, setModelDraft] = useState("") const [modelScorerDraft, setModelScorerDraft] = useState("") const [modelTailoringDraft, setModelTailoringDraft] = useState("") const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("") const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") const [resumeProjectsDraft, setResumeProjectsDraft] = useState(null) const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState(null) const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState(null) const [searchTermsDraft, setSearchTermsDraft] = useState(null) const [jobspyLocationDraft, setJobspyLocationDraft] = useState(null) const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState(null) const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState(null) const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState(null) const [jobspySitesDraft, setJobspySitesDraft] = useState(null) const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) useEffect(() => { let isMounted = true setIsLoading(true) api .getSettings() .then((data) => { if (!isMounted) return setSettings(data) setModelDraft(data.overrideModel ?? "") setModelScorerDraft(data.overrideModelScorer ?? "") setModelTailoringDraft(data.overrideModelTailoring ?? "") setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "") setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") setResumeProjectsDraft(data.resumeProjects) setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs) setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm) setSearchTermsDraft(data.overrideSearchTerms) setJobspyLocationDraft(data.overrideJobspyLocation) setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted) setJobspyHoursOldDraft(data.overrideJobspyHoursOld) setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed) setJobspySitesDraft(data.overrideJobspySites) setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" toast.error(message) }) .finally(() => { if (!isMounted) return setIsLoading(false) }) return () => { isMounted = false } }, []) const effectiveModel = settings?.model ?? "" const defaultModel = settings?.defaultModel ?? "" const overrideModel = settings?.overrideModel const effectiveModelScorer = settings?.modelScorer ?? "" const overrideModelScorer = settings?.overrideModelScorer const effectiveModelTailoring = settings?.modelTailoring ?? "" const overrideModelTailoring = settings?.overrideModelTailoring const effectiveModelProjectSelection = settings?.modelProjectSelection ?? "" const overrideModelProjectSelection = settings?.overrideModelProjectSelection const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? "" const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? "" const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50 const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50 const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50 const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50 const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm const effectiveSearchTerms = settings?.searchTerms ?? [] const defaultSearchTerms = settings?.defaultSearchTerms ?? [] const overrideSearchTerms = settings?.overrideSearchTerms const effectiveJobspyLocation = settings?.jobspyLocation ?? "" const defaultJobspyLocation = settings?.defaultJobspyLocation ?? "" const overrideJobspyLocation = settings?.overrideJobspyLocation const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200 const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200 const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72 const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72 const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? "" const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? "" const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"] const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"] const overrideJobspySites = settings?.overrideJobspySites const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription const profileProjects = settings?.profileProjects ?? [] const maxProjectsTotal = profileProjects.length const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0 const canSave = useMemo(() => { if (!settings || !resumeProjectsDraft) return false const next = modelDraft.trim() const current = (overrideModel ?? "").trim() const nextScorer = modelScorerDraft.trim() const currentScorer = (overrideModelScorer ?? "").trim() const nextTailoring = modelTailoringDraft.trim() const currentTailoring = (overrideModelTailoring ?? "").trim() const nextProjectSelection = modelProjectSelectionDraft.trim() const currentProjectSelection = (overrideModelProjectSelection ?? "").trim() const nextWebhook = pipelineWebhookUrlDraft.trim() const currentWebhook = (overridePipelineWebhookUrl ?? "").trim() const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim() const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim() const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null) const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null) const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null) return ( next !== current || nextScorer !== currentScorer || nextTailoring !== currentTailoring || nextProjectSelection !== currentProjectSelection || nextWebhook !== currentWebhook || nextJobCompleteWebhook !== currentJobCompleteWebhook || !resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) || ukvisajobsChanged || gradcrackerChanged || searchTermsChanged || jobspyLocationDraft !== (overrideJobspyLocation ?? null) || jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) || jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) || jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) || JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) || jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ) }, [ settings, modelDraft, modelScorerDraft, modelTailoringDraft, modelProjectSelectionDraft, pipelineWebhookUrlDraft, jobCompleteWebhookUrlDraft, overrideModel, overrideModelScorer, overrideModelTailoring, overrideModelProjectSelection, overridePipelineWebhookUrl, overrideJobCompleteWebhookUrl, resumeProjectsDraft, ukvisajobsMaxJobsDraft, overrideUkvisajobsMaxJobs, gradcrackerMaxJobsPerTermDraft, overrideGradcrackerMaxJobsPerTerm, searchTermsDraft, overrideSearchTerms, jobspyLocationDraft, jobspyResultsWantedDraft, jobspyHoursOldDraft, jobspyCountryIndeedDraft, jobspySitesDraft, jobspyLinkedinFetchDescriptionDraft, overrideJobspyLocation, overrideJobspyResultsWanted, overrideJobspyHoursOld, overrideJobspyCountryIndeed, overrideJobspySites, overrideJobspyLinkedinFetchDescription, ]) const handleSave = async () => { if (!settings || !resumeProjectsDraft) return try { setIsSaving(true) const trimmed = modelDraft.trim() const trimmedScorer = modelScorerDraft.trim() const trimmedTailoring = modelTailoringDraft.trim() const trimmedProjectSelection = modelProjectSelectionDraft.trim() const webhookTrimmed = pipelineWebhookUrlDraft.trim() const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects) ? null : resumeProjectsDraft const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null, modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null, modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null, pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, resumeProjects: resumeProjectsOverride, ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride, gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride, searchTerms: searchTermsOverride, jobspyLocation: jobspyLocationOverride, jobspyResultsWanted: jobspyResultsWantedOverride, jobspyHoursOld: jobspyHoursOldOverride, jobspyCountryIndeed: jobspyCountryIndeedOverride, jobspySites: jobspySitesOverride, jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") setModelScorerDraft(updated.overrideModelScorer ?? "") setModelTailoringDraft(updated.overrideModelTailoring ?? "") setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "") setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") setResumeProjectsDraft(updated.resumeProjects) setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs) setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm) setSearchTermsDraft(updated.overrideSearchTerms) setJobspyLocationDraft(updated.overrideJobspyLocation) setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted) setJobspyHoursOldDraft(updated.overrideJobspyHoursOld) setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed) setJobspySitesDraft(updated.overrideJobspySites) setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" toast.error(message) } finally { setIsSaving(false) } } const handleClearDatabase = async () => { try { 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) } finally { setIsSaving(false) } } const handleClearByStatuses = async () => { if (statusesToClear.length === 0) { toast.error("No statuses selected") return } try { setIsSaving(true) let totalDeleted = 0 const results: string[] = [] for (const status of statusesToClear) { const result = await api.deleteJobsByStatus(status) totalDeleted += result.count if (result.count > 0) { results.push(`${result.count} ${status}`) } } if (totalDeleted > 0) { toast.success("Jobs cleared", { 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) } finally { setIsSaving(false) } } const toggleStatusToClear = (status: JobStatus) => { setStatusesToClear(prev => prev.includes(status) ? prev.filter(s => s !== status) : [...prev, status] ) } const handleReset = async () => { try { setIsSaving(true) const updated = await api.updateSettings({ model: null, modelScorer: null, modelTailoring: null, modelProjectSelection: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null, resumeProjects: null, ukvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: null, searchTerms: null, jobspyLocation: null, jobspyResultsWanted: null, jobspyHoursOld: null, jobspyCountryIndeed: null, jobspySites: null, jobspyLinkedinFetchDescription: null, }) setSettings(updated) setModelDraft("") setModelScorerDraft("") setModelTailoringDraft("") setModelProjectSelectionDraft("") setPipelineWebhookUrlDraft("") setJobCompleteWebhookUrlDraft("") setResumeProjectsDraft(updated.resumeProjects) setUkvisajobsMaxJobsDraft(null) setGradcrackerMaxJobsPerTermDraft(null) setSearchTermsDraft(null) setJobspyLocationDraft(null) setJobspyResultsWantedDraft(null) setJobspyHoursOldDraft(null) setJobspyCountryIndeedDraft(null) setJobspySitesDraft(null) setJobspyLinkedinFetchDescriptionDraft(null) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" toast.error(message) } finally { setIsSaving(false) } } return ( <>
Model
Override model
setModelDraft(event.target.value)} placeholder={defaultModel || "openai/gpt-4o-mini"} disabled={isLoading || isSaving} />
Leave blank to use the default from server env (`MODEL`).
Task-Specific Overrides
Scoring Model
setModelScorerDraft(event.target.value)} placeholder={effectiveModel || "inherit"} disabled={isLoading || isSaving} />
Effective: {effectiveModelScorer || effectiveModel}
Tailoring Model
setModelTailoringDraft(event.target.value)} placeholder={effectiveModel || "inherit"} disabled={isLoading || isSaving} />
Effective: {effectiveModelTailoring || effectiveModel}
Project Selection Model
setModelProjectSelectionDraft(event.target.value)} placeholder={effectiveModel || "inherit"} disabled={isLoading || isSaving} />
Effective: {effectiveModelProjectSelection || effectiveModel}
Global Effective
{effectiveModel || "—"}
Default (env)
{defaultModel || "—"}
Pipeline Webhook
Pipeline status webhook URL
setPipelineWebhookUrlDraft(event.target.value)} placeholder={defaultPipelineWebhookUrl || "https://..."} disabled={isLoading || isSaving} />
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
Effective
{effectivePipelineWebhookUrl || "—"}
Default (env)
{defaultPipelineWebhookUrl || "—"}
Job Complete Webhook
Job completion webhook URL
setJobCompleteWebhookUrlDraft(event.target.value)} placeholder={defaultJobCompleteWebhookUrl || "https://..."} disabled={isLoading || isSaving} />
When set, the server sends a POST when you mark a job as applied (includes the job description).
Effective
{effectiveJobCompleteWebhookUrl || "—"}
Default (env)
{defaultJobCompleteWebhookUrl || "—"}
UKVisaJobs Extractor
Max jobs to fetch
{ const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { setUkvisajobsMaxJobsDraft(null) } else { setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value))) } }} disabled={isLoading || isSaving} />
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
Effective
{effectiveUkvisajobsMaxJobs}
Default
{defaultUkvisajobsMaxJobs}
Gradcracker Extractor
Max jobs per search term
{ const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { setGradcrackerMaxJobsPerTermDraft(null) } else { setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value))) } }} disabled={isLoading || isSaving} />
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
Effective
{effectiveGradcrackerMaxJobsPerTerm}
Default
{defaultGradcrackerMaxJobsPerTerm}
Search Terms
Global search terms