663 lines
23 KiB
TypeScript
663 lines
23 KiB
TypeScript
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"
|
|
|
|
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|
model: "",
|
|
modelScorer: "",
|
|
modelTailoring: "",
|
|
modelProjectSelection: "",
|
|
pipelineWebhookUrl: "",
|
|
jobCompleteWebhookUrl: "",
|
|
resumeProjects: null,
|
|
rxresumeBaseResumeId: null,
|
|
ukvisajobsMaxJobs: null,
|
|
gradcrackerMaxJobsPerTerm: null,
|
|
searchTerms: null,
|
|
jobspyLocation: null,
|
|
jobspyResultsWanted: null,
|
|
jobspyHoursOld: null,
|
|
jobspyCountryIndeed: null,
|
|
jobspySites: null,
|
|
jobspyLinkedinFetchDescription: null,
|
|
showSponsorInfo: null,
|
|
openrouterApiKey: "",
|
|
rxresumeEmail: "",
|
|
rxresumePassword: "",
|
|
basicAuthUser: "",
|
|
basicAuthPassword: "",
|
|
ukvisajobsEmail: "",
|
|
ukvisajobsPassword: "",
|
|
webhookSecret: "",
|
|
enableBasicAuth: false,
|
|
}
|
|
|
|
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|
model: null,
|
|
modelScorer: null,
|
|
modelTailoring: null,
|
|
modelProjectSelection: null,
|
|
pipelineWebhookUrl: null,
|
|
jobCompleteWebhookUrl: null,
|
|
resumeProjects: null,
|
|
rxresumeBaseResumeId: null,
|
|
ukvisajobsMaxJobs: null,
|
|
gradcrackerMaxJobsPerTerm: null,
|
|
searchTerms: null,
|
|
jobspyLocation: null,
|
|
jobspyResultsWanted: null,
|
|
jobspyHoursOld: null,
|
|
jobspyCountryIndeed: null,
|
|
jobspySites: null,
|
|
jobspyLinkedinFetchDescription: null,
|
|
showSponsorInfo: null,
|
|
openrouterApiKey: null,
|
|
rxresumeEmail: null,
|
|
rxresumePassword: null,
|
|
basicAuthUser: null,
|
|
basicAuthPassword: null,
|
|
ukvisajobsEmail: null,
|
|
ukvisajobsPassword: null,
|
|
webhookSecret: null,
|
|
enableBasicAuth: undefined,
|
|
}
|
|
|
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|
model: data.overrideModel ?? "",
|
|
modelScorer: data.overrideModelScorer ?? "",
|
|
modelTailoring: data.overrideModelTailoring ?? "",
|
|
modelProjectSelection: data.overrideModelProjectSelection ?? "",
|
|
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
|
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
|
resumeProjects: data.resumeProjects,
|
|
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
|
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
|
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
|
searchTerms: data.overrideSearchTerms,
|
|
jobspyLocation: data.overrideJobspyLocation,
|
|
jobspyResultsWanted: data.overrideJobspyResultsWanted,
|
|
jobspyHoursOld: data.overrideJobspyHoursOld,
|
|
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
|
|
jobspySites: data.overrideJobspySites,
|
|
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
|
showSponsorInfo: data.overrideShowSponsorInfo,
|
|
openrouterApiKey: "",
|
|
rxresumeEmail: data.rxresumeEmail ?? "",
|
|
rxresumePassword: "",
|
|
basicAuthUser: data.basicAuthUser ?? "",
|
|
basicAuthPassword: "",
|
|
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
|
|
ukvisajobsPassword: "",
|
|
webhookSecret: "",
|
|
enableBasicAuth: data.basicAuthActive,
|
|
})
|
|
|
|
const normalizeString = (value: string | null | undefined) => {
|
|
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 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 nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
|
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
|
|
): ResumeProjectsSettings | null => {
|
|
const allowed = new Set(catalog.map((project) => project.id))
|
|
|
|
const base = current ?? {
|
|
maxProjects: 0,
|
|
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)
|
|
)
|
|
.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))
|
|
const maxProjects = Math.min(
|
|
catalog.length,
|
|
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 getDerivedSettings = (settings: AppSettings | null) => {
|
|
const profileProjects = settings?.profileProjects ?? []
|
|
|
|
return {
|
|
model: {
|
|
effective: settings?.model ?? "",
|
|
default: settings?.defaultModel ?? "",
|
|
scorer: settings?.modelScorer ?? "",
|
|
tailoring: settings?.modelTailoring ?? "",
|
|
projectSelection: settings?.modelProjectSelection ?? "",
|
|
},
|
|
pipelineWebhook: {
|
|
effective: settings?.pipelineWebhookUrl ?? "",
|
|
default: settings?.defaultPipelineWebhookUrl ?? "",
|
|
},
|
|
jobCompleteWebhook: {
|
|
effective: settings?.jobCompleteWebhookUrl ?? "",
|
|
default: settings?.defaultJobCompleteWebhookUrl ?? "",
|
|
},
|
|
ukvisajobs: {
|
|
effective: settings?.ukvisajobsMaxJobs ?? 50,
|
|
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
|
|
},
|
|
gradcracker: {
|
|
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
|
|
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
|
|
},
|
|
searchTerms: {
|
|
effective: settings?.searchTerms ?? [],
|
|
default: settings?.defaultSearchTerms ?? [],
|
|
},
|
|
jobspy: {
|
|
location: {
|
|
effective: settings?.jobspyLocation ?? "",
|
|
default: settings?.defaultJobspyLocation ?? "",
|
|
},
|
|
resultsWanted: {
|
|
effective: settings?.jobspyResultsWanted ?? 200,
|
|
default: settings?.defaultJobspyResultsWanted ?? 200,
|
|
},
|
|
hoursOld: {
|
|
effective: settings?.jobspyHoursOld ?? 72,
|
|
default: settings?.defaultJobspyHoursOld ?? 72,
|
|
},
|
|
countryIndeed: {
|
|
effective: settings?.jobspyCountryIndeed ?? "",
|
|
default: settings?.defaultJobspyCountryIndeed ?? "",
|
|
},
|
|
sites: {
|
|
effective: settings?.jobspySites ?? ["indeed", "linkedin"],
|
|
default: settings?.defaultJobspySites ?? ["indeed", "linkedin"],
|
|
},
|
|
linkedinFetchDescription: {
|
|
effective: settings?.jobspyLinkedinFetchDescription ?? true,
|
|
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
|
|
},
|
|
},
|
|
display: {
|
|
effective: settings?.showSponsorInfo ?? true,
|
|
default: settings?.defaultShowSponsorInfo ?? true,
|
|
},
|
|
envSettings: {
|
|
readable: {
|
|
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
|
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
|
basicAuthUser: settings?.basicAuthUser ?? "",
|
|
},
|
|
private: {
|
|
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
|
|
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
|
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
|
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
|
webhookSecretHint: settings?.webhookSecretHint ?? null,
|
|
},
|
|
basicAuthActive: settings?.basicAuthActive ?? false,
|
|
},
|
|
defaultResumeProjects: settings?.defaultResumeProjects ?? 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 methods = useForm<UpdateSettingsInput>({
|
|
resolver: zodResolver(updateSettingsSchema),
|
|
mode: "onChange",
|
|
defaultValues: DEFAULT_FORM_VALUES,
|
|
})
|
|
|
|
const {
|
|
handleSubmit,
|
|
reset,
|
|
setError,
|
|
setValue,
|
|
getValues,
|
|
watch,
|
|
formState: { isDirty, errors, isValid, dirtyFields }
|
|
} = methods
|
|
|
|
const hasRxResumeAccess = Boolean(
|
|
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint
|
|
)
|
|
|
|
useEffect(() => {
|
|
let isMounted = true
|
|
setIsLoading(true)
|
|
api
|
|
.getSettings()
|
|
.then((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)
|
|
})
|
|
.finally(() => {
|
|
if (!isMounted) return
|
|
setIsLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
isMounted = false
|
|
}
|
|
}, [reset])
|
|
|
|
useEffect(() => {
|
|
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()
|
|
|
|
if (!rxResumeBaseResumeIdDraft) {
|
|
setRxResumeProjectsOverride(null)
|
|
return () => {
|
|
isMounted = false
|
|
controller.abort()
|
|
}
|
|
}
|
|
|
|
if (!hasRxResumeAccess) return () => {
|
|
isMounted = false
|
|
controller.abort()
|
|
}
|
|
|
|
setIsFetchingRxResumeProjects(true)
|
|
api
|
|
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
|
|
.then((projects) => {
|
|
if (!isMounted) return
|
|
setRxResumeProjectsOverride(projects)
|
|
const normalized = normalizeResumeProjectsForCatalog(
|
|
projects,
|
|
getValues("resumeProjects") ?? null
|
|
)
|
|
if (normalized) {
|
|
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)
|
|
})
|
|
.finally(() => {
|
|
if (!isMounted) return
|
|
setIsFetchingRxResumeProjects(false)
|
|
})
|
|
|
|
return () => {
|
|
isMounted = false
|
|
controller.abort()
|
|
}
|
|
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
|
|
|
|
const derived = getDerivedSettings(settings)
|
|
const {
|
|
model,
|
|
pipelineWebhook,
|
|
jobCompleteWebhook,
|
|
ukvisajobs,
|
|
gradcracker,
|
|
searchTerms,
|
|
jobspy,
|
|
display,
|
|
envSettings,
|
|
defaultResumeProjects,
|
|
profileProjects,
|
|
maxProjectsTotal,
|
|
} = derived
|
|
|
|
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
|
|
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
|
|
|
|
const watchedValues = watch()
|
|
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
|
|
|
const canSave = isDirty && isValid
|
|
|
|
const onSave = async (data: UpdateSettingsInput) => {
|
|
if (!settings) return
|
|
if (data.enableBasicAuth && !settings.basicAuthActive) {
|
|
const password = data.basicAuthPassword?.trim() ?? ""
|
|
if (!password) {
|
|
setError("basicAuthPassword", {
|
|
type: "manual",
|
|
message: "Password is required when basic auth is enabled",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
try {
|
|
setIsSaving(true)
|
|
|
|
// Prepare payload: nullify if equal to default
|
|
const resumeProjectsData = data.resumeProjects
|
|
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
|
|
? null
|
|
: resumeProjectsData
|
|
|
|
const envPayload: Partial<UpdateSettingsInput> = {}
|
|
|
|
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
|
|
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
|
|
}
|
|
|
|
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
|
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
|
|
}
|
|
|
|
if (data.enableBasicAuth === false) {
|
|
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)
|
|
|
|
if (dirtyFields.basicAuthPassword) {
|
|
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
|
|
}
|
|
|
|
if (dirtyFields.rxresumePassword) {
|
|
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
|
|
}
|
|
|
|
if (dirtyFields.webhookSecret) {
|
|
const value = normalizePrivateInput(data.webhookSecret)
|
|
if (value !== undefined) envPayload.webhookSecret = value
|
|
}
|
|
|
|
const payload: UpdateSettingsInput = {
|
|
model: normalizeString(data.model),
|
|
modelScorer: normalizeString(data.modelScorer),
|
|
modelTailoring: normalizeString(data.modelTailoring),
|
|
modelProjectSelection: normalizeString(data.modelProjectSelection),
|
|
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
|
resumeProjects: resumeProjectsOverride,
|
|
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
|
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),
|
|
jobspyLinkedinFetchDescription: nullIfSame(
|
|
data.jobspyLinkedinFetchDescription,
|
|
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
|
|
|
|
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)
|
|
} 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(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)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<PageHeader
|
|
icon={Settings}
|
|
title="Settings"
|
|
subtitle="Configure runtime behavior for this app."
|
|
/>
|
|
|
|
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
|
|
<Accordion type="multiple" className="w-full space-y-4">
|
|
<ModelSettingsSection
|
|
values={model}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<WebhooksSection
|
|
pipelineWebhook={pipelineWebhook}
|
|
jobCompleteWebhook={jobCompleteWebhook}
|
|
webhookSecretHint={envSettings.private.webhookSecretHint}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<UkvisajobsSection
|
|
values={ukvisajobs}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<GradcrackerSection
|
|
values={gradcracker}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<SearchTermsSection
|
|
values={searchTerms}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<JobspySection
|
|
values={jobspy}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<ReactiveResumeSection
|
|
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
|
setRxResumeBaseResumeIdDraft={(value) => {
|
|
setRxResumeBaseResumeIdDraft(value)
|
|
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
|
|
}}
|
|
hasRxResumeAccess={hasRxResumeAccess}
|
|
profileProjects={effectiveProfileProjects}
|
|
lockedCount={lockedCount}
|
|
maxProjectsTotal={effectiveMaxProjectsTotal}
|
|
isProjectsLoading={isFetchingRxResumeProjects}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<DisplaySettingsSection
|
|
values={display}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<EnvironmentSettingsSection
|
|
values={envSettings}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
<DangerZoneSection
|
|
statusesToClear={statusesToClear}
|
|
toggleStatusToClear={toggleStatusToClear}
|
|
handleClearByStatuses={handleClearByStatuses}
|
|
handleClearDatabase={handleClearDatabase}
|
|
isLoading={isLoading}
|
|
isSaving={isSaving}
|
|
/>
|
|
</Accordion>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
|
|
{isSaving ? "Saving..." : "Save"}
|
|
</Button>
|
|
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
|
Reset to default
|
|
</Button>
|
|
</div>
|
|
{Object.keys(errors).length > 0 && (
|
|
<div className="text-destructive text-sm mt-2">
|
|
Please fix the errors before saving.
|
|
</div>
|
|
)}
|
|
</main>
|
|
</FormProvider>
|
|
)
|
|
}
|