ready panel

This commit is contained in:
DaKheera47 2026-01-15 17:17:07 +00:00
parent 841fb3dec9
commit 84043c6f57
3 changed files with 443 additions and 0 deletions

View 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>
);
};

View File

@ -8,4 +8,5 @@ export { JobList } from './JobList';
export { PipelineProgress } from './PipelineProgress'; export { PipelineProgress } from './PipelineProgress';
export { TailoringEditor } from './TailoringEditor'; export { TailoringEditor } from './TailoringEditor';
export { DiscoveredPanel } from './DiscoveredPanel'; export { DiscoveredPanel } from './DiscoveredPanel';
export { ReadyPanel } from './ReadyPanel';
export * from './layout'; export * from './layout';

View File

@ -50,6 +50,7 @@ import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
import { PipelineProgress, DiscoveredPanel } from "../components"; import { PipelineProgress, DiscoveredPanel } from "../components";
import { ReadyPanel } from "../components/ReadyPanel";
import * as api from "../api"; import * as api from "../api";
import { TailoringEditor } from "../components/TailoringEditor"; import { TailoringEditor } from "../components/TailoringEditor";
import type { Job, JobSource, JobStatus } from "../../shared/types"; import type { Job, JobSource, JobStatus } from "../../shared/types";
@ -913,6 +914,30 @@ export const OrchestratorPage: React.FC = () => {
setSelectedJobId(nextJob?.id ?? null); 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 ? ( ) : !selectedJob ? (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center"> <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> <div className="text-sm font-medium text-muted-foreground">No job selected</div>