orchestrator page split up into components
This commit is contained in:
parent
4325737d00
commit
94c3cc64ae
File diff suppressed because it is too large
Load Diff
651
orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx
Normal file
651
orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx
Normal file
@ -0,0 +1,651 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
DollarSign,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
|
||||
import { DiscoveredPanel } from "../../components";
|
||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||
import { formatDate } from "../../lib/dateUtils";
|
||||
import * as api from "../../api";
|
||||
import type { Job, JobStatus } from "../../../shared/types";
|
||||
import { defaultStatusToken, sourceLabel, statusTokens } from "./constants";
|
||||
import type { FilterTab } from "./constants";
|
||||
import { safeFilenamePart, stripHtml } from "./utils";
|
||||
|
||||
interface JobDetailPanelProps {
|
||||
activeTab: FilterTab;
|
||||
activeJobs: Job[];
|
||||
selectedJob: Job | null;
|
||||
onSelectJobId: (jobId: string | null) => void;
|
||||
onJobUpdated: () => Promise<void>;
|
||||
onSetActiveTab: (tab: FilterTab) => void;
|
||||
}
|
||||
|
||||
// Subdued status pill for inspector panel - not competing with list
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
const tokens = statusTokens[status] ?? defaultStatusToken;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
|
||||
{tokens.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Compact score meter for inspector panel
|
||||
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
if (score == null) {
|
||||
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
|
||||
<div className="h-1 w-12 rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/50"
|
||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums">{score}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
activeTab,
|
||||
activeJobs,
|
||||
selectedJob,
|
||||
onSelectJobId,
|
||||
onJobUpdated,
|
||||
onSetActiveTab,
|
||||
}) => {
|
||||
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview");
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
}, [selectedJob?.id]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!selectedJob?.jobDescription) return "No description available.";
|
||||
const jd = selectedJob.jobDescription;
|
||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
||||
return jd;
|
||||
}, [selectedJob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob) {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription("");
|
||||
return;
|
||||
}
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}, [selectedJob?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob) return;
|
||||
if (!isEditingDescription) {
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}
|
||||
}, [selectedJob?.jobDescription, isEditingDescription, selectedJob]);
|
||||
|
||||
const handleSaveDescription = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
setIsSavingDescription(true);
|
||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
||||
toast.success("Job description updated");
|
||||
setIsEditingDescription(false);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update description";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSavingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUnsavedDescription =
|
||||
!!selectedJob &&
|
||||
isEditingDescription &&
|
||||
editedDescription !== (selectedJob.jobDescription || "");
|
||||
|
||||
const confirmAndSaveEdits = useCallback(
|
||||
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => {
|
||||
const pendingDescription = hasUnsavedDescription;
|
||||
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
|
||||
|
||||
if (!pendingDescription && !pendingTailoring) return true;
|
||||
|
||||
const parts = [];
|
||||
if (pendingDescription) parts.push("job description");
|
||||
if (pendingTailoring) parts.push("tailoring changes");
|
||||
|
||||
const message = `You have unsaved ${parts.join(" and ")}. Save before generating the PDF?`;
|
||||
if (!window.confirm(message)) return false;
|
||||
|
||||
try {
|
||||
if (pendingDescription && selectedJob) {
|
||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
||||
}
|
||||
|
||||
if (pendingTailoring) {
|
||||
const saveTailoring = saveTailoringRef.current;
|
||||
if (!saveTailoring) {
|
||||
toast.error("Could not save tailoring changes");
|
||||
return false;
|
||||
}
|
||||
await saveTailoring();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to save changes";
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob],
|
||||
);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true });
|
||||
if (!shouldProceed) return;
|
||||
|
||||
setProcessingJobId(selectedJob.id);
|
||||
|
||||
if (selectedJob.status === "ready") {
|
||||
await api.generateJobPdf(selectedJob.id);
|
||||
toast.success("Resume regenerated successfully");
|
||||
} else {
|
||||
await api.processJob(selectedJob.id);
|
||||
toast.success("Resume generated successfully");
|
||||
}
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to process job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setProcessingJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.markAsApplied(selectedJob.id);
|
||||
toast.success("Marked as applied");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.skipJob(selectedJob.id);
|
||||
toast.message("Job skipped");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInfo = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await copyTextToClipboard(formatJobForWebhook(selectedJob));
|
||||
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." });
|
||||
} catch {
|
||||
toast.error("Could not copy job info");
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobMoved = useCallback(
|
||||
(jobId: string) => {
|
||||
const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
|
||||
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
||||
onSelectJobId(nextJob?.id ?? null);
|
||||
},
|
||||
[activeJobs, onSelectJobId],
|
||||
);
|
||||
|
||||
const selectedHasPdf = !!selectedJob?.pdfPath;
|
||||
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
|
||||
const selectedPdfHref = selectedJob
|
||||
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
||||
: "#";
|
||||
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
|
||||
const canApply = selectedJob?.status === "ready";
|
||||
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const showReadyPdf = activeTab === "ready";
|
||||
const showGeneratePdf = activeTab === "discovered";
|
||||
const isProcessingSelected =
|
||||
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
|
||||
|
||||
if (activeTab === "discovered") {
|
||||
return (
|
||||
<DiscoveredPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "ready") {
|
||||
return (
|
||||
<ReadyPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onEditTailoring={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedJob) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
||||
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Detail header: lighter weight than list items */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground/90">{selectedJob.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{selectedJob.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[selectedJob.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tertiary metadata - subdued */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
||||
{selectedJob.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{selectedJob.location}
|
||||
</span>
|
||||
)}
|
||||
{selectedDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{selectedDeadline}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{selectedJob.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={selectedJob.status} />
|
||||
<ScoreMeter score={selectedJob.suitabilityScore} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{showReadyPdf &&
|
||||
(selectedHasPdf ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showGeneratePdf && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={handleProcess}
|
||||
disabled={!canProcess || isProcessingSelected}
|
||||
>
|
||||
{isProcessingSelected ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isProcessingSelected ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canApply && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 border border-emerald-500/30"
|
||||
onClick={handleApply}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Applied
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="More actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canProcess && !showGeneratePdf && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleProcess()}
|
||||
disabled={isProcessingSelected}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
{isProcessingSelected
|
||||
? "Processing..."
|
||||
: selectedJob.status === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Generate PDF"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit description
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy info
|
||||
</DropdownMenuItem>
|
||||
{selectedHasPdf && (
|
||||
<>
|
||||
{!showReadyPdf && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canSkip && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleSkip()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip job
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
||||
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
|
||||
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
|
||||
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
{selectedJob.suitabilityReason && (
|
||||
<div className="rounded border border-border/30 bg-muted/10 px-3 py-2 text-xs text-muted-foreground italic">
|
||||
"{selectedJob.suitabilityReason}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
|
||||
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded border border-border/30 bg-muted/5 px-2.5 py-2 text-[11px] text-muted-foreground/80 line-clamp-4 whitespace-pre-wrap leading-relaxed hover:bg-muted/10 transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
{description}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
View full description
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tailoring" className="pt-3">
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={onJobUpdated}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="description" className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditingDescription ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditingDescription(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleSaveDescription}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copyTextToClipboard(selectedJob.jobDescription || "");
|
||||
toast.success("Copied raw description");
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy raw text
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground">
|
||||
{isEditingDescription ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(event) => setEditedDescription(event.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed focus-visible:ring-1"
|
||||
placeholder="Enter job description..."
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Description
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
Normal file
110
orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { Job } from "../../../shared/types";
|
||||
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
|
||||
import type { FilterTab } from "./constants";
|
||||
|
||||
interface JobListPanelProps {
|
||||
isLoading: boolean;
|
||||
jobs: Job[];
|
||||
activeJobs: Job[];
|
||||
selectedJobId: string | null;
|
||||
activeTab: FilterTab;
|
||||
searchQuery: string;
|
||||
onSelectJob: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
isLoading,
|
||||
jobs,
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
activeTab,
|
||||
searchQuery,
|
||||
onSelectJob,
|
||||
}) => (
|
||||
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
|
||||
{isLoading && jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">Loading jobs...</div>
|
||||
</div>
|
||||
) : activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
<div className="text-base font-semibold">No jobs found</div>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{activeJobs.map((job) => {
|
||||
const isSelected = job.id === selectedJobId;
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/20 border-l-2 border-l-transparent",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70",
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && <span className="before:content-['_in_']">{job.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
job.suitabilityScore! >= 70
|
||||
? "text-emerald-400/90"
|
||||
: job.suitabilityScore! >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { ArrowUpDown, Filter, Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import { defaultSortDirection, sortLabels, sourceLabel, tabs } from "./constants";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
|
||||
interface OrchestratorFiltersProps {
|
||||
activeTab: FilterTab;
|
||||
onTabChange: (value: FilterTab) => void;
|
||||
counts: Record<FilterTab, number>;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sort: JobSort;
|
||||
onSortChange: (sort: JobSort) => void;
|
||||
}
|
||||
|
||||
export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
counts,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
}) => (
|
||||
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
|
||||
<span>{tab.label}</span>
|
||||
{counts[tab.id] > 0 && (
|
||||
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
|
||||
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
|
||||
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sourceLabel[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
{sortLabels[sort.key]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sort.key}
|
||||
onValueChange={(value) =>
|
||||
onSortChange({
|
||||
key: value as JobSort["key"],
|
||||
direction: defaultSortDirection[value as JobSort["key"]],
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sortLabels[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
onSortChange({
|
||||
...sort,
|
||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
@ -0,0 +1,206 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Home,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import { orderedSources, sourceLabel } from "./constants";
|
||||
|
||||
interface OrchestratorHeaderProps {
|
||||
navOpen: boolean;
|
||||
onNavOpenChange: (open: boolean) => void;
|
||||
isPipelineRunning: boolean;
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||
onRunPipeline: () => void;
|
||||
onOpenManualImport: () => void;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
navOpen,
|
||||
onNavOpenChange,
|
||||
isPipelineRunning,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
onSetPipelineSources,
|
||||
onRunPipeline,
|
||||
onOpenManualImport,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet open={navOpen} onOpenChange={onNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (location.pathname === to) {
|
||||
onNavOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onNavOpenChange(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
}}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
|
||||
location.pathname === to
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<span className="hidden sm:inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Pipeline running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenManualImport}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Manual import</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onRunPipeline}
|
||||
disabled={isPipelineRunning}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{isPipelineRunning ? "Running" : "Run pipeline"}</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPipelineRunning}
|
||||
aria-label="Select pipeline sources"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{orderedSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(orderedSources);
|
||||
}}
|
||||
>
|
||||
All sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(["gradcracker"]);
|
||||
}}
|
||||
>
|
||||
Gradcracker only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(["indeed", "linkedin"]);
|
||||
}}
|
||||
>
|
||||
Indeed + LinkedIn only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
|
||||
import { PipelineProgress } from "../../components";
|
||||
import type { JobStatus } from "../../../shared/types";
|
||||
|
||||
interface OrchestratorSummaryProps {
|
||||
stats: Record<JobStatus, number>;
|
||||
isPipelineRunning: boolean;
|
||||
}
|
||||
|
||||
export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
|
||||
stats,
|
||||
isPipelineRunning,
|
||||
}) => {
|
||||
const totalJobs = Object.values(stats).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<div className="max-w-3xl">
|
||||
<PipelineProgress isRunning={isPipelineRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact metrics summary - demoted visual weight */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
||||
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
|
||||
<span className="text-border">•</span>
|
||||
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
|
||||
{(stats.skipped > 0 || stats.expired > 0) && (
|
||||
<>
|
||||
<span className="text-border">•</span>
|
||||
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
93
orchestrator/src/client/pages/orchestrator/constants.ts
Normal file
93
orchestrator/src/client/pages/orchestrator/constants.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { JobSource, JobStatus } from "../../../shared/types";
|
||||
|
||||
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
||||
|
||||
export const sourceLabel: Record<JobSource, string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
|
||||
discovered: {
|
||||
label: "Discovered",
|
||||
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||
dot: "bg-sky-400",
|
||||
},
|
||||
processing: {
|
||||
label: "Processing",
|
||||
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
applied: {
|
||||
label: "Applied",
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
skipped: {
|
||||
label: "Skipped",
|
||||
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
},
|
||||
expired: {
|
||||
label: "Expired",
|
||||
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
|
||||
dot: "bg-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultStatusToken = {
|
||||
label: "Unknown",
|
||||
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
|
||||
dot: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
export type FilterTab = "ready" | "discovered" | "applied" | "all";
|
||||
|
||||
export type SortKey = "discoveredAt" | "score" | "title" | "employer";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface JobSort {
|
||||
key: SortKey;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
export const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" };
|
||||
|
||||
export const sortLabels: Record<JobSort["key"], string> = {
|
||||
discoveredAt: "Discovered",
|
||||
score: "Score",
|
||||
title: "Title",
|
||||
employer: "Company",
|
||||
};
|
||||
|
||||
export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
|
||||
discoveredAt: "desc",
|
||||
score: "desc",
|
||||
title: "asc",
|
||||
employer: "asc",
|
||||
};
|
||||
|
||||
export const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
|
||||
{ id: "ready", label: "Ready", statuses: ["ready"] },
|
||||
{ id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
|
||||
{ id: "applied", label: "Applied", statuses: ["applied"] },
|
||||
{ id: "all", label: "All Jobs", statuses: [] },
|
||||
];
|
||||
|
||||
export const emptyStateCopy: Record<FilterTab, string> = {
|
||||
ready: "Run the pipeline to discover and process new jobs.",
|
||||
discovered: "All discovered jobs have been processed.",
|
||||
applied: "You have not applied to any jobs yet.",
|
||||
all: "No jobs in the system yet. Run the pipeline to get started.",
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Job, JobSource } from "../../../shared/types";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import { compareJobs, jobMatchesQuery } from "./utils";
|
||||
|
||||
export const useFilteredJobs = (
|
||||
jobs: Job[],
|
||||
activeTab: FilterTab,
|
||||
sourceFilter: JobSource | "all",
|
||||
searchQuery: string,
|
||||
sort: JobSort,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
let filtered = jobs;
|
||||
|
||||
if (activeTab === "ready") {
|
||||
filtered = filtered.filter((job) => job.status === "ready");
|
||||
} else if (activeTab === "discovered") {
|
||||
filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing");
|
||||
} else if (activeTab === "applied") {
|
||||
filtered = filtered.filter((job) => job.status === "applied");
|
||||
}
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
|
||||
}
|
||||
|
||||
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
|
||||
}, [jobs, activeTab, sourceFilter, searchQuery, sort]);
|
||||
@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Job, JobStatus } from "../../../shared/types";
|
||||
import * as api from "../../api";
|
||||
|
||||
const initialStats: Record<JobStatus, number> = {
|
||||
discovered: 0,
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
export const useOrchestratorData = () => {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getJobs();
|
||||
setJobs(data.jobs);
|
||||
setStats(data.byStatus);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load jobs";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkPipelineStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await api.getPipelineStatus();
|
||||
setIsPipelineRunning(status.isRunning);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs, checkPipelineStatus]);
|
||||
|
||||
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus };
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import {
|
||||
DEFAULT_PIPELINE_SOURCES,
|
||||
PIPELINE_SOURCES_STORAGE_KEY,
|
||||
orderedSources,
|
||||
} from "./constants";
|
||||
|
||||
export const usePipelineSources = () => {
|
||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PIPELINE_SOURCES;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES;
|
||||
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
|
||||
return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES;
|
||||
} catch {
|
||||
return DEFAULT_PIPELINE_SOURCES;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [pipelineSources]);
|
||||
|
||||
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
|
||||
setPipelineSources((current) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...current, source]))
|
||||
: current.filter((value) => value !== source);
|
||||
|
||||
return next.length === 0 ? current : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { pipelineSources, setPipelineSources, toggleSource };
|
||||
};
|
||||
93
orchestrator/src/client/pages/orchestrator/utils.ts
Normal file
93
orchestrator/src/client/pages/orchestrator/utils.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
|
||||
const dateValue = (value: string | null) => {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||
const compareNumber = (a: number, b: number) => a - b;
|
||||
|
||||
export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
|
||||
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 "score": {
|
||||
const aScore = a.suitabilityScore;
|
||||
const bScore = b.suitabilityScore;
|
||||
|
||||
if (aScore == null && bScore == null) {
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
if (aScore == null) return 1;
|
||||
if (bScore == null) return -1;
|
||||
value = compareNumber(aScore, bScore);
|
||||
break;
|
||||
}
|
||||
case "discoveredAt": {
|
||||
const aDate = dateValue(a.discoveredAt);
|
||||
const bDate = dateValue(b.discoveredAt);
|
||||
if (aDate == null && bDate == null) {
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
if (aDate == null) return 1;
|
||||
if (bDate == null) return -1;
|
||||
value = compareNumber(aDate, bDate);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
value = 0;
|
||||
}
|
||||
|
||||
if (value !== 0) return sort.direction === "asc" ? value : -value;
|
||||
return a.id.localeCompare(b.id);
|
||||
};
|
||||
|
||||
export const jobMatchesQuery = (job: Job, query: string) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
const haystack = [
|
||||
job.title,
|
||||
job.employer,
|
||||
job.location,
|
||||
job.source,
|
||||
job.status,
|
||||
job.jobType,
|
||||
job.jobFunction,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
};
|
||||
|
||||
export const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
|
||||
|
||||
export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
discovered: 0,
|
||||
applied: 0,
|
||||
all: jobs.length,
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "ready") byTab.ready += 1;
|
||||
if (job.status === "applied") byTab.applied += 1;
|
||||
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
|
||||
}
|
||||
|
||||
return byTab;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user