700 lines
24 KiB
TypeScript
700 lines
24 KiB
TypeScript
/**
|
|
* 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 { formatDate } from "../lib/dateUtils";
|
|
import * as api from "../api";
|
|
import { FitAssessment } from ".";
|
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Types
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
type PanelMode = "decide" | "tailor";
|
|
|
|
interface DiscoveredPanelProps {
|
|
job: Job | null;
|
|
onJobUpdated: () => void | Promise<void>;
|
|
onJobMoved: (jobId: string) => void;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const stripHtml = (value: string) =>
|
|
value
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
const sourceLabel: Record<Job["source"], string> = {
|
|
gradcracker: "Gradcracker",
|
|
indeed: "Indeed",
|
|
linkedin: "LinkedIn",
|
|
ukvisajobs: "UK Visa Jobs",
|
|
manual: "Manual",
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Decide Mode Panel
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface DecideModeProps {
|
|
job: Job;
|
|
onTailor: () => void;
|
|
onSkip: () => void;
|
|
isSkipping: boolean;
|
|
}
|
|
|
|
const DecideMode: React.FC<DecideModeProps> = ({
|
|
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 (
|
|
<div className='flex flex-col h-full'>
|
|
{/* Header */}
|
|
<div className='space-y-3 pb-4'>
|
|
<div className='flex items-start justify-between gap-2'>
|
|
<div className='min-w-0 flex-1'>
|
|
<h2 className='text-base font-semibold text-foreground/90 leading-tight'>
|
|
{job.title}
|
|
</h2>
|
|
<p className='text-sm text-muted-foreground mt-0.5'>
|
|
{job.employer}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center justify-center">
|
|
<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 */}
|
|
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 justify-between'>
|
|
{job.location && (
|
|
<span className='flex items-center gap-1'>
|
|
<MapPin className='h-3 w-3' />
|
|
{job.location}
|
|
</span>
|
|
)}
|
|
{deadline && (
|
|
<span className='flex items-center gap-1'>
|
|
<Calendar className='h-3 w-3' />
|
|
{deadline}
|
|
</span>
|
|
)}
|
|
{job.salary && (
|
|
<span className='flex items-center gap-1'>
|
|
<DollarSign className='h-3 w-3' />
|
|
{job.salary}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Primary/Secondary actions */}
|
|
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
|
|
<Button
|
|
variant='outline'
|
|
size='default'
|
|
onClick={onSkip}
|
|
disabled={isSkipping}
|
|
className='flex-1 h-11 text-sm text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
|
>
|
|
{isSkipping ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<XCircle className='mr-2 h-4 w-4' />
|
|
)}
|
|
Skip
|
|
</Button>
|
|
<Button
|
|
size='default'
|
|
onClick={onTailor}
|
|
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs'
|
|
>
|
|
<Sparkles className='mr-2 h-4 w-4' />
|
|
Tailor
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className='opacity-50' />
|
|
|
|
{/* Fit Summary - the core content */}
|
|
<div className='flex-1 py-4 space-y-4 overflow-y-auto'>
|
|
<FitAssessment job={job} />
|
|
|
|
{/* Collapsible full description */}
|
|
<div className='space-y-2'>
|
|
<button
|
|
type='button'
|
|
onClick={() => setShowDescription(!showDescription)}
|
|
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
|
>
|
|
{showDescription ? (
|
|
<ChevronUp className='h-3.5 w-3.5' />
|
|
) : (
|
|
<ChevronDown className='h-3.5 w-3.5' />
|
|
)}
|
|
{showDescription ? "Hide" : "View"} full job description
|
|
</button>
|
|
|
|
{showDescription && (
|
|
<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'>
|
|
{description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className='opacity-50' />
|
|
|
|
{/* Actions - clear hierarchy */}
|
|
<div className='pt-4 pb-2'>
|
|
{/* External link - tertiary */}
|
|
<div className='flex justify-center'>
|
|
<a
|
|
href={jobLink}
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
|
>
|
|
<ExternalLink className='h-3 w-3' />
|
|
View original listing
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tailor Mode Panel
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface TailorModeProps {
|
|
job: Job;
|
|
onBack: () => void;
|
|
onFinalize: () => void;
|
|
isFinalizing: boolean;
|
|
}
|
|
|
|
const TailorMode: React.FC<TailorModeProps> = ({
|
|
job,
|
|
onBack,
|
|
onFinalize,
|
|
isFinalizing,
|
|
}) => {
|
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
|
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
|
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
|
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");
|
|
const [showDescription, setShowDescription] = useState(false);
|
|
|
|
// Load project catalog
|
|
useEffect(() => {
|
|
api.getProfileProjects().then(setCatalog).catch(console.error);
|
|
}, []);
|
|
|
|
// Reset form when job changes
|
|
useEffect(() => {
|
|
setSummary(job.tailoredSummary || "");
|
|
setJobDescription(job.jobDescription || "");
|
|
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
|
setSelectedIds(new Set(saved));
|
|
setDraftStatus("saved");
|
|
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
|
|
|
|
// Track unsaved changes
|
|
const savedSummary = job.tailoredSummary || "";
|
|
const savedDescription = job.jobDescription || "";
|
|
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 (jobDescription !== savedDescription) return true;
|
|
if (selectedIds.size !== savedIds.size) return true;
|
|
for (const id of selectedIds) {
|
|
if (!savedIds.has(id)) return true;
|
|
}
|
|
return false;
|
|
}, [summary, savedSummary, jobDescription, savedDescription, 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,
|
|
jobDescription: jobDescription,
|
|
selectedProjectIds: Array.from(selectedIds).join(","),
|
|
});
|
|
setDraftStatus("saved");
|
|
} catch {
|
|
setDraftStatus("unsaved");
|
|
}
|
|
}, 1500);
|
|
|
|
return () => clearTimeout(timeout);
|
|
}, [summary, jobDescription, 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);
|
|
|
|
// Save any pending changes first so AI uses the latest description
|
|
if (hasChanges) {
|
|
await api.updateJob(job.id, {
|
|
tailoredSummary: summary,
|
|
jobDescription: jobDescription,
|
|
selectedProjectIds: Array.from(selectedIds).join(","),
|
|
});
|
|
}
|
|
|
|
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
|
setSummary(updatedJob.tailoredSummary || "");
|
|
setJobDescription(updatedJob.jobDescription || "");
|
|
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,
|
|
jobDescription: jobDescription,
|
|
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 (
|
|
<div className='flex flex-col h-full'>
|
|
{/* Header with back navigation */}
|
|
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<button
|
|
type='button'
|
|
onClick={onBack}
|
|
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' />
|
|
Back to overview
|
|
</button>
|
|
|
|
{/* Draft status indicator */}
|
|
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
|
|
{draftStatus === "saving" && (
|
|
<>
|
|
<Loader2 className='h-3 w-3 animate-spin' />
|
|
Saving...
|
|
</>
|
|
)}
|
|
{draftStatus === "saved" && !hasChanges && (
|
|
<>
|
|
<Check className='h-3 w-3 text-emerald-400' />
|
|
Saved
|
|
</>
|
|
)}
|
|
{draftStatus === "unsaved" && (
|
|
<span className='text-amber-400'>Unsaved changes</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Draft framing */}
|
|
<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='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
|
|
<span className='text-xs font-medium text-amber-300'>
|
|
Draft tailoring for this role
|
|
</span>
|
|
</div>
|
|
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
|
|
Edit below, then finalize to generate your PDF and move to Ready.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
|
{/* AI Generate option */}
|
|
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<div className='text-xs font-medium'>
|
|
Need help getting started?
|
|
</div>
|
|
<div className='text-[10px] text-muted-foreground'>
|
|
AI can draft a summary and select projects for you
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
onClick={handleGenerateWithAI}
|
|
disabled={isGenerating || isFinalizing}
|
|
className='h-8 w-full text-xs sm:w-auto'
|
|
>
|
|
{isGenerating ? (
|
|
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
|
) : (
|
|
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
|
|
)}
|
|
Generate draft
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Job Description - collapsible */}
|
|
<div className='space-y-2'>
|
|
<button
|
|
type='button'
|
|
onClick={() => setShowDescription(!showDescription)}
|
|
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
|
>
|
|
{showDescription ? (
|
|
<ChevronUp className='h-3.5 w-3.5' />
|
|
) : (
|
|
<ChevronDown className='h-3.5 w-3.5' />
|
|
)}
|
|
{showDescription ? "Hide" : "Edit"} job description
|
|
</button>
|
|
|
|
{showDescription && (
|
|
<div className='space-y-1'>
|
|
<label className='text-[10px] font-medium text-muted-foreground/70'>
|
|
Edit to help AI tailoring
|
|
</label>
|
|
<textarea
|
|
className='w-full min-h-[120px] max-h-[250px] 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={jobDescription}
|
|
onChange={(e) => setJobDescription(e.target.value)}
|
|
placeholder='The raw job description...'
|
|
disabled={isGenerating || isFinalizing}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tailored Summary */}
|
|
<div className='space-y-2'>
|
|
<label className='text-xs font-medium text-muted-foreground'>
|
|
Tailored Summary
|
|
</label>
|
|
<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'
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
placeholder='Write a tailored summary for this role, or generate with AI...'
|
|
disabled={isGenerating || isFinalizing}
|
|
/>
|
|
</div>
|
|
|
|
{/* Selected Projects */}
|
|
<div className='space-y-2'>
|
|
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
|
|
<label className='text-xs font-medium text-muted-foreground'>
|
|
Selected Projects
|
|
</label>
|
|
{tooManyProjects && (
|
|
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
|
|
<AlertTriangle className='h-3 w-3' />
|
|
Max {maxProjects} recommended
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
|
|
{catalog.length === 0 ? (
|
|
<div className='text-xs text-muted-foreground text-center py-4'>
|
|
Loading projects...
|
|
</div>
|
|
) : (
|
|
catalog.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className={cn(
|
|
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
|
|
selectedIds.has(project.id)
|
|
? "border-primary/40 bg-primary/5"
|
|
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
|
)}
|
|
onClick={() =>
|
|
!isGenerating &&
|
|
!isFinalizing &&
|
|
handleToggleProject(project.id)
|
|
}
|
|
>
|
|
<Checkbox
|
|
id={`project-${project.id}`}
|
|
checked={selectedIds.has(project.id)}
|
|
onCheckedChange={() => handleToggleProject(project.id)}
|
|
disabled={isGenerating || isFinalizing}
|
|
className='mt-0.5'
|
|
/>
|
|
<div className='flex-1 min-w-0'>
|
|
<div className='font-medium truncate'>{project.name}</div>
|
|
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
|
|
{project.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className='opacity-50 my-4' />
|
|
|
|
{/* Actions */}
|
|
<div className='space-y-2'>
|
|
{!canFinalize && (
|
|
<p className='text-[10px] text-center text-muted-foreground'>
|
|
Add a summary and select at least one project to finalize.
|
|
</p>
|
|
)}
|
|
<Button
|
|
onClick={handleFinalize}
|
|
disabled={isFinalizing || !canFinalize || isGenerating}
|
|
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
|
|
>
|
|
{isFinalizing ? (
|
|
<>
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
Finalizing & generating PDF...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className='mr-2 h-4 w-4' />
|
|
Finalize & Move to Ready
|
|
</>
|
|
)}
|
|
</Button>
|
|
<p className='text-[10px] text-center text-muted-foreground/70'>
|
|
This will generate your tailored PDF and move the job to Ready.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Main Panel Component
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|
job,
|
|
onJobUpdated,
|
|
onJobMoved,
|
|
}) => {
|
|
const [mode, setMode] = useState<PanelMode>("decide");
|
|
const [isSkipping, setIsSkipping] = useState(false);
|
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
|
|
|
// Reset mode when job changes
|
|
useEffect(() => {
|
|
setMode("decide");
|
|
setIsSkipping(false);
|
|
setIsFinalizing(false);
|
|
}, [job?.id]);
|
|
|
|
const handleSkip = async () => {
|
|
if (!job) return;
|
|
try {
|
|
setIsSkipping(true);
|
|
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 job";
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSkipping(false);
|
|
}
|
|
};
|
|
|
|
const handleFinalize = async () => {
|
|
if (!job) return;
|
|
try {
|
|
setIsFinalizing(true);
|
|
|
|
// Generate PDF - this also transitions to Ready status
|
|
await api.processJob(job.id);
|
|
|
|
toast.success("Job moved to Ready", {
|
|
description: "Your tailored PDF has been generated.",
|
|
});
|
|
|
|
onJobMoved(job.id);
|
|
await onJobUpdated();
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to finalize job";
|
|
toast.error(message);
|
|
} finally {
|
|
setIsFinalizing(false);
|
|
}
|
|
};
|
|
|
|
// Empty state
|
|
if (!job) {
|
|
return (
|
|
<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'>
|
|
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
|
|
</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 job from the list to see details and decide whether to
|
|
tailor.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Processing state (job is being processed by pipeline)
|
|
if (job.status === "processing") {
|
|
return (
|
|
<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' />
|
|
<div className='text-sm font-medium text-foreground/80'>
|
|
Processing job...
|
|
</div>
|
|
<p className='text-xs text-muted-foreground max-w-[220px]'>
|
|
This job is currently being analyzed by the pipeline. Please wait.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='h-full'>
|
|
{mode === "decide" ? (
|
|
<DecideMode
|
|
job={job}
|
|
onTailor={() => setMode("tailor")}
|
|
onSkip={handleSkip}
|
|
isSkipping={isSkipping}
|
|
/>
|
|
) : (
|
|
<TailorMode
|
|
job={job}
|
|
onBack={() => setMode("decide")}
|
|
onFinalize={handleFinalize}
|
|
isFinalizing={isFinalizing}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DiscoveredPanel;
|