diff --git a/orchestrator/src/client/pages/JobPage.tsx b/orchestrator/src/client/pages/JobPage.tsx index 46cb0d5..23d3bed 100644 --- a/orchestrator/src/client/pages/JobPage.tsx +++ b/orchestrator/src/client/pages/JobPage.tsx @@ -10,9 +10,18 @@ import confetti from "canvas-confetti"; import { ArrowLeft, CalendarClock, + CheckCircle2, ClipboardList, + Copy, DollarSign, + Edit2, + ExternalLink, + FileText, + MoreHorizontal, PlusCircle, + RefreshCcw, + Sparkles, + XCircle, } from "lucide-react"; import React from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -20,10 +29,22 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatTimestamp } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + copyTextToClipboard, + formatJobForWebhook, + formatTimestamp, +} from "@/lib/utils"; import * as api from "../api"; import { ConfirmDelete } from "../components/ConfirmDelete"; import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer"; +import { JobDetailsEditDrawer } from "../components/JobDetailsEditDrawer"; import { JobHeader } from "../components/JobHeader"; import { type LogEventFormValues, @@ -40,6 +61,8 @@ export const JobPage: React.FC = () => { const [isLoading, setIsLoading] = React.useState(true); const [isLogModalOpen, setIsLogModalOpen] = React.useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); + const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false); + const [activeAction, setActiveAction] = React.useState(null); const [eventToDelete, setEventToDelete] = React.useState(null); const [editingEvent, setEditingEvent] = React.useState( null, @@ -184,13 +207,102 @@ export const JobPage: React.FC = () => { setIsLogModalOpen(true); }; + const runAction = React.useCallback( + async (actionKey: string, task: () => Promise) => { + if (!job) return; + try { + setActiveAction(actionKey); + await task(); + await loadData(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to run action"; + toast.error(message); + } finally { + setActiveAction(null); + } + }, + [job, loadData], + ); + + const handleMarkApplied = async () => { + await runAction("mark-applied", async () => { + if (!job) return; + await api.markAsApplied(job.id); + toast.success("Marked as applied"); + }); + }; + + const handleMoveToInProgress = async () => { + await runAction("move-in-progress", async () => { + if (!job) return; + await api.updateJob(job.id, { status: "in_progress" }); + toast.success("Moved to in progress"); + }); + }; + + const handleSkip = async () => { + await runAction("skip", async () => { + if (!job) return; + await api.skipJob(job.id); + toast.message("Job skipped"); + }); + }; + + const handleRescore = async () => { + await runAction("rescore", async () => { + if (!job) return; + await api.rescoreJob(job.id); + toast.success("Match recalculated"); + }); + }; + + const handleRegeneratePdf = async () => { + await runAction("regenerate-pdf", async () => { + if (!job) return; + await api.generateJobPdf(job.id); + toast.success("Resume PDF generated"); + }); + }; + + const handleCheckSponsor = async () => { + await runAction("check-sponsor", async () => { + if (!job) return; + await api.checkSponsor(job.id); + toast.success("Sponsor check completed"); + }); + }; + + const handleCopyJobInfo = async () => { + if (!job) return; + try { + await copyTextToClipboard(formatJobForWebhook(job)); + toast.success("Copied job info", { + description: "Webhook payload copied to clipboard.", + }); + } catch { + toast.error("Could not copy job info"); + } + }; + const currentStage = job ? (events.at(-1)?.toStage ?? (job.status === "applied" || job.status === "in_progress" ? "applied" : null)) : null; + const isClosedStage = currentStage === "closed"; const canTrackStages = job?.status === "in_progress"; + const canLogEvents = canTrackStages && !isClosedStage; + const jobLink = job ? job.applicationLink || job.jobUrl : null; + const pdfHref = job?.pdfPath + ? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}` + : null; + const isBusy = activeAction !== null; + const isDiscovered = job?.status === "discovered"; + const isReady = job?.status === "ready"; + const isApplied = job?.status === "applied"; + const isInProgress = job?.status === "in_progress"; if (!id) { return null; @@ -203,23 +315,13 @@ export const JobPage: React.FC = () => { Back -
- -
{job ? ( ) : (
@@ -227,6 +329,175 @@ export const JobPage: React.FC = () => {
)} + {job && ( +
+
+
+ {jobLink && ( + + )} + + {isReady && ( + <> + + + + )} + + {isDiscovered && ( + <> + + + + )} + + {isApplied && ( + + )} + + {isInProgress && ( + + )} +
+ +
+ {isReady && ( + + )} + + {pdfHref && ( + + )} + + {isReady && ( + + )} + + + + + + + setIsEditDetailsOpen(true)}> + + Edit details + + void handleCopyJobInfo()}> + + Copy job info + + {(isReady || isDiscovered) && ( + void handleRescore()}> + + Recalculate match + + )} + + void handleCheckSponsor()}> + Check sponsorship status + + + +
+
+
+ )} +
@@ -263,10 +534,15 @@ export const JobPage: React.FC = () => { Move this job to In Progress to track application stages.
)} + {canTrackStages && isClosedStage && ( +
+ This application is closed. Stage logging is disabled. +
+ )} @@ -373,6 +649,13 @@ export const JobPage: React.FC = () => { }} onConfirm={handleDeleteEvent} /> + + ); };