From 3056369a7514772589ad8645ad55e5d08eaaa94e Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 00:50:49 +0000 Subject: [PATCH 01/12] initial table implementation --- .../src/client/components/JobList.tsx | 311 +++++++++++++++--- .../src/client/components/JobTable.tsx | 297 +++++++++++++++++ orchestrator/src/client/components/index.ts | 1 + orchestrator/src/components/ui/input.tsx | 23 ++ orchestrator/src/components/ui/table.tsx | 113 +++++++ 5 files changed, 703 insertions(+), 42 deletions(-) create mode 100644 orchestrator/src/client/components/JobTable.tsx create mode 100644 orchestrator/src/components/ui/input.tsx create mode 100644 orchestrator/src/components/ui/table.tsx diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 9e48c97..866173d 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -2,14 +2,17 @@ * Job list with filtering tabs. */ -import React, { useMemo, useState } from "react"; -import { Loader2, RefreshCcw } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { LayoutGrid, Loader2, RefreshCcw, Search, Table2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { JobCard } from "./JobCard"; +import { JobTable, type JobSort } from "./JobTable"; interface JobListProps { jobs: Job[]; @@ -22,6 +25,10 @@ interface JobListProps { } type FilterTab = "ready" | "discovered" | "applied" | "all"; +type ViewMode = "cards" | "table"; + +const JOB_LIST_VIEW_STORAGE_KEY = "jobops.jobs.viewMode"; +const DEFAULT_SORT: JobSort = { key: "discoveredAt", direction: "desc" }; const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ { id: "ready", label: "Ready", statuses: ["ready"] }, @@ -37,6 +44,110 @@ const emptyStateCopy: Record = { all: "No jobs in the system yet. Run the pipeline to get started!", }; +const statusRank: Record = { + discovered: 0, + processing: 1, + ready: 2, + applied: 3, + rejected: 4, + expired: 5, +}; + +const dateValue = (value: string | null) => { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const compareNullable = ( + a: T | null | undefined, + b: T | null | undefined, + compare: (left: T, right: T) => number, +) => { + const left = a ?? null; + const right = b ?? null; + if (left === null && right === null) return 0; + if (left === null) return 1; + if (right === null) return -1; + return compare(left, right); +}; + +const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); + +const compareNumber = (a: number, b: number) => a - b; + +const compareJobs = (a: Job, b: Job, sort: JobSort) => { + let value = 0; + + switch (sort.key) { + case "title": + value = compareString(a.title, b.title); + break; + case "employer": + value = compareString(a.employer, b.employer); + break; + case "source": + value = compareString(a.source, b.source); + break; + case "location": + value = compareNullable(a.location, b.location, compareString); + break; + case "status": + value = statusRank[a.status] - statusRank[b.status]; + break; + case "score": + if (a.suitabilityScore == null && b.suitabilityScore == null) { + value = 0; + break; + } + if (a.suitabilityScore == null) return 1; + if (b.suitabilityScore == null) return -1; + value = compareNumber(a.suitabilityScore, b.suitabilityScore); + break; + case "discoveredAt": + value = compareNullable(dateValue(a.discoveredAt), dateValue(b.discoveredAt), compareNumber); + break; + default: + value = 0; + } + + if (value !== 0) return sort.direction === "asc" ? value : -value; + + const tieByDiscovered = compareNullable( + dateValue(b.discoveredAt), + dateValue(a.discoveredAt), + compareNumber, + ); + if (tieByDiscovered !== 0) return tieByDiscovered; + + return a.id.localeCompare(b.id); +}; + +const jobMatchesQuery = (job: Job, query: string) => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + + const haystack = [ + job.title, + job.employer, + job.location, + job.disciplines, + job.salary, + job.degreeRequired, + job.starting, + job.source, + job.status, + job.jobType, + job.jobFunction, + job.jobLevel, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return haystack.includes(normalized); +}; + export const JobList: React.FC = ({ jobs, onApply, @@ -47,6 +158,25 @@ export const JobList: React.FC = ({ isProcessingAll, }) => { const [activeTab, setActiveTab] = useState("ready"); + const [searchQuery, setSearchQuery] = useState(""); + const [sort, setSort] = useState(DEFAULT_SORT); + const [viewMode, setViewMode] = useState(() => { + try { + const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY); + if (raw === "cards" || raw === "table") return raw; + return "cards"; + } catch { + return "cards"; + } + }); + + useEffect(() => { + try { + localStorage.setItem(JOB_LIST_VIEW_STORAGE_KEY, viewMode); + } catch { + // Ignore localStorage errors + } + }, [viewMode]); const counts = useMemo(() => { const byTab: Record = { @@ -79,43 +209,121 @@ export const JobList: React.FC = ({ return map; }, [jobs]); + const visibleJobsForTab = useMemo(() => { + const map = new Map(); + const normalizedQuery = searchQuery.trim().toLowerCase(); + + for (const tab of tabs) { + const base = jobsForTab.get(tab.id) ?? []; + const filtered = normalizedQuery ? base.filter((job) => jobMatchesQuery(job, normalizedQuery)) : base; + const sorted = [...filtered].sort((a, b) => compareJobs(a, b, sort)); + map.set(tab.id, sorted); + } + + return map; + }, [jobsForTab, searchQuery, sort]); + + const activeResultsCount = visibleJobsForTab.get(activeTab)?.length ?? 0; + const hasActiveFilters = + searchQuery.trim().length > 0 || + sort.key !== DEFAULT_SORT.key || + sort.direction !== DEFAULT_SORT.direction; + return ( setActiveTab(value as FilterTab)} className="space-y-4" > -
- - {tabs.map((tab) => ( - - {tab.label} - - ({counts[tab.id]}) - - - ))} - +
+
+ + {tabs.map((tab) => ( + + {tab.label} + + ({counts[tab.id]}) + + + ))} + - {activeTab === "discovered" && counts.discovered > 0 && ( - )} - - )} + +
+ + +
+
+
+ +
+
+ + setSearchQuery(event.target.value)} + placeholder="Filter jobs..." + className="pl-9" + /> +
+ +
+ {activeResultsCount} jobs + {hasActiveFilters && ( + + )} +
+
{tabs.map((tab) => { - const filteredJobs = jobsForTab.get(tab.id) ?? []; + const filteredJobs = visibleJobsForTab.get(tab.id) ?? []; + const trimmedQuery = searchQuery.trim(); return ( @@ -123,22 +331,42 @@ export const JobList: React.FC = ({
No jobs found
-

{emptyStateCopy[tab.id]}

+

+ {trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]} +

) : ( -
- {filteredJobs.map((job) => ( - - ))} -
+ <> + {viewMode === "table" ? ( + + + + + + ) : ( +
+ {filteredJobs.map((job) => ( + + ))} +
+ )} + )}
); @@ -146,4 +374,3 @@ export const JobList: React.FC = ({
); }; - diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx new file mode 100644 index 0000000..3e05a83 --- /dev/null +++ b/orchestrator/src/client/components/JobTable.tsx @@ -0,0 +1,297 @@ +/** + * Table-based job list view. + */ + +import React from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + CheckCircle2, + Download, + ExternalLink, + MoreHorizontal, + RefreshCcw, + XCircle, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { Job } from "../../shared/types"; +import { StatusBadge } from "./StatusBadge"; + +export type JobSortKey = + | "title" + | "employer" + | "source" + | "location" + | "status" + | "score" + | "discoveredAt"; + +export type JobSortDirection = "asc" | "desc"; + +export interface JobSort { + key: JobSortKey; + direction: JobSortDirection; +} + +export interface JobTableProps { + jobs: Job[]; + sort: JobSort; + onSortChange: (sort: JobSort) => void; + onApply: (id: string) => void; + onReject: (id: string) => void; + onProcess: (id: string) => void; + processingJobId: string | null; +} + +const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", +}; + +const defaultSortDirection: Record = { + title: "asc", + employer: "asc", + source: "asc", + location: "asc", + status: "asc", + score: "desc", + discoveredAt: "desc", +}; + +const formatDate = (dateStr: string | null) => { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + } catch { + return dateStr; + } +}; + +const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); + +const SortButton: React.FC<{ + label: string; + sortKey: JobSortKey; + sort: JobSort; + onSortChange: (sort: JobSort) => void; + className?: string; +}> = ({ label, sortKey, sort, onSortChange, className }) => { + const isActive = sort.key === sortKey; + const Icon = isActive ? (sort.direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; + + return ( + + ); +}; + +export const JobTable: React.FC = ({ + jobs, + sort, + onSortChange, + onApply, + onReject, + onProcess, + processingJobId, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + Actions + + + + + {jobs.map((job) => { + const jobLink = job.applicationLink || job.jobUrl; + const hasPdf = !!job.pdfPath; + const pdfHref = `/pdfs/resume_${job.id}.pdf`; + + const canApply = job.status === "ready"; + const canProcess = job.status === "discovered"; + const canReject = ["discovered", "ready"].includes(job.status); + const isProcessing = processingJobId === job.id; + + return ( + + + + + + + {job.employer} + + + + + {sourceLabel[job.source]} + + + + + {job.location || "—"} + + + + + + + + {job.suitabilityScore ?? "—"} + + + + {formatDate(job.discoveredAt)} + + + + + + + + + + + + + View Job + + + + {hasPdf && ( + <> + + + + View PDF + + + + + + Download PDF + + + + )} + + {(canProcess || canReject || canApply) && } + + {canProcess && ( + onProcess(job.id)} + disabled={isProcessing} + > + + {isProcessing ? "Processing..." : "Generate Resume"} + + )} + + {canReject && ( + onReject(job.id)} + > + + Skip + + )} + + {canApply && ( + onApply(job.id)} + > + + Mark Applied + + )} + + + + + ); + })} + +
+ ); +}; diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index b8adf8b..b5baf1a 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -3,5 +3,6 @@ export { Stats } from './Stats'; export { StatusBadge } from './StatusBadge'; export { ScoreIndicator } from './ScoreIndicator'; export { JobCard } from './JobCard'; +export { JobTable } from './JobTable'; export { JobList } from './JobList'; export { PipelineProgress } from './PipelineProgress'; diff --git a/orchestrator/src/components/ui/input.tsx b/orchestrator/src/components/ui/input.tsx new file mode 100644 index 0000000..04aefeb --- /dev/null +++ b/orchestrator/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } + diff --git a/orchestrator/src/components/ui/table.tsx b/orchestrator/src/components/ui/table.tsx new file mode 100644 index 0000000..84287ad --- /dev/null +++ b/orchestrator/src/components/ui/table.tsx @@ -0,0 +1,113 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", className)} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} + From 77b23317b8e948f4987c81d3387804a5d9039f02 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 00:54:52 +0000 Subject: [PATCH 02/12] text wrapping --- orchestrator/src/client/components/JobList.tsx | 11 +++++++---- orchestrator/src/client/components/JobTable.tsx | 10 +++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 866173d..07ac840 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -96,13 +96,16 @@ const compareJobs = (a: Job, b: Job, sort: JobSort) => { value = statusRank[a.status] - statusRank[b.status]; break; case "score": - if (a.suitabilityScore == null && b.suitabilityScore == null) { + const aScore = a.suitabilityScore; + const bScore = b.suitabilityScore; + + if (aScore == null && bScore == null) { value = 0; break; } - if (a.suitabilityScore == null) return 1; - if (b.suitabilityScore == null) return -1; - value = compareNumber(a.suitabilityScore, b.suitabilityScore); + if (aScore == null) return 1; + if (bScore == null) return -1; + value = compareNumber(aScore, bScore); break; case "discoveredAt": value = compareNullable(dateValue(a.discoveredAt), dateValue(b.discoveredAt), compareNumber); diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx index 3e05a83..4d81f2d 100644 --- a/orchestrator/src/client/components/JobTable.tsx +++ b/orchestrator/src/client/components/JobTable.tsx @@ -181,12 +181,12 @@ export const JobTable: React.FC = ({ return ( - + - - {job.employer} + + {job.employer} @@ -204,7 +204,7 @@ export const JobTable: React.FC = ({ - + {job.location || "—"} From 4244c908e55617e32f6fb8185590203a7307ae74 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 01:19:01 +0000 Subject: [PATCH 03/12] ensure that a "discovered" job must have a score --- orchestrator/src/client/App.tsx | 32 ----- orchestrator/src/client/api/client.ts | 13 +- orchestrator/src/client/components/Header.tsx | 106 ++++++++-------- .../src/client/components/JobList.tsx | 22 +--- orchestrator/src/server/api/routes.ts | 33 ----- .../src/server/pipeline/orchestrator.ts | 113 ++++-------------- orchestrator/src/server/pipeline/progress.ts | 12 +- orchestrator/src/server/repositories/jobs.ts | 16 ++- 8 files changed, 96 insertions(+), 251 deletions(-) diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 60a363c..79f686f 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -26,7 +26,6 @@ export const App: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const [processingJobId, setProcessingJobId] = useState(null); - const [isProcessingAll, setIsProcessingAll] = useState(false); const [pipelineSources, setPipelineSources] = useState(() => { try { const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); @@ -159,35 +158,6 @@ export const App: React.FC = () => { } }; - const handleProcessAll = async () => { - try { - setIsProcessingAll(true); - const result = await api.processAllDiscovered(); - toast.message("Processing jobs", { description: `Processing ${result.count} jobs in background...` }); - - const pollInterval = setInterval(async () => { - try { - const data = await api.getJobs(); - setJobs(data.jobs); - setStats(data.byStatus); - - const stillDiscovered = data.byStatus.discovered + data.byStatus.processing; - if (stillDiscovered === 0) { - clearInterval(pollInterval); - setIsProcessingAll(false); - toast.success("All jobs processed"); - } - } catch { - // Ignore errors - } - }, 3000); - } catch (error) { - setIsProcessingAll(false); - const message = error instanceof Error ? error.message : "Failed to process jobs"; - toast.error(message); - } - }; - return ( <>
{ onApply={handleApply} onReject={handleReject} onProcess={handleProcess} - onProcessAll={handleProcessAll} processingJobId={processingJobId} - isProcessingAll={isProcessingAll} /> diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 5f26054..6f09f0d 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -107,15 +107,4 @@ export async function clearDatabase(): Promise<{ }); } -// Bulk operations -export async function processAllDiscovered(): Promise<{ - message: string; - count: number; -}> { - return fetchApi<{ - message: string; - count: number; - }>('/jobs/process-discovered', { - method: 'POST', - }); -} +// Bulk operations (intentionally none - processing is manual) diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 605e3f9..a34b5fd 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -112,62 +112,60 @@ export const Header: React.FC = ({ Refresh -
- + - - - + + + Sources + + {orderedSources.map((source) => ( + toggleSource(source, Boolean(checked))} > - - - - - Sources - - {orderedSources.map((source) => ( - toggleSource(source, Boolean(checked))} - > - {sourceLabel[source]} - - ))} - - onPipelineSourcesChange(orderedSources)}> - All sources - - onPipelineSourcesChange(["gradcracker"])}> - Gradcracker only - - onPipelineSourcesChange(["indeed", "linkedin"])}> - Indeed + LinkedIn only - - - -
+ {sourceLabel[source]} + + ))} + + onPipelineSourcesChange(orderedSources)}> + All sources + + onPipelineSourcesChange(["gradcracker"])}> + Gradcracker only + + onPipelineSourcesChange(["indeed", "linkedin"])}> + Indeed + LinkedIn only + + +
diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 07ac840..23b1dac 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -3,7 +3,7 @@ */ import React, { useEffect, useMemo, useState } from "react"; -import { LayoutGrid, Loader2, RefreshCcw, Search, Table2 } from "lucide-react"; +import { LayoutGrid, Search, Table2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -19,9 +19,7 @@ interface JobListProps { onApply: (id: string) => void; onReject: (id: string) => void; onProcess: (id: string) => void; - onProcessAll: () => void; processingJobId: string | null; - isProcessingAll: boolean; } type FilterTab = "ready" | "discovered" | "applied" | "all"; @@ -156,9 +154,7 @@ export const JobList: React.FC = ({ onApply, onReject, onProcess, - onProcessAll, processingJobId, - isProcessingAll, }) => { const [activeTab, setActiveTab] = useState("ready"); const [searchQuery, setSearchQuery] = useState(""); @@ -252,22 +248,6 @@ export const JobList: React.FC = ({
- {activeTab === "discovered" && counts.discovered > 0 && ( - - )} -
+ + + +
+ + + + ) +} + diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index 0aaf59d..bd66185 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import * as jobsRepo from '../repositories/jobs.js'; import * as pipelineRepo from '../repositories/pipeline.js'; +import * as settingsRepo from '../repositories/settings.js'; import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js'; import { createNotionEntry } from '../services/notion.js'; import { clearDatabase } from '../db/clear.js'; @@ -174,6 +175,63 @@ apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => { // Pipeline API // ============================================================================ +/** + * GET /api/settings - Get app settings (effective + defaults) + */ +apiRouter.get('/settings', async (_req: Request, res: Response) => { + try { + const overrideModel = await settingsRepo.getSetting('model'); + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + const model = overrideModel || defaultModel; + + res.json({ + success: true, + data: { + model, + defaultModel, + overrideModel, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } +}); + +const updateSettingsSchema = z.object({ + model: z.string().trim().min(1).max(200).nullable().optional(), +}); + +/** + * PATCH /api/settings - Update settings overrides + */ +apiRouter.patch('/settings', async (req: Request, res: Response) => { + try { + const input = updateSettingsSchema.parse(req.body); + + if ('model' in input) { + const model = input.model ?? null; + await settingsRepo.setSetting('model', model); + } + + const overrideModel = await settingsRepo.getSetting('model'); + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + const model = overrideModel || defaultModel; + + res.json({ + success: true, + data: { + model, + defaultModel, + overrideModel, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(400).json({ success: false, error: message }); + } +}); + /** * GET /api/pipeline/status - Get pipeline status */ diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index 4792170..051314b 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -88,6 +88,13 @@ const migrations = [ error_message TEXT )`, + `CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + // Add source column for existing databases (safe to skip if already present) `ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`, `UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`, diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts index 3d66498..f317983 100644 --- a/orchestrator/src/server/db/schema.ts +++ b/orchestrator/src/server/db/schema.ts @@ -82,7 +82,16 @@ export const pipelineRuns = sqliteTable('pipeline_runs', { errorMessage: text('error_message'), }); +export const settings = sqliteTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), + updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`), +}); + export type JobRow = typeof jobs.$inferSelect; export type NewJobRow = typeof jobs.$inferInsert; export type PipelineRunRow = typeof pipelineRuns.$inferSelect; export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert; +export type SettingsRow = typeof settings.$inferSelect; +export type NewSettingsRow = typeof settings.$inferInsert; diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts new file mode 100644 index 0000000..70602e0 --- /dev/null +++ b/orchestrator/src/server/repositories/settings.ts @@ -0,0 +1,42 @@ +/** + * Settings repository - key/value storage for runtime configuration. + */ + +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' + +const { settings } = schema + +export type SettingKey = 'model' + +export async function getSetting(key: SettingKey): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, key)) + return row?.value ?? null +} + +export async function setSetting(key: SettingKey, value: string | null): Promise { + const now = new Date().toISOString() + + if (value === null) { + await db.delete(settings).where(eq(settings.key, key)) + return + } + + const [existing] = await db.select({ key: settings.key }).from(settings).where(eq(settings.key, key)) + + if (existing) { + await db + .update(settings) + .set({ value, updatedAt: now }) + .where(eq(settings.key, key)) + return + } + + await db.insert(settings).values({ + key, + value, + createdAt: now, + updatedAt: now, + }) +} + diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 814739e..71428b5 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -3,6 +3,7 @@ */ import type { Job } from '../../shared/types.js'; +import { getSetting } from '../repositories/settings.js'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -24,7 +25,8 @@ export async function scoreJobSuitability( return mockScore(job); } - const model = process.env.MODEL || 'openai/gpt-4o-mini'; + const overrideModel = await getSetting('model'); + const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildScoringPrompt(job, profile); diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 235969c..8e65dc3 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -171,3 +171,9 @@ export interface PipelineStatusResponse { lastRun: PipelineRun | null; nextScheduledRun: string | null; } + +export interface AppSettings { + model: string; + defaultModel: string; + overrideModel: string | null; +} From 9dd1b7a36e583eccdb419febc5fab840c9f0f07a Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 16:17:40 +0000 Subject: [PATCH 05/12] show filtering all the time --- .../src/client/components/JobList.tsx | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 23b1dac..34366bb 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -3,10 +3,20 @@ */ import React, { useEffect, useMemo, useState } from "react"; -import { LayoutGrid, Search, Table2 } from "lucide-react"; +import { ArrowUpDown, LayoutGrid, Search, Table2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; @@ -28,6 +38,16 @@ type ViewMode = "cards" | "table"; const JOB_LIST_VIEW_STORAGE_KEY = "jobops.jobs.viewMode"; const DEFAULT_SORT: JobSort = { key: "discoveredAt", direction: "desc" }; +const sortLabels: Record = { + discoveredAt: "Discovered", + score: "Score", + title: "Title", + employer: "Company", + source: "Source", + location: "Location", + status: "Status", +}; + const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ { id: "ready", label: "Ready", statuses: ["ready"] }, { id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] }, @@ -286,7 +306,52 @@ export const JobList: React.FC = ({ />
-
+
+ + + + + + Sort by + + + setSort({ + key: value as JobSort["key"], + direction: + value === "score" || value === "discoveredAt" + ? "desc" + : "asc", + }) + } + > + {(Object.keys(sortLabels) as Array).map((key) => ( + + {sortLabels[key]} + + ))} + + + + setSort((current) => ({ + ...current, + direction: current.direction === "asc" ? "desc" : "asc", + })) + } + > + Direction: {sort.direction === "asc" ? "Ascending" : "Descending"} + + + + {activeResultsCount} jobs {hasActiveFilters && ( + + + +
+
+ )} + + + + + + + ) : (
{filteredJobs.map((job) => ( diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx index 4d81f2d..a924a0f 100644 --- a/orchestrator/src/client/components/JobTable.tsx +++ b/orchestrator/src/client/components/JobTable.tsx @@ -17,6 +17,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -49,9 +50,11 @@ export interface JobTableProps { jobs: Job[]; sort: JobSort; onSortChange: (sort: JobSort) => void; - onApply: (id: string) => void; - onReject: (id: string) => void; - onProcess: (id: string) => void; + selectedJobIds: Set; + onSelectedJobIdsChange: (ids: Set) => void; + onApply: (id: string) => void | Promise; + onReject: (id: string) => void | Promise; + onProcess: (id: string) => void | Promise; processingJobId: string | null; } @@ -123,15 +126,36 @@ export const JobTable: React.FC = ({ jobs, sort, onSortChange, + selectedJobIds, + onSelectedJobIdsChange, onApply, onReject, onProcess, processingJobId, }) => { + const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0); + const allSelected = jobs.length > 0 && selectedCount === jobs.length; + const someSelected = selectedCount > 0 && selectedCount < jobs.length; + return ( + + { + const next = new Set(selectedJobIds); + if (checked) { + for (const job of jobs) next.add(job.id); + } else { + for (const job of jobs) next.delete(job.id); + } + onSelectedJobIdsChange(next); + }} + /> + @@ -178,9 +202,22 @@ export const JobTable: React.FC = ({ const canProcess = job.status === "discovered"; const canReject = ["discovered", "ready"].includes(job.status); const isProcessing = processingJobId === job.id; + const isSelected = selectedJobIds.has(job.id); return ( - + + + { + const next = new Set(selectedJobIds); + if (checked) next.add(job.id); + else next.delete(job.id); + onSelectedJobIdsChange(next); + }} + /> + - + + + Pipeline Webhook + + + +
+
Pipeline status webhook URL
+ setPipelineWebhookUrlDraft(event.target.value)} + placeholder={defaultPipelineWebhookUrl || "https://..."} + disabled={isLoading || isSaving} + /> +
+ When set, the server sends a POST on pipeline completion/failure. Leave blank to disable. +
+
+ + + +
+
+
Effective
+
{effectivePipelineWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultPipelineWebhookUrl || "—"}
+
+ +
+ + +
) } - diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index bd66185..5fd6f63 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -184,12 +184,19 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => { const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; const model = overrideModel || defaultModel; + const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); + const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; + const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; + res.json({ success: true, data: { model, defaultModel, overrideModel, + pipelineWebhookUrl, + defaultPipelineWebhookUrl, + overridePipelineWebhookUrl, }, }); } catch (error) { @@ -200,6 +207,7 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => { const updateSettingsSchema = z.object({ model: z.string().trim().min(1).max(200).nullable().optional(), + pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(), }); /** @@ -214,16 +222,28 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => { await settingsRepo.setSetting('model', model); } + if ('pipelineWebhookUrl' in input) { + const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null; + await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl); + } + const overrideModel = await settingsRepo.getSetting('model'); const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; const model = overrideModel || defaultModel; + const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); + const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; + const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; + res.json({ success: true, data: { model, defaultModel, overrideModel, + pipelineWebhookUrl, + defaultPipelineWebhookUrl, + overridePipelineWebhookUrl, }, }); } catch (error) { diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index 051314b..fd0ef71 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -95,6 +95,11 @@ const migrations = [ updated_at TEXT NOT NULL DEFAULT (datetime('now')) )`, + // Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run) + `INSERT OR REPLACE INTO settings(key, value, created_at, updated_at) + SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`, + `DELETE FROM settings WHERE key = 'webhookUrl'`, + // Add source column for existing databases (safe to skip if already present) `ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`, `UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`, diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index f8ac63c..6c9b571 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -17,6 +17,7 @@ import { generateSummary } from '../services/summary.js'; import { generatePdf } from '../services/pdf.js'; import * as jobsRepo from '../repositories/jobs.js'; import * as pipelineRepo from '../repositories/pipeline.js'; +import * as settingsRepo from '../repositories/settings.js'; import { progressHelpers, resetProgress, updateProgress } from './progress.js'; import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js'; @@ -34,6 +35,42 @@ const DEFAULT_CONFIG: PipelineConfig = { // Track if pipeline is currently running let isPipelineRunning = false; +async function notifyPipelineWebhook( + event: 'pipeline.completed' | 'pipeline.failed', + payload: Record +) { + const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl') + const pipelineWebhookUrl = ( + overridePipelineWebhookUrl || + process.env.PIPELINE_WEBHOOK_URL || + process.env.WEBHOOK_URL || + '' + ).trim() + if (!pipelineWebhookUrl) return + + try { + const headers: Record = { 'Content-Type': 'application/json' } + const secret = process.env.WEBHOOK_SECRET + if (secret) headers.Authorization = `Bearer ${secret}` + + const response = await fetch(pipelineWebhookUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + event, + sentAt: new Date().toISOString(), + ...payload, + }), + }) + + if (!response.ok) { + console.warn(`āš ļø Pipeline webhook POST failed (${response.status}): ${await response.text()}`) + } + } catch (error) { + console.warn('āš ļø Pipeline webhook POST failed:', error) + } +} + /** * Run the full job discovery and processing pipeline. */ @@ -196,6 +233,13 @@ export async function runPipeline(config: Partial = {}): Promise console.log(' Jobs processed: 0 (manual)'); progressHelpers.complete(created, 0); + + await notifyPipelineWebhook('pipeline.completed', { + pipelineRunId: pipelineRun.id, + jobsDiscovered: created, + jobsScored: unprocessedJobs.length, + jobsProcessed: 0, + }) isPipelineRunning = false; return { @@ -214,6 +258,11 @@ export async function runPipeline(config: Partial = {}): Promise }); progressHelpers.failed(message); + + await notifyPipelineWebhook('pipeline.failed', { + pipelineRunId: pipelineRun.id, + error: message, + }) isPipelineRunning = false; console.error('\nāŒ Pipeline failed:', message); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 70602e0..d86115f 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -8,6 +8,7 @@ import { db, schema } from '../db/index.js' const { settings } = schema export type SettingKey = 'model' + | 'pipelineWebhookUrl' export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)) @@ -39,4 +40,3 @@ export async function setSetting(key: SettingKey, value: string | null): Promise updatedAt: now, }) } - diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 8e65dc3..ac2b616 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -176,4 +176,7 @@ export interface AppSettings { model: string; defaultModel: string; overrideModel: string | null; + pipelineWebhookUrl: string; + defaultPipelineWebhookUrl: string; + overridePipelineWebhookUrl: string | null; } From 91b1f08000d7665c6de83d2f2c741c6e9a8ce7b9 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 17:28:45 +0000 Subject: [PATCH 08/12] jobcompletion webhook --- .env.example | 1 + orchestrator/.env.example | 1 + orchestrator/src/client/api/client.ts | 1 + .../src/client/pages/SettingsPage.tsx | 63 ++++++++++++++++++- orchestrator/src/server/api/routes.ts | 54 +++++++++++++++- .../src/server/repositories/settings.ts | 1 + orchestrator/src/shared/types.ts | 3 + 7 files changed, 120 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 68f8d97..8598d5e 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ NOTION_DATABASE_ID= # Optional: Webhook secret for n8n automation WEBHOOK_SECRET= PIPELINE_WEBHOOK_URL= +JOB_COMPLETE_WEBHOOK_URL= # ============================================================================= # JobSpy (Indeed/LinkedIn scraping) - optional diff --git a/orchestrator/.env.example b/orchestrator/.env.example index a92ecc4..a12357a 100644 --- a/orchestrator/.env.example +++ b/orchestrator/.env.example @@ -12,6 +12,7 @@ NOTION_DATABASE_ID= # Webhook security (optional) WEBHOOK_SECRET= PIPELINE_WEBHOOK_URL= +JOB_COMPLETE_WEBHOOK_URL= # Pipeline configuration PIPELINE_TOP_N=10 diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index cef2bf9..b01f939 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -101,6 +101,7 @@ export async function getSettings(): Promise { export async function updateSettings(update: { model?: string | null pipelineWebhookUrl?: string | null + jobCompleteWebhookUrl?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index d2c4690..92b34bc 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -16,6 +16,7 @@ export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [modelDraft, setModelDraft] = useState("") const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") + const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -29,6 +30,7 @@ export const SettingsPage: React.FC = () => { setSettings(data) setModelDraft(data.overrideModel ?? "") setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") + setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -50,6 +52,9 @@ export const SettingsPage: React.FC = () => { const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? "" const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? "" const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl + const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" + const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" + const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl const canSave = useMemo(() => { if (!settings) return false @@ -57,8 +62,22 @@ export const SettingsPage: React.FC = () => { const current = (overrideModel ?? "").trim() const nextWebhook = pipelineWebhookUrlDraft.trim() const currentWebhook = (overridePipelineWebhookUrl ?? "").trim() - return next !== current || nextWebhook !== currentWebhook - }, [modelDraft, overrideModel, settings, pipelineWebhookUrlDraft, overridePipelineWebhookUrl]) + const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim() + const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim() + return ( + next !== current || + nextWebhook !== currentWebhook || + nextJobCompleteWebhook !== currentJobCompleteWebhook + ) + }, [ + settings, + modelDraft, + pipelineWebhookUrlDraft, + jobCompleteWebhookUrlDraft, + overrideModel, + overridePipelineWebhookUrl, + overrideJobCompleteWebhookUrl, + ]) const handleSave = async () => { if (!settings) return @@ -66,13 +85,16 @@ export const SettingsPage: React.FC = () => { setIsSaving(true) const trimmed = modelDraft.trim() const webhookTrimmed = pipelineWebhookUrlDraft.trim() + const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, + jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") + setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -85,10 +107,11 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null }) + const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null }) setSettings(updated) setModelDraft("") setPipelineWebhookUrlDraft("") + setJobCompleteWebhookUrlDraft("") toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -173,6 +196,40 @@ export const SettingsPage: React.FC = () => { + + + Job Complete Webhook + + + +
+
Job completion webhook URL
+ setJobCompleteWebhookUrlDraft(event.target.value)} + placeholder={defaultJobCompleteWebhookUrl || "https://..."} + disabled={isLoading || isSaving} + /> +
+ When set, the server sends a POST when you mark a job as applied (includes the job description). +
+
+ + + +
+
+
Effective
+
{effectiveJobCompleteWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultJobCompleteWebhookUrl || "—"}
+
+
+
+
+
+ + {hasPdf && (
@@ -273,6 +285,11 @@ export const JobTable: React.FC = ({ + void handleCopyInfo(job)}> + + Copy info + + {hasPdf && ( <> diff --git a/orchestrator/src/client/lib/jobCopy.ts b/orchestrator/src/client/lib/jobCopy.ts new file mode 100644 index 0000000..3c00ad8 --- /dev/null +++ b/orchestrator/src/client/lib/jobCopy.ts @@ -0,0 +1,110 @@ +import type { Job } from "@shared/types"; + +const pushLine = (lines: string[], label: string, value: unknown) => { + if (value == null) return; + const normalized = typeof value === "string" ? value.trim() : String(value); + if (!normalized) return; + lines.push(`${label}: ${normalized}`); +}; + +const pushBlock = (lines: string[], heading: string, value: string | null | undefined) => { + const normalized = value?.trim(); + if (!normalized) return; + lines.push(""); + lines.push(`${heading}:`); + lines.push(normalized); +}; + +export const formatJobForLlmContext = (job: Job) => { + const jobLink = job.applicationLink || job.jobUrl; + + const lines: string[] = []; + lines.push("JOB CONTEXT"); + + pushLine(lines, "Title", job.title); + pushLine(lines, "Company", job.employer); + pushLine(lines, "Source", job.source); + pushLine(lines, "Status", job.status); + + pushLine(lines, "Job URL", job.jobUrl); + pushLine(lines, "Application link", job.applicationLink); + pushLine(lines, "Best link", jobLink); + pushLine(lines, "Direct URL", job.jobUrlDirect); + pushLine(lines, "Source job id", job.sourceJobId); + + pushLine(lines, "Location", job.location); + pushLine(lines, "Remote", job.isRemote); + pushLine(lines, "Disciplines", job.disciplines); + pushLine(lines, "Job type", job.jobType); + pushLine(lines, "Job level", job.jobLevel); + pushLine(lines, "Job function", job.jobFunction); + pushLine(lines, "Listing type", job.listingType); + + pushLine(lines, "Salary", job.salary); + if (job.salaryMinAmount != null || job.salaryMaxAmount != null) { + pushLine( + lines, + "Salary range", + [ + job.salaryMinAmount != null ? String(job.salaryMinAmount) : null, + job.salaryMaxAmount != null ? String(job.salaryMaxAmount) : null, + ] + .filter(Boolean) + .join(" - "), + ); + } + pushLine(lines, "Salary interval", job.salaryInterval); + pushLine(lines, "Salary currency", job.salaryCurrency); + pushLine(lines, "Salary source", job.salarySource); + + pushLine(lines, "Degree required", job.degreeRequired); + pushLine(lines, "Starting", job.starting); + pushLine(lines, "Deadline", job.deadline); + pushLine(lines, "Date posted", job.datePosted); + + pushLine(lines, "Skills", job.skills); + pushLine(lines, "Experience", job.experienceRange); + pushLine(lines, "Emails", job.emails); + + pushLine(lines, "Company industry", job.companyIndustry); + pushLine(lines, "Company URL", job.companyUrlDirect || job.employerUrl); + pushLine(lines, "Company employees", job.companyNumEmployees); + pushLine(lines, "Company revenue", job.companyRevenue); + pushLine(lines, "Company rating", job.companyRating); + pushLine(lines, "Company reviews", job.companyReviewsCount); + pushLine(lines, "Company addresses", job.companyAddresses); + + pushLine(lines, "Discovered", job.discoveredAt); + pushLine(lines, "Processed", job.processedAt); + + pushBlock(lines, "Job description", job.jobDescription); + pushBlock(lines, "Company description", job.companyDescription); + + return lines.join("\n").trim() + "\n"; +}; + +export async function copyTextToClipboard(text: string) { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + + if (!ok) { + throw new Error("Copy failed"); + } +} From 92d42d74fa064912735a446d870796bc9f4bc06d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 17:34:35 +0000 Subject: [PATCH 10/12] header link --- orchestrator/src/client/components/Header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 67d4b03..4ab418d 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -68,7 +68,7 @@ export const Header: React.FC = ({ return (
-
+
@@ -76,7 +76,7 @@ export const Header: React.FC = ({
Job Ops
Orchestrator
-
+
From 7b0cd82193193996369ed96dc86c43bfb3cac224 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 17:38:59 +0000 Subject: [PATCH 11/12] align correct --- orchestrator/src/client/components/JobTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx index 2cd588a..0817444 100644 --- a/orchestrator/src/client/components/JobTable.tsx +++ b/orchestrator/src/client/components/JobTable.tsx @@ -218,7 +218,7 @@ export const JobTable: React.FC = ({ return ( - + = ({ }} /> - + - + {job.employer} @@ -253,7 +253,7 @@ export const JobTable: React.FC = ({ - + {job.location || "—"} From 14535b73d26db75cf4099a9ab6d25b2451a3cd15 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 17:54:47 +0000 Subject: [PATCH 12/12] spacing and sizing --- orchestrator/src/client/components/Header.tsx | 183 +++++++++++------- .../src/client/components/JobList.tsx | 10 +- 2 files changed, 114 insertions(+), 79 deletions(-) diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 4ab418d..5498dd0 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -3,7 +3,15 @@ */ import React from "react"; -import { ChevronDown, Loader2, Play, RefreshCcw, Rocket, Settings, Trash2 } from "lucide-react"; +import { + ChevronDown, + Loader2, + Play, + RefreshCcw, + Rocket, + Settings, + Trash2, +} from "lucide-react"; import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -66,29 +74,32 @@ export const Header: React.FC = ({ }; return ( -
-
- -
- +
+
+ +
+
-
-
Job Ops
-
Orchestrator
+
+
Job Ops
+
Orchestrator
-
+
@@ -108,72 +119,96 @@ export const Header: React.FC = ({ - - - - - - - - - Sources - - {orderedSources.map((source) => ( - toggleSource(source, Boolean(checked))} +
+ + + + + + + + Sources + + {orderedSources.map((source) => ( + + toggleSource(source, Boolean(checked)) + } + > + {sourceLabel[source]} + + ))} + + onPipelineSourcesChange(orderedSources)} + > + All sources + + onPipelineSourcesChange(["gradcracker"])} + > + Gradcracker only + + + onPipelineSourcesChange(["indeed", "linkedin"]) + } + > + Indeed + LinkedIn only + + + +
diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index e288713..213514a 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -313,8 +313,8 @@ export const JobList: React.FC = ({ className="space-y-4" >
-
- +
+ {tabs.map((tab) => ( {tab.label} @@ -325,8 +325,8 @@ export const JobList: React.FC = ({ ))} -
-
+
+