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,332 +628,15 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const isProcessingSelected =
|
||||
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
|
||||
|
||||
const toggleSource = (source: JobSource, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...pipelineSources, source]))
|
||||
: pipelineSources.filter((s) => s !== source);
|
||||
|
||||
if (next.length === 0) return;
|
||||
setPipelineSources(next);
|
||||
const handleSelectJob = (jobId: string) => {
|
||||
setSelectedJobId(jobId);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
discovered: 0,
|
||||
applied: 0,
|
||||
all: jobs.length,
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "ready") byTab.ready += 1;
|
||||
if (job.status === "applied") byTab.applied += 1;
|
||||
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
|
||||
}
|
||||
|
||||
return byTab;
|
||||
}, [jobs]);
|
||||
|
||||
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="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="text-sm font-semibold tracking-tight">Job Ops</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
{isPipelineRunning && (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Pipeline running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Visa Sponsors search">
|
||||
<Link to="/visa-sponsors">
|
||||
<Shield className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="icon" aria-label="UK Visa Jobs search">
|
||||
<Link to="/ukvisajobs">
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Settings">
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" onClick={handleRunPipeline} disabled={isPipelineRunning} className="gap-2">
|
||||
{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">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{orderedSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) => toggleSource(source, Boolean(checked))}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(orderedSources);
|
||||
}}
|
||||
>
|
||||
All sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(["gradcracker"]);
|
||||
}}
|
||||
>
|
||||
Gradcracker only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(["indeed", "linkedin"]);
|
||||
}}
|
||||
>
|
||||
Indeed + LinkedIn only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<div className="max-w-3xl">
|
||||
<PipelineProgress isRunning={isPipelineRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact metrics summary - demoted visual weight */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
|
||||
{(stats.skipped > 0 || stats.expired > 0) && (
|
||||
<>
|
||||
<span className="text-border">•</span>
|
||||
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content: tabs/filters -> list/detail */}
|
||||
<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">
|
||||
{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>
|
||||
{counts[tab.id] > 0 && (
|
||||
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</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">
|
||||
<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}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(value) => setSourceFilter(value as JobSource | "all")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
|
||||
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sourceLabel[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
{sortLabels[sort.key]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sort.key}
|
||||
onValueChange={(value) =>
|
||||
setSort({
|
||||
key: value as JobSort["key"],
|
||||
direction: defaultSortDirection[value as JobSort["key"]],
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sortLabels[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSort((current) => ({
|
||||
...current,
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* 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">
|
||||
{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" />
|
||||
<div className="text-sm text-muted-foreground">Loading jobs...</div>
|
||||
</div>
|
||||
) : activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
<div className="text-base font-semibold">No jobs found</div>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{activeJobs.map((job) => {
|
||||
const isSelected = job.id === selectedJobId;
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedJobId(job.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/20 border-l-2 border-l-transparent",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70"
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium"
|
||||
)}>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && <span className="before:content-['_·_']">{job.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
job.suitabilityScore! >= 70 ? "text-emerald-400/90" :
|
||||
job.suitabilityScore! >= 50 ? "text-foreground/60" :
|
||||
"text-muted-foreground/60"
|
||||
)}>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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" ? (
|
||||
const detailPanelContent =
|
||||
activeTab === "discovered" ? (
|
||||
<DiscoveredPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={loadJobs}
|
||||
@ -935,7 +648,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
) : activeTab === "ready" ? (
|
||||
/* ReadyPanel for Ready tab - shipping lane workflow: verify → download → apply → mark applied */
|
||||
/* ReadyPanel for Ready tab - shipping lane workflow: verify + download + apply + mark applied */
|
||||
<ReadyPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={loadJobs}
|
||||
@ -1125,7 +838,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
||||
<TabsList className="h-8 text-xs">
|
||||
<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>
|
||||
@ -1141,19 +854,19 @@ export const OrchestratorPage: React.FC = () => {
|
||||
<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 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 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 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 className="text-foreground/80">{selectedJob.jobType || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1300,11 +1013,372 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toggleSource = (source: JobSource, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...pipelineSources, source]))
|
||||
: pipelineSources.filter((s) => s !== source);
|
||||
|
||||
if (next.length === 0) return;
|
||||
setPipelineSources(next);
|
||||
};
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
discovered: 0,
|
||||
applied: 0,
|
||||
all: jobs.length,
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "ready") byTab.ready += 1;
|
||||
if (job.status === "applied") byTab.applied += 1;
|
||||
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
|
||||
}
|
||||
|
||||
return byTab;
|
||||
}, [jobs]);
|
||||
|
||||
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 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="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>
|
||||
{isPipelineRunning && (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Pipeline running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="icon" aria-label="UK Visa Jobs search">
|
||||
<Link to="/ukvisajobs">
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Settings">
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<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"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{orderedSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) => toggleSource(source, Boolean(checked))}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(orderedSources);
|
||||
}}
|
||||
>
|
||||
All sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(["gradcracker"]);
|
||||
}}
|
||||
>
|
||||
Gradcracker only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPipelineSources(["indeed", "linkedin"]);
|
||||
}}
|
||||
>
|
||||
Indeed + LinkedIn only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<div className="max-w-3xl">
|
||||
<PipelineProgress isRunning={isPipelineRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact metrics summary - demoted visual weight */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
|
||||
{(stats.skipped > 0 || stats.expired > 0) && (
|
||||
<>
|
||||
<span className="text-border">•</span>
|
||||
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content: tabs/filters -> list/detail */}
|
||||
<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-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>
|
||||
{counts[tab.id] > 0 && (
|
||||
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(value) => setSourceFilter(value as JobSource | "all")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
|
||||
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sourceLabel[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sort.key}
|
||||
onValueChange={(value) =>
|
||||
setSort({
|
||||
key: value as JobSort["key"],
|
||||
direction: defaultSortDirection[value as JobSort["key"]],
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sortLabels[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSort((current) => ({
|
||||
...current,
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* 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="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" />
|
||||
<div className="text-sm text-muted-foreground">Loading jobs...</div>
|
||||
</div>
|
||||
) : activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
<div className="text-base font-semibold">No jobs found</div>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{activeJobs.map((job) => {
|
||||
const isSelected = job.id === selectedJobId;
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectJob(job.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/20 border-l-2 border-l-transparent",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70"
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium"
|
||||
)}>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && <span className="before:content-['_·_']">{job.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
job.suitabilityScore! >= 70 ? "text-emerald-400/90" :
|
||||
job.suitabilityScore! >= 50 ? "text-foreground/60" :
|
||||
"text-muted-foreground/60"
|
||||
)}>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inspector panel: visually subordinate to list */}
|
||||
<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