discovered panel tweaks
This commit is contained in:
parent
84043c6f57
commit
b754e702e4
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* DiscoveredPanel - Two-mode triage workspace for Discovered jobs.
|
* DiscoveredPanel - Two-mode triage workspace for Discovered jobs.
|
||||||
*
|
*
|
||||||
* Mode A: Decide (default) - Quick assessment to Skip or Tailor
|
* Mode A: Decide (default) - Quick assessment to Skip or Tailor
|
||||||
* Mode B: Tailor - Draft tailoring data before moving to Ready
|
* Mode B: Tailor - Draft tailoring data before moving to Ready
|
||||||
*
|
*
|
||||||
* Moving to Ready generates the PDF using the current tailored draft.
|
* Moving to Ready generates the PDF using the current tailored draft.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -61,16 +61,51 @@ const formatDate = (dateStr: string | null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScoreLabel = (score: number | null): { label: string; color: string; description: string } => {
|
const getScoreLabel = (
|
||||||
if (score == null) return { label: "Unscored", color: "text-muted-foreground", description: "No AI assessment yet" };
|
score: number | null
|
||||||
if (score >= 80) return { label: "Excellent fit", color: "text-emerald-400", description: "Strong match for your profile" };
|
): { label: string; color: string; description: string } => {
|
||||||
if (score >= 65) return { label: "Good fit", color: "text-emerald-400/80", description: "Solid match worth considering" };
|
if (score == null)
|
||||||
if (score >= 50) return { label: "Possible fit", color: "text-amber-400", description: "Some relevant aspects" };
|
return {
|
||||||
if (score >= 35) return { label: "Weak fit", color: "text-orange-400", description: "Limited alignment" };
|
label: "Unscored",
|
||||||
return { label: "Poor fit", color: "text-rose-400", description: "May not be worth pursuing" };
|
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 stripHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
const sourceLabel: Record<Job["source"], string> = {
|
const sourceLabel: Record<Job["source"], string> = {
|
||||||
gradcracker: "Gradcracker",
|
gradcracker: "Gradcracker",
|
||||||
@ -88,43 +123,15 @@ interface FitSummaryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FitSummary: React.FC<FitSummaryProps> = ({ job }) => {
|
const FitSummary: React.FC<FitSummaryProps> = ({ job }) => {
|
||||||
const scoreInfo = getScoreLabel(job.suitabilityScore);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className='space-y-3'>
|
||||||
{/* Score badge with context */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={cn(
|
|
||||||
"flex items-center gap-2 rounded-full border px-3 py-1.5",
|
|
||||||
job.suitabilityScore != null && job.suitabilityScore >= 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 && (
|
|
||||||
<span className={cn("text-lg font-bold tabular-nums", scoreInfo.color)}>
|
|
||||||
{job.suitabilityScore}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<div className={cn("text-xs font-semibold", scoreInfo.color)}>
|
|
||||||
{scoreInfo.label}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
|
||||||
{scoreInfo.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Assessment */}
|
{/* AI Assessment */}
|
||||||
{job.suitabilityReason && (
|
{job.suitabilityReason && (
|
||||||
<div className="rounded-lg border border-border/40 bg-muted/10 p-3">
|
<div className='rounded-lg border border-border/40 bg-muted/10 p-3'>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1.5">
|
<div className='text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1.5'>
|
||||||
AI Assessment
|
AI Assessment
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
<p className='text-sm text-foreground/80 leading-relaxed'>
|
||||||
{job.suitabilityReason}
|
{job.suitabilityReason}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -132,11 +139,11 @@ const FitSummary: React.FC<FitSummaryProps> = ({ job }) => {
|
|||||||
|
|
||||||
{/* No assessment fallback */}
|
{/* No assessment fallback */}
|
||||||
{!job.suitabilityReason && !job.suitabilityScore && (
|
{!job.suitabilityReason && !job.suitabilityScore && (
|
||||||
<div className="rounded-lg border border-border/40 bg-muted/10 p-3 text-center">
|
<div className='rounded-lg border border-border/40 bg-muted/10 p-3 text-center'>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className='text-sm text-muted-foreground'>
|
||||||
No AI assessment available yet.
|
No AI assessment available yet.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className='text-xs text-muted-foreground/70 mt-1'>
|
||||||
Review the job description to decide if you want to tailor.
|
Review the job description to decide if you want to tailor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -165,7 +172,8 @@ const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
const [showDescription, setShowDescription] = useState(false);
|
const [showDescription, setShowDescription] = useState(false);
|
||||||
const deadline = formatDate(job.deadline);
|
const deadline = formatDate(job.deadline);
|
||||||
const jobLink = job.applicationLink || job.jobUrl;
|
const jobLink = job.applicationLink || job.jobUrl;
|
||||||
|
const scoreInfo = getScoreLabel(job.suitabilityScore);
|
||||||
|
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
if (!job.jobDescription) return "No description available.";
|
if (!job.jobDescription) return "No description available.";
|
||||||
const jd = job.jobDescription;
|
const jd = job.jobDescription;
|
||||||
@ -174,68 +182,88 @@ const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
}, [job.jobDescription]);
|
}, [job.jobDescription]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className='flex flex-col h-full'>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-3 pb-4">
|
<div className='space-y-3 pb-4'>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className='flex items-start justify-between gap-2'>
|
||||||
<div className="min-w-0 flex-1">
|
<div className='min-w-0 flex-1'>
|
||||||
<h2 className="text-base font-semibold text-foreground/90 leading-tight">
|
<h2 className='text-base font-semibold text-foreground/90 leading-tight'>
|
||||||
{job.title}
|
{job.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{job.employer}</p>
|
<p className='text-sm text-muted-foreground mt-0.5'>
|
||||||
|
{job.employer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='text-[10px] uppercase tracking-wide text-muted-foreground border-border/50 shrink-0'
|
||||||
|
>
|
||||||
|
{sourceLabel[job.source]}
|
||||||
|
</Badge>
|
||||||
|
{job.suitabilityScore != null && (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-bold tabular-nums",
|
||||||
|
scoreInfo.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{job.suitabilityScore}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50 shrink-0">
|
|
||||||
{sourceLabel[job.source]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata row */}
|
{/* Metadata row */}
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80">
|
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 justify-between'>
|
||||||
{job.location && (
|
{job.location && (
|
||||||
<span className="flex items-center gap-1">
|
<span className='flex items-center gap-1'>
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className='h-3 w-3' />
|
||||||
{job.location}
|
{job.location}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{deadline && (
|
{deadline && (
|
||||||
<span className="flex items-center gap-1">
|
<span className='flex items-center gap-1'>
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className='h-3 w-3' />
|
||||||
{deadline}
|
{deadline}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{job.salary && (
|
{job.salary && (
|
||||||
<span className="flex items-center gap-1">
|
<span className='flex items-center gap-1'>
|
||||||
<DollarSign className="h-3 w-3" />
|
<DollarSign className='h-3 w-3' />
|
||||||
{job.salary}
|
{job.salary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="opacity-50" />
|
<Separator className='opacity-50' />
|
||||||
|
|
||||||
{/* Fit Summary - the core content */}
|
{/* Fit Summary - the core content */}
|
||||||
<div className="flex-1 py-4 space-y-4 overflow-y-auto">
|
<div className='flex-1 py-4 space-y-4 overflow-y-auto'>
|
||||||
<FitSummary job={job} />
|
<FitSummary job={job} />
|
||||||
|
|
||||||
{/* Collapsible full description */}
|
{/* Collapsible full description */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
onClick={() => setShowDescription(!showDescription)}
|
onClick={() => setShowDescription(!showDescription)}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
||||||
>
|
>
|
||||||
{showDescription ? (
|
{showDescription ? (
|
||||||
<ChevronUp className="h-3.5 w-3.5" />
|
<ChevronUp className='h-3.5 w-3.5' />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className='h-3.5 w-3.5' />
|
||||||
)}
|
)}
|
||||||
{showDescription ? "Hide" : "View"} full job description
|
{showDescription ? "Hide" : "View"} full job description
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDescription && (
|
{showDescription && (
|
||||||
<div className="rounded-lg border border-border/40 bg-muted/5 p-3 max-h-[300px] overflow-y-auto">
|
<div className='rounded-lg border border-border/40 bg-muted/5 p-3 max-h-[300px] overflow-y-auto'>
|
||||||
<p className="text-xs text-muted-foreground/80 whitespace-pre-wrap leading-relaxed">
|
<p className='text-xs text-muted-foreground/80 whitespace-pre-wrap leading-relaxed'>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -243,45 +271,45 @@ const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="opacity-50" />
|
<Separator className='opacity-50' />
|
||||||
|
|
||||||
{/* Actions - clear hierarchy */}
|
{/* Actions - clear hierarchy */}
|
||||||
<div className="pt-4 space-y-3">
|
<div className='pt-4 space-y-3'>
|
||||||
{/* External link - tertiary */}
|
{/* External link - tertiary */}
|
||||||
<div className="flex justify-center">
|
<div className='flex justify-center'>
|
||||||
<a
|
<a
|
||||||
href={jobLink}
|
href={jobLink}
|
||||||
target="_blank"
|
target='_blank'
|
||||||
rel="noopener noreferrer"
|
rel='noopener noreferrer'
|
||||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className='inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className='h-3 w-3' />
|
||||||
View original listing
|
View original listing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary/Secondary actions */}
|
{/* Primary/Secondary actions */}
|
||||||
<div className="flex gap-2">
|
<div className='flex gap-2'>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant='outline'
|
||||||
size="sm"
|
size='sm'
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
disabled={isSkipping}
|
disabled={isSkipping}
|
||||||
className="flex-1 h-10 text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5"
|
className='flex-1 h-10 text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5'
|
||||||
>
|
>
|
||||||
{isSkipping ? (
|
{isSkipping ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className='mr-2 h-4 w-4' />
|
||||||
)}
|
)}
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size='sm'
|
||||||
onClick={onTailor}
|
onClick={onTailor}
|
||||||
className="flex-1 h-10 bg-primary/90 hover:bg-primary"
|
className='flex-1 h-10 bg-primary/90 hover:bg-primary'
|
||||||
>
|
>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className='mr-2 h-4 w-4' />
|
||||||
Tailor
|
Tailor
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +343,9 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
});
|
});
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">("saved");
|
const [draftStatus, setDraftStatus] = useState<
|
||||||
|
"unsaved" | "saving" | "saved"
|
||||||
|
>("saved");
|
||||||
|
|
||||||
// Load project catalog
|
// Load project catalog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -386,7 +416,9 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||||
setSummary(updatedJob.tailoredSummary || "");
|
setSummary(updatedJob.tailoredSummary || "");
|
||||||
if (updatedJob.selectedProjectIds) {
|
if (updatedJob.selectedProjectIds) {
|
||||||
setSelectedIds(new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)));
|
setSelectedIds(
|
||||||
|
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setDraftStatus("saved"); // AI response is saved server-side
|
setDraftStatus("saved"); // AI response is saved server-side
|
||||||
toast.success("Draft generated with AI", {
|
toast.success("Draft generated with AI", {
|
||||||
@ -426,108 +458,110 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
|
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className='flex flex-col h-full'>
|
||||||
{/* Header with back navigation */}
|
{/* Header with back navigation */}
|
||||||
<div className="flex items-center justify-between pb-3">
|
<div className='flex items-center justify-between pb-3'>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
<ArrowLeft className='h-3.5 w-3.5' />
|
||||||
Back to overview
|
Back to overview
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Draft status indicator */}
|
{/* Draft status indicator */}
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
|
||||||
{draftStatus === "saving" && (
|
{draftStatus === "saving" && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className='h-3 w-3 animate-spin' />
|
||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{draftStatus === "saved" && !hasChanges && (
|
{draftStatus === "saved" && !hasChanges && (
|
||||||
<>
|
<>
|
||||||
<Check className="h-3 w-3 text-emerald-400" />
|
<Check className='h-3 w-3 text-emerald-400' />
|
||||||
Saved
|
Saved
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{draftStatus === "unsaved" && (
|
{draftStatus === "unsaved" && (
|
||||||
<span className="text-amber-400">Unsaved changes</span>
|
<span className='text-amber-400'>Unsaved changes</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft framing */}
|
{/* Draft framing */}
|
||||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4">
|
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
|
||||||
<span className="text-xs font-medium text-amber-300">
|
<span className='text-xs font-medium text-amber-300'>
|
||||||
Draft tailoring for this role
|
Draft tailoring for this role
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 ml-4">
|
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
|
||||||
Edit below, then finalize to generate your PDF and move to Ready.
|
Edit below, then finalize to generate your PDF and move to Ready.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
||||||
{/* AI Generate option */}
|
{/* AI Generate option */}
|
||||||
<div className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/10 p-3">
|
<div className='flex items-center justify-between rounded-lg border border-border/40 bg-muted/10 p-3'>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium">Need help getting started?</div>
|
<div className='text-xs font-medium'>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
Need help getting started?
|
||||||
|
</div>
|
||||||
|
<div className='text-[10px] text-muted-foreground'>
|
||||||
AI can draft a summary and select projects for you
|
AI can draft a summary and select projects for you
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size='sm'
|
||||||
variant="outline"
|
variant='outline'
|
||||||
onClick={handleGenerateWithAI}
|
onClick={handleGenerateWithAI}
|
||||||
disabled={isGenerating || isFinalizing}
|
disabled={isGenerating || isFinalizing}
|
||||||
className="h-8 text-xs"
|
className='h-8 text-xs'
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
|
||||||
)}
|
)}
|
||||||
Generate draft
|
Generate draft
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tailored Summary */}
|
{/* Tailored Summary */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
<label className="text-xs font-medium text-muted-foreground">
|
<label className='text-xs font-medium text-muted-foreground'>
|
||||||
Tailored Summary
|
Tailored Summary
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className='w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
placeholder="Write a tailored summary for this role, or generate with AI..."
|
placeholder='Write a tailored summary for this role, or generate with AI...'
|
||||||
disabled={isGenerating || isFinalizing}
|
disabled={isGenerating || isFinalizing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Projects */}
|
{/* Selected Projects */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
<div className="flex items-center justify-between">
|
<div className='flex items-center justify-between'>
|
||||||
<label className="text-xs font-medium text-muted-foreground">
|
<label className='text-xs font-medium text-muted-foreground'>
|
||||||
Selected Projects
|
Selected Projects
|
||||||
</label>
|
</label>
|
||||||
{tooManyProjects && (
|
{tooManyProjects && (
|
||||||
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
|
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className='h-3 w-3' />
|
||||||
Max {maxProjects} recommended
|
Max {maxProjects} recommended
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 max-h-[200px] overflow-y-auto pr-1">
|
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
|
||||||
{catalog.length === 0 ? (
|
{catalog.length === 0 ? (
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
<div className='text-xs text-muted-foreground text-center py-4'>
|
||||||
Loading projects...
|
Loading projects...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -540,18 +574,22 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
? "border-primary/40 bg-primary/5"
|
? "border-primary/40 bg-primary/5"
|
||||||
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
||||||
)}
|
)}
|
||||||
onClick={() => !isGenerating && !isFinalizing && handleToggleProject(project.id)}
|
onClick={() =>
|
||||||
|
!isGenerating &&
|
||||||
|
!isFinalizing &&
|
||||||
|
handleToggleProject(project.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`project-${project.id}`}
|
id={`project-${project.id}`}
|
||||||
checked={selectedIds.has(project.id)}
|
checked={selectedIds.has(project.id)}
|
||||||
onCheckedChange={() => handleToggleProject(project.id)}
|
onCheckedChange={() => handleToggleProject(project.id)}
|
||||||
disabled={isGenerating || isFinalizing}
|
disabled={isGenerating || isFinalizing}
|
||||||
className="mt-0.5"
|
className='mt-0.5'
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className='flex-1 min-w-0'>
|
||||||
<div className="font-medium truncate">{project.name}</div>
|
<div className='font-medium truncate'>{project.name}</div>
|
||||||
<div className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
|
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
|
||||||
{project.description}
|
{project.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -562,33 +600,33 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="opacity-50 my-4" />
|
<Separator className='opacity-50 my-4' />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
{!canFinalize && (
|
{!canFinalize && (
|
||||||
<p className="text-[10px] text-center text-muted-foreground">
|
<p className='text-[10px] text-center text-muted-foreground'>
|
||||||
Add a summary and select at least one project to finalize.
|
Add a summary and select at least one project to finalize.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinalize}
|
onClick={handleFinalize}
|
||||||
disabled={isFinalizing || !canFinalize || isGenerating}
|
disabled={isFinalizing || !canFinalize || isGenerating}
|
||||||
className="w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white"
|
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||||
>
|
>
|
||||||
{isFinalizing ? (
|
{isFinalizing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
Finalizing & generating PDF...
|
Finalizing & generating PDF...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className='mr-2 h-4 w-4' />
|
||||||
Finalize & Move to Ready
|
Finalize & Move to Ready
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-[10px] text-center text-muted-foreground/70">
|
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||||
This will generate your tailored PDF and move the job to Ready.
|
This will generate your tailored PDF and move the job to Ready.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -625,7 +663,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to skip job";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to skip job";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSkipping(false);
|
setIsSkipping(false);
|
||||||
@ -636,18 +675,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
try {
|
try {
|
||||||
setIsFinalizing(true);
|
setIsFinalizing(true);
|
||||||
|
|
||||||
// Generate PDF - this also transitions to Ready status
|
// Generate PDF - this also transitions to Ready status
|
||||||
await api.processJob(job.id);
|
await api.processJob(job.id);
|
||||||
|
|
||||||
toast.success("Job moved to Ready", {
|
toast.success("Job moved to Ready", {
|
||||||
description: "Your tailored PDF has been generated.",
|
description: "Your tailored PDF has been generated.",
|
||||||
});
|
});
|
||||||
|
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to finalize job";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to finalize job";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsFinalizing(false);
|
setIsFinalizing(false);
|
||||||
@ -657,13 +697,16 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
// Empty state
|
// Empty state
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4">
|
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
|
||||||
<div className="h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center">
|
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
|
||||||
<Sparkles className="h-4 w-4 text-muted-foreground/50" />
|
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
<div className='text-sm font-medium text-muted-foreground'>
|
||||||
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
No job selected
|
||||||
Select a job from the list to see details and decide whether to tailor.
|
</div>
|
||||||
|
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
|
||||||
|
Select a job from the list to see details and decide whether to
|
||||||
|
tailor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -672,10 +715,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
// Processing state (job is being processed by pipeline)
|
// Processing state (job is being processed by pipeline)
|
||||||
if (job.status === "processing") {
|
if (job.status === "processing") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4">
|
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-amber-400" />
|
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
|
||||||
<div className="text-sm font-medium text-foreground/80">Processing job...</div>
|
<div className='text-sm font-medium text-foreground/80'>
|
||||||
<p className="text-xs text-muted-foreground max-w-[220px]">
|
Processing job...
|
||||||
|
</div>
|
||||||
|
<p className='text-xs text-muted-foreground max-w-[220px]'>
|
||||||
This job is currently being analyzed by the pipeline. Please wait.
|
This job is currently being analyzed by the pipeline. Please wait.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -683,7 +728,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className='h-full'>
|
||||||
{mode === "decide" ? (
|
{mode === "decide" ? (
|
||||||
<DecideMode
|
<DecideMode
|
||||||
job={job}
|
job={job}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user