From 841fb3dec98ff4685dc7a074e498eae7e30c8274 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 15 Jan 2026 16:41:30 +0000 Subject: [PATCH] way better discovered panel --- .../src/client/components/DiscoveredPanel.tsx | 706 ++++++++++++++++++ orchestrator/src/client/components/index.ts | 1 + .../src/client/pages/OrchestratorPage.tsx | 16 +- 3 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 orchestrator/src/client/components/DiscoveredPanel.tsx diff --git a/orchestrator/src/client/components/DiscoveredPanel.tsx b/orchestrator/src/client/components/DiscoveredPanel.tsx new file mode 100644 index 0000000..21fe0aa --- /dev/null +++ b/orchestrator/src/client/components/DiscoveredPanel.tsx @@ -0,0 +1,706 @@ +/** + * DiscoveredPanel - Two-mode triage workspace for Discovered jobs. + * + * Mode A: Decide (default) - Quick assessment to Skip or Tailor + * Mode B: Tailor - Draft tailoring data before moving to Ready + * + * Moving to Ready generates the PDF using the current tailored draft. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + ArrowLeft, + Calendar, + Check, + ChevronDown, + ChevronUp, + DollarSign, + ExternalLink, + Loader2, + MapPin, + Sparkles, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import * as api from "../api"; +import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type PanelMode = "decide" | "tailor"; + +interface DiscoveredPanelProps { + job: Job | null; + onJobUpdated: () => void | Promise; + onJobMoved: (jobId: string) => void; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +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 getScoreLabel = (score: number | null): { label: string; color: string; description: string } => { + if (score == null) return { label: "Unscored", color: "text-muted-foreground", description: "No AI assessment yet" }; + if (score >= 80) return { label: "Excellent fit", color: "text-emerald-400", description: "Strong match for your profile" }; + if (score >= 65) return { label: "Good fit", color: "text-emerald-400/80", description: "Solid match worth considering" }; + if (score >= 50) return { label: "Possible fit", color: "text-amber-400", description: "Some relevant aspects" }; + if (score >= 35) return { label: "Weak fit", color: "text-orange-400", description: "Limited alignment" }; + return { label: "Poor fit", color: "text-rose-400", description: "May not be worth pursuing" }; +}; + +const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + +const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", + ukvisajobs: "UK Visa Jobs", +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Fit Summary Component (for Decide mode) +// ───────────────────────────────────────────────────────────────────────────── + +interface FitSummaryProps { + job: Job; +} + +const FitSummary: React.FC = ({ job }) => { + const scoreInfo = getScoreLabel(job.suitabilityScore); + + return ( +
+ {/* Score badge with context */} +
+
= 50 + ? "border-emerald-500/30 bg-emerald-500/10" + : job.suitabilityScore != null + ? "border-amber-500/30 bg-amber-500/10" + : "border-border/50 bg-muted/20" + )}> + {job.suitabilityScore != null && ( + + {job.suitabilityScore} + + )} +
+
+ {scoreInfo.label} +
+
+ {scoreInfo.description} +
+
+
+
+ + {/* AI Assessment */} + {job.suitabilityReason && ( +
+
+ AI Assessment +
+

+ {job.suitabilityReason} +

+
+ )} + + {/* No assessment fallback */} + {!job.suitabilityReason && !job.suitabilityScore && ( +
+

+ No AI assessment available yet. +

+

+ Review the job description to decide if you want to tailor. +

+
+ )} +
+ ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Decide Mode Panel +// ───────────────────────────────────────────────────────────────────────────── + +interface DecideModeProps { + job: Job; + onTailor: () => void; + onSkip: () => void; + isSkipping: boolean; +} + +const DecideMode: React.FC = ({ + job, + onTailor, + onSkip, + isSkipping, +}) => { + const [showDescription, setShowDescription] = useState(false); + const deadline = formatDate(job.deadline); + const jobLink = job.applicationLink || job.jobUrl; + + const description = useMemo(() => { + if (!job.jobDescription) return "No description available."; + const jd = job.jobDescription; + if (jd.includes("<") && jd.includes(">")) return stripHtml(jd); + return jd; + }, [job.jobDescription]); + + return ( +
+ {/* Header */} +
+
+
+

+ {job.title} +

+

{job.employer}

+
+ + {sourceLabel[job.source]} + +
+ + {/* Metadata row */} +
+ {job.location && ( + + + {job.location} + + )} + {deadline && ( + + + {deadline} + + )} + {job.salary && ( + + + {job.salary} + + )} +
+
+ + + + {/* Fit Summary - the core content */} +
+ + + {/* Collapsible full description */} +
+ + + {showDescription && ( +
+

+ {description} +

+
+ )} +
+
+ + + + {/* Actions - clear hierarchy */} +
+ {/* External link - tertiary */} + + + {/* Primary/Secondary actions */} +
+ + +
+
+
+ ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Tailor Mode Panel +// ───────────────────────────────────────────────────────────────────────────── + +interface TailorModeProps { + job: Job; + onBack: () => void; + onFinalize: () => void; + isFinalizing: boolean; +} + +const TailorMode: React.FC = ({ + job, + onBack, + onFinalize, + isFinalizing, +}) => { + const [catalog, setCatalog] = useState([]); + const [summary, setSummary] = useState(job.tailoredSummary || ""); + const [selectedIds, setSelectedIds] = useState>(() => { + const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; + return new Set(saved); + }); + const [isGenerating, setIsGenerating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">("saved"); + + // Load project catalog + useEffect(() => { + api.getProfileProjects().then(setCatalog).catch(console.error); + }, []); + + // Reset form when job changes + useEffect(() => { + setSummary(job.tailoredSummary || ""); + const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; + setSelectedIds(new Set(saved)); + setDraftStatus("saved"); + }, [job.id, job.tailoredSummary, job.selectedProjectIds]); + + // Track unsaved changes + const savedSummary = job.tailoredSummary || ""; + const savedIds = useMemo(() => { + const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; + return new Set(saved); + }, [job.selectedProjectIds]); + + const hasChanges = useMemo(() => { + if (summary !== savedSummary) return true; + if (selectedIds.size !== savedIds.size) return true; + for (const id of selectedIds) { + if (!savedIds.has(id)) return true; + } + return false; + }, [summary, savedSummary, selectedIds, savedIds]); + + // Update draft status when changes are made + useEffect(() => { + if (hasChanges && draftStatus === "saved") { + setDraftStatus("unsaved"); + } + }, [hasChanges, draftStatus]); + + // Auto-save draft (debounced) + useEffect(() => { + if (!hasChanges || draftStatus !== "unsaved") return; + + const timeout = setTimeout(async () => { + try { + setDraftStatus("saving"); + await api.updateJob(job.id, { + tailoredSummary: summary, + selectedProjectIds: Array.from(selectedIds).join(","), + }); + setDraftStatus("saved"); + } catch { + setDraftStatus("unsaved"); + } + }, 1500); + + return () => clearTimeout(timeout); + }, [summary, selectedIds, hasChanges, draftStatus, job.id]); + + const handleToggleProject = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedIds(next); + }; + + const handleGenerateWithAI = async () => { + try { + setIsGenerating(true); + const updatedJob = await api.summarizeJob(job.id, { force: true }); + setSummary(updatedJob.tailoredSummary || ""); + if (updatedJob.selectedProjectIds) { + setSelectedIds(new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))); + } + setDraftStatus("saved"); // AI response is saved server-side + toast.success("Draft generated with AI", { + description: "Review and edit before finalizing.", + }); + } catch { + toast.error("Failed to generate AI draft"); + } finally { + setIsGenerating(false); + } + }; + + const handleFinalize = async () => { + // Save any pending changes first + if (hasChanges) { + try { + setIsSaving(true); + await api.updateJob(job.id, { + tailoredSummary: summary, + selectedProjectIds: Array.from(selectedIds).join(","), + }); + } catch { + toast.error("Failed to save draft before finalizing"); + setIsSaving(false); + return; + } finally { + setIsSaving(false); + } + } + + // Now finalize (which generates PDF and moves to Ready) + onFinalize(); + }; + + const maxProjects = 3; + const tooManyProjects = selectedIds.size > maxProjects; + const canFinalize = summary.trim().length > 0 && selectedIds.size > 0; + + return ( +
+ {/* Header with back navigation */} +
+ + + {/* Draft status indicator */} +
+ {draftStatus === "saving" && ( + <> + + Saving... + + )} + {draftStatus === "saved" && !hasChanges && ( + <> + + Saved + + )} + {draftStatus === "unsaved" && ( + Unsaved changes + )} +
+
+ + {/* Draft framing */} +
+
+
+ + Draft tailoring for this role + +
+

+ Edit below, then finalize to generate your PDF and move to Ready. +

+
+ + {/* Scrollable content */} +
+ {/* AI Generate option */} +
+
+
Need help getting started?
+
+ AI can draft a summary and select projects for you +
+
+ +
+ + {/* Tailored Summary */} +
+ +