responsiveness
This commit is contained in:
parent
63d8c40c2e
commit
072feaf373
14
orchestrator/package-lock.json
generated
14
orchestrator/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@ -31,6 +32,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -8722,6 +8724,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@ -43,6 +44,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -150,13 +150,13 @@ const DecideMode: React.FC<DecideModeProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Primary/Secondary actions */}
|
||||
<div className='flex gap-2 pt-2'>
|
||||
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
size='default'
|
||||
onClick={onSkip}
|
||||
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-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' />
|
||||
@ -166,9 +166,9 @@ const DecideMode: React.FC<DecideModeProps> = ({
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
size='default'
|
||||
onClick={onTailor}
|
||||
className='flex-1 h-10 bg-primary/90 hover:bg-primary'
|
||||
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
|
||||
@ -388,7 +388,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* Header with back navigation */}
|
||||
<div className='flex items-center justify-between pb-3'>
|
||||
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBack}
|
||||
@ -434,7 +434,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
||||
{/* Scrollable content */}
|
||||
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
||||
{/* AI Generate option */}
|
||||
<div className='flex items-center justify-between rounded-lg border border-border/40 bg-muted/10 p-3'>
|
||||
<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?
|
||||
@ -448,7 +448,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
||||
variant='outline'
|
||||
onClick={handleGenerateWithAI}
|
||||
disabled={isGenerating || isFinalizing}
|
||||
className='h-8 text-xs'
|
||||
className='h-8 w-full text-xs sm:w-auto'
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
@ -506,7 +506,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
|
||||
|
||||
{/* Selected Projects */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<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>
|
||||
|
||||
@ -156,8 +156,8 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-base">Pipeline</CardTitle>
|
||||
<Badge variant="outline" className={cn("uppercase tracking-wide", stepBadgeClasses[step])}>
|
||||
{stepLabels[step]}
|
||||
|
||||
@ -223,9 +223,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
All actions in one line: View, Save, Open, and Mark Applied
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
<div className="pb-4 border-b border-border/40">
|
||||
<div className="flex gap-1">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{/* Show PDF - to verify quickly without download */}
|
||||
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<Button asChild variant="outline" className="h-9 w-full 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 PDF</span>
|
||||
@ -233,7 +233,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
</Button>
|
||||
|
||||
{/* Download PDF - primary artifact action */}
|
||||
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
||||
<a
|
||||
href={pdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||
@ -244,7 +244,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
</Button>
|
||||
|
||||
{/* Open job - to verify before applying */}
|
||||
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||
<Button asChild variant="outline" className="h-9 w-full 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 Job Listing</span>
|
||||
@ -255,7 +255,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
<Button
|
||||
onClick={handleMarkApplied}
|
||||
variant="default"
|
||||
className="flex-1 h-9 gap-1 px-2 text-xs"
|
||||
className="h-9 w-full gap-1 px-2 text-xs"
|
||||
disabled={isMarkingApplied}
|
||||
>
|
||||
{isMarkingApplied ? (
|
||||
@ -382,10 +382,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
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">
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] max-w-xl">
|
||||
<div className="flex flex-wrap 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="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium">{recentlyApplied.jobTitle}</span>
|
||||
<span className="text-muted-foreground"> marked applied</span>
|
||||
</span>
|
||||
|
||||
@ -154,14 +154,15 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Editor</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSummarize}
|
||||
disabled={isSummarizing || isGeneratingPdf || isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSummarizing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
AI Summarize
|
||||
@ -170,6 +171,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
size="sm"
|
||||
onClick={handleGeneratePdf}
|
||||
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isGeneratingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
|
||||
Generate PDF
|
||||
@ -203,7 +205,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
|
||||
<label className="text-sm font-medium">Selected Projects</label>
|
||||
{tooManyProjects && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||
|
||||
@ -40,12 +40,12 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
actions,
|
||||
}) => (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
@ -57,7 +57,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
{statusIndicator}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||
{nav?.map((item) => (
|
||||
<Button key={item.to} asChild variant="ghost" size="icon" aria-label={item.label}>
|
||||
<Link to={item.to}>
|
||||
@ -132,7 +132,7 @@ interface ListPanelProps {
|
||||
}
|
||||
|
||||
export const ListPanel: React.FC<ListPanelProps> = ({ children, header, footer, className }) => (
|
||||
<div className={cn("rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}>
|
||||
<div className={cn("min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}>
|
||||
{header && <div className="border-b border-border/60 px-4 py-3">{header}</div>}
|
||||
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">{children}</div>
|
||||
{footer && <div className="border-t border-border/60 px-4 py-2">{footer}</div>}
|
||||
@ -178,7 +178,7 @@ interface DetailPanelProps {
|
||||
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border/60 bg-card/40 p-4",
|
||||
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
|
||||
sticky && "lg:sticky lg:top-24 lg:self-start",
|
||||
className
|
||||
)}
|
||||
@ -254,11 +254,11 @@ interface FullHeightSplitProps {
|
||||
|
||||
export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
|
||||
sidebar,
|
||||
sidebarWidth = "w-[420px]",
|
||||
sidebarWidth = "lg:w-[420px]",
|
||||
children,
|
||||
}) => (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className={cn("flex flex-col border-r", sidebarWidth)}>{sidebar}</div>
|
||||
<div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<div className={cn("flex w-full flex-col border-b lg:border-b-0 lg:border-r", sidebarWidth)}>{sidebar}</div>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -47,6 +47,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import { PipelineProgress, DiscoveredPanel } from "../components";
|
||||
@ -314,6 +315,10 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
||||
);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||
try {
|
||||
@ -488,6 +493,31 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
}, [activeJobs, selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJobId) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const media = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => setIsDesktop(media.matches);
|
||||
handleChange();
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener("change", handleChange);
|
||||
return () => media.removeEventListener("change", handleChange);
|
||||
}
|
||||
media.addListener(handleChange);
|
||||
return () => media.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop && isDetailDrawerOpen) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [isDesktop, isDetailDrawerOpen]);
|
||||
|
||||
const selectedJob = useMemo(
|
||||
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
|
||||
[jobs, selectedJobId],
|
||||
@ -598,6 +628,393 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const isProcessingSelected =
|
||||
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
|
||||
|
||||
const handleSelectJob = (jobId: string) => {
|
||||
setSelectedJobId(jobId);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const detailPanelContent =
|
||||
activeTab === "discovered" ? (
|
||||
<DiscoveredPanel
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
) : 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>
|
||||
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Detail header: lighter weight than list items */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground/90">{selectedJob.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{selectedJob.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[selectedJob.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tertiary metadata - subdued */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
||||
{selectedJob.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{selectedJob.location}
|
||||
</span>
|
||||
)}
|
||||
{selectedDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{selectedDeadline}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{selectedJob.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={selectedJob.status} />
|
||||
<ScoreMeter score={selectedJob.suitabilityScore} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{showReadyPdf &&
|
||||
(selectedHasPdf ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showGeneratePdf && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => handleProcess(selectedJob.id)}
|
||||
disabled={!canProcess || isProcessingSelected}
|
||||
>
|
||||
{isProcessingSelected ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isProcessingSelected ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canApply && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 border border-emerald-500/30"
|
||||
onClick={() => handleApply(selectedJob.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Applied
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="More actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canProcess && !showGeneratePdf && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleProcess(selectedJob.id)}
|
||||
disabled={isProcessingSelected}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
{isProcessingSelected
|
||||
? "Processing..."
|
||||
: selectedJob.status === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Generate PDF"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit description
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleCopyInfo(selectedJob)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy info
|
||||
</DropdownMenuItem>
|
||||
{selectedHasPdf && (
|
||||
<>
|
||||
{!showReadyPdf && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canSkip && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleSkip(selectedJob.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip job
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
||||
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
|
||||
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
|
||||
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
{selectedJob.suitabilityReason && (
|
||||
<div className="rounded border border-border/30 bg-muted/10 px-3 py-2 text-xs text-muted-foreground italic">
|
||||
"{selectedJob.suitabilityReason}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
|
||||
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded border border-border/30 bg-muted/5 px-2.5 py-2 text-[11px] text-muted-foreground/80 line-clamp-4 whitespace-pre-wrap leading-relaxed hover:bg-muted/10 transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
{description}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
View full description →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tailoring" className="pt-3">
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={loadJobs}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="description" className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditingDescription ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditingDescription(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleSaveDescription}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copyTextToClipboard(selectedJob.jobDescription || "");
|
||||
toast.success("Copied raw description");
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy raw text
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground">
|
||||
{isEditingDescription ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(event) => setEditedDescription(event.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed focus-visible:ring-1"
|
||||
placeholder="Enter job description..."
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Description
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toggleSource = (source: JobSource, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...pipelineSources, source]))
|
||||
@ -627,12 +1044,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
@ -644,7 +1061,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Visa Sponsors search">
|
||||
<Link to="/visa-sponsors">
|
||||
<Shield className="h-4 w-4" />
|
||||
@ -661,15 +1078,26 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" onClick={handleRunPipeline} disabled={isPipelineRunning} className="gap-2">
|
||||
<div className="flex w-full items-center gap-1 sm:w-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunPipeline}
|
||||
disabled={isPipelineRunning}
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
>
|
||||
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
{isPipelineRunning ? "Running" : "Run pipeline"}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="outline" disabled={isPipelineRunning} aria-label="Select pipeline sources">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPipelineRunning}
|
||||
aria-label="Select pipeline sources"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@ -752,7 +1180,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
<section className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as FilterTab)}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<TabsList className="h-9 w-full lg:w-auto">
|
||||
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
|
||||
<span>{tab.label}</span>
|
||||
@ -764,7 +1192,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</TabsList>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full min-w-[180px] flex-1 lg:max-w-[240px] lg:flex-none">
|
||||
<div className="relative w-full min-w-0 flex-1 sm:min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
@ -776,7 +1204,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full gap-1.5 text-xs text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
|
||||
</Button>
|
||||
@ -800,7 +1232,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full gap-1.5 text-xs text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
{sortLabels[sort.key]}
|
||||
</Button>
|
||||
@ -844,7 +1280,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
{/* List/Detail grid - directly under tabs, no extra section */}
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
|
||||
{/* Primary region: Job list with highest visual weight */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
|
||||
{isLoading && jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@ -867,7 +1303,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedJobId(job.id)}
|
||||
onClick={() => handleSelectJob(job.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
isSelected
|
||||
@ -921,390 +1357,28 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Inspector panel: visually subordinate to list */}
|
||||
<div className="rounded-lg border border-border/40 bg-muted/5 p-4 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto">
|
||||
{/* Use DiscoveredPanel for Discovered tab - two-mode triage workflow */}
|
||||
{activeTab === "discovered" ? (
|
||||
<DiscoveredPanel
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
) : 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>
|
||||
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Detail header: lighter weight than list items */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground/90">{selectedJob.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{selectedJob.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[selectedJob.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tertiary metadata - subdued */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
||||
{selectedJob.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{selectedJob.location}
|
||||
</span>
|
||||
)}
|
||||
{selectedDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{selectedDeadline}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{selectedJob.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={selectedJob.status} />
|
||||
<ScoreMeter score={selectedJob.suitabilityScore} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{showReadyPdf &&
|
||||
(selectedHasPdf ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showGeneratePdf && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => handleProcess(selectedJob.id)}
|
||||
disabled={!canProcess || isProcessingSelected}
|
||||
>
|
||||
{isProcessingSelected ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isProcessingSelected ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canApply && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 border border-emerald-500/30"
|
||||
onClick={() => handleApply(selectedJob.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Applied
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="More actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canProcess && !showGeneratePdf && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleProcess(selectedJob.id)}
|
||||
disabled={isProcessingSelected}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
{isProcessingSelected
|
||||
? "Processing..."
|
||||
: selectedJob.status === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Generate PDF"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit description
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleCopyInfo(selectedJob)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy info
|
||||
</DropdownMenuItem>
|
||||
{selectedHasPdf && (
|
||||
<>
|
||||
{!showReadyPdf && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canSkip && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleSkip(selectedJob.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip job
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
||||
<TabsList className="h-8 text-xs">
|
||||
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
|
||||
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
{selectedJob.suitabilityReason && (
|
||||
<div className="rounded border border-border/30 bg-muted/10 px-3 py-2 text-xs text-muted-foreground italic">
|
||||
"{selectedJob.suitabilityReason}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
|
||||
<div className="text-foreground/80">{selectedJob.disciplines || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobFunction || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobLevel || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobType || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded border border-border/30 bg-muted/5 px-2.5 py-2 text-[11px] text-muted-foreground/80 line-clamp-4 whitespace-pre-wrap leading-relaxed hover:bg-muted/10 transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
{description}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
View full description →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tailoring" className="pt-3">
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={loadJobs}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="description" className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditingDescription ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditingDescription(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleSaveDescription}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copyTextToClipboard(selectedJob.jobDescription || "");
|
||||
toast.success("Copied raw description");
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy raw text
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground">
|
||||
{isEditingDescription ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(event) => setEditedDescription(event.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed focus-visible:ring-1"
|
||||
placeholder="Enter job description..."
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Description
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 rounded-lg border border-border/40 bg-muted/5 p-4 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto hidden lg:block">
|
||||
{detailPanelContent}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
|
||||
{detailPanelContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import type { CreateJobInput } from "../../shared/types";
|
||||
@ -86,6 +87,10 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
const [totalJobs, setTotalJobs] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) {
|
||||
@ -98,6 +103,31 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
}
|
||||
}, [results, selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJobId) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const media = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => setIsDesktop(media.matches);
|
||||
handleChange();
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener("change", handleChange);
|
||||
return () => media.removeEventListener("change", handleChange);
|
||||
}
|
||||
media.addListener(handleChange);
|
||||
return () => media.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop && isDetailDrawerOpen) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [isDesktop, isDetailDrawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedJobIds(new Set());
|
||||
}, [results]);
|
||||
@ -204,15 +234,111 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
const canGoPrev = page > 1;
|
||||
const canGoNext = page < totalPages;
|
||||
|
||||
const handleSelectJob = (jobId: string) => {
|
||||
setSelectedJobId(jobId);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const detailPanelContent = !selectedJob ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<div className="text-base font-semibold">Select a job</div>
|
||||
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="uppercase tracking-wide">
|
||||
UK Visa Jobs
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{selectedJob.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{selectedJob.location}
|
||||
</span>
|
||||
)}
|
||||
{selectedDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{selectedDeadline}
|
||||
</span>
|
||||
)}
|
||||
{selectedPosted && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Posted {selectedPosted}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3.5 w-3.5" />
|
||||
{selectedJob.salary}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.degreeRequired && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="h-3.5 w-3.5" />
|
||||
{selectedJob.degreeRequired}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Job type</div>
|
||||
<div className="font-medium">{selectedJob.jobType || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Job level</div>
|
||||
<div className="font-medium">{selectedJob.jobLevel || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Location</div>
|
||||
<div className="font-medium">{selectedJob.location || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Deadline</div>
|
||||
<div className="font-medium">{selectedDeadline || "Not set"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button asChild size="sm" variant="outline" className="w-full gap-2 sm:w-auto">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View job
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Description
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{selectedDescription}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
|
||||
<div className="text-xs text-muted-foreground">Live search console</div>
|
||||
</div>
|
||||
@ -221,7 +347,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Back to orchestrator">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@ -270,11 +396,11 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{totalJobs} total
|
||||
</Badge>
|
||||
@ -290,7 +416,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]">
|
||||
<div className="relative rounded-xl border border-border/60 bg-card/40">
|
||||
<div className="relative min-w-0 rounded-xl border border-border/60 bg-card/40">
|
||||
{isSearching && results.length > 0 && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-xl bg-background/70 text-sm text-muted-foreground backdrop-blur-sm">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@ -338,7 +464,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
onClick={handleImportSelected}
|
||||
disabled={selectedCount === 0 || isImporting}
|
||||
>
|
||||
@ -358,11 +484,11 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedJobId(key)}
|
||||
onClick={() => handleSelectJob(key)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedJobId(key);
|
||||
handleSelectJob(key);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@ -443,7 +569,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
<span>
|
||||
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -473,98 +599,27 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card/40 p-4 lg:sticky lg:top-24 lg:self-start">
|
||||
{!selectedJob ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<div className="text-base font-semibold">Select a job</div>
|
||||
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="uppercase tracking-wide">
|
||||
UK Visa Jobs
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{selectedJob.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{selectedJob.location}
|
||||
</span>
|
||||
)}
|
||||
{selectedDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{selectedDeadline}
|
||||
</span>
|
||||
)}
|
||||
{selectedPosted && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Posted {selectedPosted}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3.5 w-3.5" />
|
||||
{selectedJob.salary}
|
||||
</span>
|
||||
)}
|
||||
{selectedJob.degreeRequired && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="h-3.5 w-3.5" />
|
||||
{selectedJob.degreeRequired}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Job type</div>
|
||||
<div className="font-medium">{selectedJob.jobType || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Job level</div>
|
||||
<div className="font-medium">{selectedJob.jobLevel || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Location</div>
|
||||
<div className="font-medium">{selectedJob.location || "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Deadline</div>
|
||||
<div className="font-medium">{selectedDeadline || "Not set"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button asChild size="sm" variant="outline" className="gap-2">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View job
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Description
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{selectedDescription}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 rounded-xl border border-border/60 bg-card/40 p-4 lg:sticky lg:top-24 lg:self-start hidden lg:block">
|
||||
{detailPanelContent}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
|
||||
{detailPanelContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PageHeader,
|
||||
@ -86,6 +87,10 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
||||
);
|
||||
|
||||
// Fetch status on mount
|
||||
useEffect(() => {
|
||||
@ -152,6 +157,31 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrg) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [selectedOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const media = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => setIsDesktop(media.matches);
|
||||
handleChange();
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener("change", handleChange);
|
||||
return () => media.removeEventListener("change", handleChange);
|
||||
}
|
||||
media.addListener(handleChange);
|
||||
return () => media.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop && isDetailDrawerOpen) {
|
||||
setIsDetailDrawerOpen(false);
|
||||
}
|
||||
}, [isDesktop, isDetailDrawerOpen]);
|
||||
|
||||
// Fetch organization details
|
||||
const fetchOrgDetails = async (orgName: string) => {
|
||||
setIsLoadingDetails(true);
|
||||
@ -186,6 +216,13 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOrg = (orgName: string) => {
|
||||
fetchOrgDetails(orgName);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedResult = useMemo(
|
||||
() => results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
|
||||
[results, selectedOrg]
|
||||
@ -193,6 +230,89 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
|
||||
const isUpdateInProgress = isUpdating || status?.isUpdating;
|
||||
|
||||
const detailPanelContent = !selectedOrg ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<div className="text-base font-semibold">Select a company</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a company from the results to see details here.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoadingDetails ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Licensed Sponsor
|
||||
</span>
|
||||
{selectedResult && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
||||
getScoreTokens(selectedResult.score).badge
|
||||
)}
|
||||
>
|
||||
{selectedResult.score}% Match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{selectedOrg}</h2>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Location
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Licence types / routes */}
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Licensed Routes ({orgDetails.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{orgDetails.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-border/60 bg-muted/20 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{entry.route}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
|
||||
{entry.typeRating}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
||||
<div className="font-medium text-sky-200 mb-1">What does this mean?</div>
|
||||
<p className="text-xs text-sky-300/80">
|
||||
This organisation is licensed by the UK Home Office to sponsor workers on the
|
||||
routes listed above. An "A rating" means they're fully compliant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@ -323,7 +443,7 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
<ListItem
|
||||
key={`${result.sponsor.organisationName}-${index}`}
|
||||
selected={selectedOrg === result.sponsor.organisationName}
|
||||
onClick={() => fetchOrgDetails(result.sponsor.organisationName)}
|
||||
onClick={() => handleSelectOrg(result.sponsor.organisationName)}
|
||||
className="gap-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -351,92 +471,27 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
</ListPanel>
|
||||
|
||||
{/* Right panel - Details */}
|
||||
<DetailPanel>
|
||||
{!selectedOrg ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<div className="text-base font-semibold">Select a company</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a company from the results to see details here.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoadingDetails ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Licensed Sponsor
|
||||
</span>
|
||||
{selectedResult && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
||||
getScoreTokens(selectedResult.score).badge
|
||||
)}
|
||||
>
|
||||
{selectedResult.score}% Match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{selectedOrg}</h2>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Location
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Licence types / routes */}
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Licensed Routes ({orgDetails.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{orgDetails.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-border/60 bg-muted/20 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{entry.route}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
|
||||
{entry.typeRating}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
||||
<div className="font-medium text-sky-200 mb-1">What does this mean?</div>
|
||||
<p className="text-xs text-sky-300/80">
|
||||
This organisation is licensed by the UK Home Office to sponsor workers on the
|
||||
routes listed above. An "A rating" means they're fully compliant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DetailPanel className="hidden lg:block">
|
||||
{detailPanelContent}
|
||||
</DetailPanel>
|
||||
</SplitLayout>
|
||||
</PageMain>
|
||||
|
||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Sponsor details</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
|
||||
{detailPanelContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
116
orchestrator/src/components/ui/drawer.tsx
Normal file
116
orchestrator/src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user