ready panel
This commit is contained in:
parent
841fb3dec9
commit
84043c6f57
417
orchestrator/src/client/components/ReadyPanel.tsx
Normal file
417
orchestrator/src/client/components/ReadyPanel.tsx
Normal file
@ -0,0 +1,417 @@
|
||||
/**
|
||||
* ReadyPanel - Optimized "shipping lane" view for Ready jobs.
|
||||
*
|
||||
* Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied.
|
||||
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronUp,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RefreshCcw,
|
||||
Undo2,
|
||||
Copy,
|
||||
Edit2,
|
||||
XCircle,
|
||||
Briefcase,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import * as api from "../api";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
interface ReadyPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onEditTailoring: () => void;
|
||||
onEditDescription: () => void;
|
||||
}
|
||||
|
||||
const safeFilenamePart = (value: string | null | undefined) =>
|
||||
(value || "Unknown").replace(/[^\w\s-]/g, "").replace(/\s+/g, "_");
|
||||
|
||||
export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onEditTailoring,
|
||||
onEditDescription,
|
||||
}) => {
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [recentlyApplied, setRecentlyApplied] = useState<{
|
||||
jobId: string;
|
||||
jobTitle: string;
|
||||
employer: string;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | null>(null);
|
||||
|
||||
// Load project catalog once
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Compute derived values
|
||||
const pdfHref = job
|
||||
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
|
||||
: "#";
|
||||
|
||||
const jobLink = job ? job.applicationLink || job.jobUrl : "#";
|
||||
|
||||
const selectedProjectIds = useMemo(() => {
|
||||
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
}, [job?.selectedProjectIds]);
|
||||
|
||||
const selectedProjectNames = useMemo(() => {
|
||||
if (!catalog.length || !selectedProjectIds.length) return [];
|
||||
return selectedProjectIds
|
||||
.map(id => catalog.find(p => p.id === id)?.name)
|
||||
.filter(Boolean) as string[];
|
||||
}, [catalog, selectedProjectIds]);
|
||||
|
||||
const tailoredSummary = job?.tailoredSummary || null;
|
||||
|
||||
// Handle mark as applied with undo capability
|
||||
const handleMarkApplied = useCallback(async () => {
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
setIsMarkingApplied(true);
|
||||
await api.markAsApplied(job.id);
|
||||
|
||||
// Store for undo
|
||||
const timeoutId = setTimeout(() => {
|
||||
setRecentlyApplied(null);
|
||||
}, 8000);
|
||||
|
||||
setRecentlyApplied({
|
||||
jobId: job.id,
|
||||
jobTitle: job.title,
|
||||
employer: job.employer,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
// Notify parent to move to next job
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
|
||||
toast.success("Marked as applied", {
|
||||
description: `${job.title} at ${job.employer}`,
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => handleUndoApplied(job.id),
|
||||
},
|
||||
duration: 6000,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsMarkingApplied(false);
|
||||
}
|
||||
}, [job, onJobMoved, onJobUpdated]);
|
||||
|
||||
const handleUndoApplied = useCallback(
|
||||
async (jobId: string) => {
|
||||
try {
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
toast.success("Reverted to Ready");
|
||||
|
||||
if (recentlyApplied?.timeoutId) {
|
||||
clearTimeout(recentlyApplied.timeoutId);
|
||||
}
|
||||
setRecentlyApplied(null);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to undo";
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[onJobUpdated, recentlyApplied],
|
||||
);
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await api.generateJobPdf(job.id);
|
||||
toast.success("PDF regenerated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
}, [job, onJobUpdated]);
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
await api.skipJob(job.id);
|
||||
toast.message("Job skipped");
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to skip";
|
||||
toast.error(message);
|
||||
}
|
||||
}, [job, onJobMoved, onJobUpdated]);
|
||||
|
||||
const handleCopyInfo = useCallback(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");
|
||||
}
|
||||
}, [job]);
|
||||
|
||||
// Empty state
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
||||
Select a Ready job to view its application kit and take action.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
PRIMARY ACTION CLUSTER
|
||||
Three actions: Download PDF (primary), Open job (secondary), Mark applied (prominent)
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
<div className="space-y-3 pb-4 border-b border-border/40">
|
||||
{/* Primary CTA: Mark Applied - most prominent, one-click */}
|
||||
<Button
|
||||
className="w-full h-11 gap-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm"
|
||||
onClick={handleMarkApplied}
|
||||
disabled={isMarkingApplied}
|
||||
>
|
||||
{isMarkingApplied ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
Mark Applied
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions row */}
|
||||
<div className="flex gap-1">
|
||||
{/* Show PDF - to verify quickly without download */}
|
||||
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">View</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{/* Download PDF - primary artifact action */}
|
||||
<Button asChild variant="default" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<a
|
||||
href={pdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Save</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{/* Open job - to verify before applying */}
|
||||
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<a href={jobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Open</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
APPLICATION KIT SUMMARY
|
||||
Abstract representation of what the PDF contains - verify at a glance
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 py-4 space-y-4">
|
||||
{/* Job identity - confirm this is the right role */}
|
||||
<div className="space-y-3">
|
||||
{/* AI Suitability Reasoning - Why you're a fit */}
|
||||
{job.suitabilityReason && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-primary/70 mb-1.5 flex items-center gap-1.5">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Fit Assessment
|
||||
</div>
|
||||
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
||||
{job.suitabilityReason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tailored summary snippet - shows what's in the PDF */}
|
||||
{tailoredSummary && (
|
||||
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||
Tailored Summary
|
||||
</div>
|
||||
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
|
||||
"{tailoredSummary}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project selection - expandable accordion */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="projects" className="border-none">
|
||||
<AccordionTrigger className="hover:no-underline py-0 data-[state=open]:pb-2">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-sm font-medium text-foreground leading-tight">
|
||||
{selectedProjectIds.length} {selectedProjectIds.length === 1 ? "project" : "projects"} selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-1 pl-11">
|
||||
<ul className="list-disc text-xs text-muted-foreground space-y-1">
|
||||
{selectedProjectNames.map((name, i) => (
|
||||
<li key={i}>{name}</li>
|
||||
))}
|
||||
{selectedProjectNames.length === 0 && (
|
||||
<li className="list-none italic">No projects selected</li>
|
||||
)}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
SECONDARY ACTIONS
|
||||
Fix/More menu - all non-critical actions demoted here
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
<div className="pt-3 border-t border-border/40">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-2 text-xs text-muted-foreground hover:text-foreground justify-center"
|
||||
>
|
||||
More actions
|
||||
<ChevronUp className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
{/* Fix/Edit actions */}
|
||||
<DropdownMenuItem onSelect={onEditTailoring}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit tailoring
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")} />
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onEditDescription}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit job description
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Utility actions */}
|
||||
<DropdownMenuItem onSelect={handleCopyInfo}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy job info
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Destructive actions */}
|
||||
<DropdownMenuItem
|
||||
onSelect={handleSkip}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip this job
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
UNDO BAR (conditional)
|
||||
Lightweight undo option after marking applied
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
{recentlyApplied && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-2 shadow-lg">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{recentlyApplied.jobTitle}</span>
|
||||
<span className="text-muted-foreground"> marked applied</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => handleUndoApplied(recentlyApplied.jobId)}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -8,4 +8,5 @@ export { JobList } from './JobList';
|
||||
export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
export { DiscoveredPanel } from './DiscoveredPanel';
|
||||
export { ReadyPanel } from './ReadyPanel';
|
||||
export * from './layout';
|
||||
|
||||
@ -50,6 +50,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import { PipelineProgress, DiscoveredPanel } from "../components";
|
||||
import { ReadyPanel } from "../components/ReadyPanel";
|
||||
import * as api from "../api";
|
||||
import { TailoringEditor } from "../components/TailoringEditor";
|
||||
import type { Job, JobSource, JobStatus } from "../../shared/types";
|
||||
@ -913,6 +914,30 @@ export const OrchestratorPage: React.FC = () => {
|
||||
setSelectedJobId(nextJob?.id ?? null);
|
||||
}}
|
||||
/>
|
||||
) : activeTab === "ready" ? (
|
||||
/* ReadyPanel for Ready tab - shipping lane workflow: verify → download → apply → mark applied */
|
||||
<ReadyPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={loadJobs}
|
||||
onJobMoved={(jobId) => {
|
||||
// Select next job in list after current one is moved
|
||||
const currentIndex = activeJobs.findIndex((j) => j.id === jobId);
|
||||
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
||||
setSelectedJobId(nextJob?.id ?? null);
|
||||
}}
|
||||
onEditTailoring={() => {
|
||||
setActiveTab("discovered");
|
||||
// Brief delay to let tab switch, then we're showing generic panel with tailoring
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
setActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
) : !selectedJob ? (
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user