settings page split up into components
This commit is contained in:
parent
65551b147f
commit
4325737d00
@ -3,70 +3,24 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { AlertTriangle, Settings, Trash2 } from "lucide-react"
|
||||
import { Settings } 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 { Accordion } 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<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',
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
import { arraysEqual, resumeProjectsEqual } from "./settings/utils"
|
||||
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
|
||||
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
|
||||
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
|
||||
import { JobspySection } from "./settings/components/JobspySection"
|
||||
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
|
||||
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
|
||||
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
|
||||
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
|
||||
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
@ -332,7 +286,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setIsSaving(true)
|
||||
let totalDeleted = 0
|
||||
const results: string[] = []
|
||||
|
||||
|
||||
for (const status of statusesToClear) {
|
||||
const result = await api.deleteJobsByStatus(status)
|
||||
totalDeleted += result.count
|
||||
@ -340,14 +294,14 @@ export const SettingsPage: React.FC = () => {
|
||||
results.push(`${result.count} ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
toast.success("Jobs cleared", {
|
||||
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`
|
||||
toast.success("Jobs cleared", {
|
||||
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
|
||||
})
|
||||
} else {
|
||||
toast.info("No jobs found", {
|
||||
description: `No jobs with selected statuses found`
|
||||
toast.info("No jobs found", {
|
||||
description: `No jobs with selected statuses found`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@ -359,8 +313,8 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const toggleStatusToClear = (status: JobStatus) => {
|
||||
setStatusesToClear(prev =>
|
||||
prev.includes(status)
|
||||
setStatusesToClear(prev =>
|
||||
prev.includes(status)
|
||||
? prev.filter(s => s !== status)
|
||||
: [...prev, status]
|
||||
)
|
||||
@ -422,707 +376,119 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
<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">
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Model</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Override model</div>
|
||||
<Input
|
||||
value={modelDraft}
|
||||
onChange={(event) => setModelDraft(event.target.value)}
|
||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default from server env (`MODEL`).
|
||||
</div>
|
||||
</div>
|
||||
<ModelSettingsSection
|
||||
modelDraft={modelDraft}
|
||||
setModelDraft={setModelDraft}
|
||||
modelScorerDraft={modelScorerDraft}
|
||||
setModelScorerDraft={setModelScorerDraft}
|
||||
modelTailoringDraft={modelTailoringDraft}
|
||||
setModelTailoringDraft={setModelTailoringDraft}
|
||||
modelProjectSelectionDraft={modelProjectSelectionDraft}
|
||||
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
|
||||
effectiveModel={effectiveModel}
|
||||
effectiveModelScorer={effectiveModelScorer}
|
||||
effectiveModelTailoring={effectiveModelTailoring}
|
||||
effectiveModelProjectSelection={effectiveModelProjectSelection}
|
||||
defaultModel={defaultModel}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<PipelineWebhookSection
|
||||
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
|
||||
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
|
||||
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
|
||||
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobCompleteWebhookSection
|
||||
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
|
||||
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
|
||||
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
|
||||
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<UkvisajobsSection
|
||||
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
|
||||
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
|
||||
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
|
||||
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<GradcrackerSection
|
||||
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
|
||||
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
|
||||
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
|
||||
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SearchTermsSection
|
||||
searchTermsDraft={searchTermsDraft}
|
||||
setSearchTermsDraft={setSearchTermsDraft}
|
||||
defaultSearchTerms={defaultSearchTerms}
|
||||
effectiveSearchTerms={effectiveSearchTerms}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={defaultJobspySites}
|
||||
effectiveJobspySites={effectiveJobspySites}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation={defaultJobspyLocation}
|
||||
effectiveJobspyLocation={effectiveJobspyLocation}
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
|
||||
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={defaultJobspyHoursOld}
|
||||
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
|
||||
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
|
||||
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={resumeProjectsDraft}
|
||||
setResumeProjectsDraft={setResumeProjectsDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={maxProjectsTotal}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
handleClearByStatuses={handleClearByStatuses}
|
||||
handleClearDatabase={handleClearDatabase}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Accordion>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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">{effectiveModel || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Pipeline status webhook URL</div>
|
||||
<Input
|
||||
value={pipelineWebhookUrlDraft}
|
||||
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultPipelineWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectivePipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Job completion webhook URL</div>
|
||||
<Input
|
||||
value={jobCompleteWebhookUrlDraft}
|
||||
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST when you mark a job as applied (includes the job description).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Gradcracker Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Search Terms</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<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={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
|
||||
setSearchTermsDraft(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clean up on blur
|
||||
if (searchTermsDraft) {
|
||||
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{(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>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">JobSpy Scraper</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<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">
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
||||
</div>
|
||||
</div>
|
||||
<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: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={500}
|
||||
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyResultsWantedDraft(null)
|
||||
} else {
|
||||
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={168}
|
||||
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyHoursOldDraft(null)
|
||||
} else {
|
||||
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
|
||||
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="linkedin-desc"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Resume Projects</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
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(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.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
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<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>
|
||||
</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-xs text-muted-foreground">
|
||||
Select which job statuses you want to clear.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{ALL_JOB_STATUSES.map((status) => {
|
||||
const isSelected = statusesToClear.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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-xs text-muted-foreground">
|
||||
{STATUS_DESCRIPTIONS[status]}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving || statusesToClear.length === 0}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Selected ({statusesToClear.length})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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-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}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Database
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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">
|
||||
Clear database
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
import React from "react"
|
||||
import { AlertTriangle, Trash2 } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
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 "../constants"
|
||||
|
||||
type DangerZoneSectionProps = {
|
||||
statusesToClear: JobStatus[]
|
||||
toggleStatusToClear: (status: JobStatus) => void
|
||||
handleClearByStatuses: () => void
|
||||
handleClearDatabase: () => void
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
statusesToClear,
|
||||
toggleStatusToClear,
|
||||
handleClearByStatuses,
|
||||
handleClearDatabase,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
</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-xs text-muted-foreground">
|
||||
Select which job statuses you want to clear.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{ALL_JOB_STATUSES.map((status) => {
|
||||
const isSelected = statusesToClear.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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-xs text-muted-foreground">
|
||||
{STATUS_DESCRIPTIONS[status]}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving || statusesToClear.length === 0}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Selected ({statusesToClear.length})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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-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}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Database
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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">
|
||||
Clear database
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
gradcrackerMaxJobsPerTermDraft: number | null
|
||||
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
|
||||
defaultGradcrackerMaxJobsPerTerm: number
|
||||
effectiveGradcrackerMaxJobsPerTerm: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
gradcrackerMaxJobsPerTermDraft,
|
||||
setGradcrackerMaxJobsPerTermDraft,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
effectiveGradcrackerMaxJobsPerTerm,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Gradcracker Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobCompleteWebhookSectionProps = {
|
||||
jobCompleteWebhookUrlDraft: string
|
||||
setJobCompleteWebhookUrlDraft: (value: string) => void
|
||||
defaultJobCompleteWebhookUrl: string
|
||||
effectiveJobCompleteWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
|
||||
jobCompleteWebhookUrlDraft,
|
||||
setJobCompleteWebhookUrlDraft,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
effectiveJobCompleteWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Job completion webhook URL</div>
|
||||
<Input
|
||||
value={jobCompleteWebhookUrlDraft}
|
||||
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST when you mark a job as applied (includes the job description).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,240 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobspySectionProps = {
|
||||
jobspySitesDraft: string[] | null
|
||||
setJobspySitesDraft: (value: string[] | null) => void
|
||||
defaultJobspySites: string[]
|
||||
effectiveJobspySites: string[]
|
||||
jobspyLocationDraft: string | null
|
||||
setJobspyLocationDraft: (value: string | null) => void
|
||||
defaultJobspyLocation: string
|
||||
effectiveJobspyLocation: string
|
||||
jobspyResultsWantedDraft: number | null
|
||||
setJobspyResultsWantedDraft: (value: number | null) => void
|
||||
defaultJobspyResultsWanted: number
|
||||
effectiveJobspyResultsWanted: number
|
||||
jobspyHoursOldDraft: number | null
|
||||
setJobspyHoursOldDraft: (value: number | null) => void
|
||||
defaultJobspyHoursOld: number
|
||||
effectiveJobspyHoursOld: number
|
||||
jobspyCountryIndeedDraft: string | null
|
||||
setJobspyCountryIndeedDraft: (value: string | null) => void
|
||||
defaultJobspyCountryIndeed: string
|
||||
effectiveJobspyCountryIndeed: string
|
||||
jobspyLinkedinFetchDescriptionDraft: boolean | null
|
||||
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
|
||||
defaultJobspyLinkedinFetchDescription: boolean
|
||||
effectiveJobspyLinkedinFetchDescription: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
jobspySitesDraft,
|
||||
setJobspySitesDraft,
|
||||
defaultJobspySites,
|
||||
effectiveJobspySites,
|
||||
jobspyLocationDraft,
|
||||
setJobspyLocationDraft,
|
||||
defaultJobspyLocation,
|
||||
effectiveJobspyLocation,
|
||||
jobspyResultsWantedDraft,
|
||||
setJobspyResultsWantedDraft,
|
||||
defaultJobspyResultsWanted,
|
||||
effectiveJobspyResultsWanted,
|
||||
jobspyHoursOldDraft,
|
||||
setJobspyHoursOldDraft,
|
||||
defaultJobspyHoursOld,
|
||||
effectiveJobspyHoursOld,
|
||||
jobspyCountryIndeedDraft,
|
||||
setJobspyCountryIndeedDraft,
|
||||
defaultJobspyCountryIndeed,
|
||||
effectiveJobspyCountryIndeed,
|
||||
jobspyLinkedinFetchDescriptionDraft,
|
||||
setJobspyLinkedinFetchDescriptionDraft,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
effectiveJobspyLinkedinFetchDescription,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">JobSpy Scraper</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<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">
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
||||
</div>
|
||||
</div>
|
||||
<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: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={500}
|
||||
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyResultsWantedDraft(null)
|
||||
} else {
|
||||
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={168}
|
||||
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyHoursOldDraft(null)
|
||||
} else {
|
||||
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
|
||||
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="linkedin-desc"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type ModelSettingsSectionProps = {
|
||||
modelDraft: string
|
||||
setModelDraft: (value: string) => void
|
||||
modelScorerDraft: string
|
||||
setModelScorerDraft: (value: string) => void
|
||||
modelTailoringDraft: string
|
||||
setModelTailoringDraft: (value: string) => void
|
||||
modelProjectSelectionDraft: string
|
||||
setModelProjectSelectionDraft: (value: string) => void
|
||||
effectiveModel: string
|
||||
effectiveModelScorer: string
|
||||
effectiveModelTailoring: string
|
||||
effectiveModelProjectSelection: string
|
||||
defaultModel: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
modelDraft,
|
||||
setModelDraft,
|
||||
modelScorerDraft,
|
||||
setModelScorerDraft,
|
||||
modelTailoringDraft,
|
||||
setModelTailoringDraft,
|
||||
modelProjectSelectionDraft,
|
||||
setModelProjectSelectionDraft,
|
||||
effectiveModel,
|
||||
effectiveModelScorer,
|
||||
effectiveModelTailoring,
|
||||
effectiveModelProjectSelection,
|
||||
defaultModel,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Model</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Override model</div>
|
||||
<Input
|
||||
value={modelDraft}
|
||||
onChange={(event) => setModelDraft(event.target.value)}
|
||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default from server env (`MODEL`).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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">{effectiveModel || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type PipelineWebhookSectionProps = {
|
||||
pipelineWebhookUrlDraft: string
|
||||
setPipelineWebhookUrlDraft: (value: string) => void
|
||||
defaultPipelineWebhookUrl: string
|
||||
effectivePipelineWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
|
||||
pipelineWebhookUrlDraft,
|
||||
setPipelineWebhookUrlDraft,
|
||||
defaultPipelineWebhookUrl,
|
||||
effectivePipelineWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Pipeline status webhook URL</div>
|
||||
<Input
|
||||
value={pipelineWebhookUrlDraft}
|
||||
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultPipelineWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectivePipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
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 { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
||||
import { clampInt } from "../utils"
|
||||
|
||||
type ResumeProjectsSectionProps = {
|
||||
resumeProjectsDraft: ResumeProjectsSettings | null
|
||||
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
|
||||
profileProjects: ResumeProjectCatalogItem[]
|
||||
lockedCount: number
|
||||
maxProjectsTotal: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
|
||||
resumeProjectsDraft,
|
||||
setResumeProjectsDraft,
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Resume Projects</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
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(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.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
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type SearchTermsSectionProps = {
|
||||
searchTermsDraft: string[] | null
|
||||
setSearchTermsDraft: (value: string[] | null) => void
|
||||
defaultSearchTerms: string[]
|
||||
effectiveSearchTerms: string[]
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
searchTermsDraft,
|
||||
setSearchTermsDraft,
|
||||
defaultSearchTerms,
|
||||
effectiveSearchTerms,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Search Terms</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<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={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
|
||||
setSearchTermsDraft(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clean up on blur
|
||||
if (searchTermsDraft) {
|
||||
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{(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>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
ukvisajobsMaxJobsDraft: number | null
|
||||
setUkvisajobsMaxJobsDraft: (value: number | null) => void
|
||||
defaultUkvisajobsMaxJobs: number
|
||||
effectiveUkvisajobsMaxJobs: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
ukvisajobsMaxJobsDraft,
|
||||
setUkvisajobsMaxJobsDraft,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
effectiveUkvisajobsMaxJobs,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
18
orchestrator/src/client/pages/settings/constants.ts
Normal file
18
orchestrator/src/client/pages/settings/constants.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Settings page constants.
|
||||
*/
|
||||
|
||||
import type { JobStatus } from "@shared/types"
|
||||
|
||||
/** All available job statuses for clearing */
|
||||
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',
|
||||
}
|
||||
27
orchestrator/src/client/pages/settings/utils.ts
Normal file
27
orchestrator/src/client/pages/settings/utils.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Settings page helpers.
|
||||
*/
|
||||
|
||||
import type { ResumeProjectsSettings } from "@shared/types"
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||
return (
|
||||
a.maxProjects === b.maxProjects &&
|
||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
export 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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user