layout similar
This commit is contained in:
parent
f7347a180d
commit
110956ea46
@ -7,4 +7,4 @@ export { JobTable } from './JobTable';
|
||||
export { JobList } from './JobList';
|
||||
export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
|
||||
export * from './layout';
|
||||
|
||||
294
orchestrator/src/client/components/layout.tsx
Normal file
294
orchestrator/src/client/components/layout.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Shared layout components for consistent page structure.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// Page Header
|
||||
// ============================================================================
|
||||
|
||||
interface HeaderNavItem {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge?: string;
|
||||
statusIndicator?: React.ReactNode;
|
||||
nav?: HeaderNavItem[];
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
statusIndicator,
|
||||
nav,
|
||||
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="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="text-sm font-semibold tracking-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
{badge && (
|
||||
<Badge variant="outline" className="uppercase tracking-wide">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{statusIndicator}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{nav?.map((item) => (
|
||||
<Button key={item.to} asChild variant="ghost" size="icon" aria-label={item.label}>
|
||||
<Link to={item.to}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Status Indicator (Pipeline running, Updating, etc.)
|
||||
// ============================================================================
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
label: string;
|
||||
variant?: "amber" | "emerald" | "sky";
|
||||
}
|
||||
|
||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant = "amber" }) => {
|
||||
const colorMap = {
|
||||
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
sky: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||
};
|
||||
const dotMap = {
|
||||
amber: "bg-amber-400",
|
||||
emerald: "bg-emerald-400",
|
||||
sky: "bg-sky-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
|
||||
colorMap[variant]
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full animate-pulse", dotMap[variant])} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Split Layout (List + Detail panels)
|
||||
// ============================================================================
|
||||
|
||||
interface SplitLayoutProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SplitLayout: React.FC<SplitLayoutProps> = ({ children, className }) => (
|
||||
<section className={cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]", className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// List Panel (left side of split)
|
||||
// ============================================================================
|
||||
|
||||
interface ListPanelProps {
|
||||
children: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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)}>
|
||||
{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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// List Item (clickable row in list)
|
||||
// ============================================================================
|
||||
|
||||
interface ListItemProps {
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<ListItemProps> = ({ selected, onClick, children, className }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
|
||||
selected ? "bg-muted/40" : "hover:bg-muted/30",
|
||||
className
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Detail Panel (right side of split)
|
||||
// ============================================================================
|
||||
|
||||
interface DetailPanelProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border/60 bg-card/40 p-4",
|
||||
sticky && "lg:sticky lg:top-24 lg:self-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Empty State
|
||||
// ============================================================================
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ icon: Icon, title, description, action }) => (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
{Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />}
|
||||
<div className="text-base font-semibold">{title}</div>
|
||||
{description && <p className="max-w-md text-sm text-muted-foreground">{description}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Score Meter
|
||||
// ============================================================================
|
||||
|
||||
interface ScoreMeterProps {
|
||||
score: number | null;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const getScoreTokens = (score: number) => {
|
||||
if (score >= 90) return { bar: "bg-emerald-500/80" };
|
||||
if (score >= 70) return { bar: "bg-amber-500/80" };
|
||||
if (score >= 50) return { bar: "bg-orange-500/80" };
|
||||
return { bar: "bg-rose-500/80" };
|
||||
};
|
||||
|
||||
export const ScoreMeter: React.FC<ScoreMeterProps> = ({ score, showLabel = true }) => {
|
||||
if (score == null) {
|
||||
return <span className="text-xs text-muted-foreground">Not scored</span>;
|
||||
}
|
||||
|
||||
const tokens = getScoreTokens(score);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-12 rounded-full bg-muted/40">
|
||||
<div
|
||||
className={cn("h-1.5 rounded-full", tokens.bar)}
|
||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && <span className="tabular-nums text-foreground">{score}%</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Full Height Split Layout (for pages like VisaSponsors that use full viewport)
|
||||
// ============================================================================
|
||||
|
||||
interface FullHeightSplitProps {
|
||||
sidebar: React.ReactNode;
|
||||
sidebarWidth?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
|
||||
sidebar,
|
||||
sidebarWidth = "w-[420px]",
|
||||
children,
|
||||
}) => (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className={cn("flex flex-col border-r", sidebarWidth)}>{sidebar}</div>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Section Card (for forms, stats, etc.)
|
||||
// ============================================================================
|
||||
|
||||
interface SectionCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SectionCard: React.FC<SectionCardProps> = ({ children, className }) => (
|
||||
<section className={cn("rounded-xl border border-border/60 bg-card/40 p-4", className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Page Main Content Wrapper
|
||||
// ============================================================================
|
||||
|
||||
interface PageMainProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageMain: React.FC<PageMainProps> = ({ children, className }) => (
|
||||
<main className={cn("container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12", className)}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
@ -19,13 +19,23 @@ import {
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PageHeader,
|
||||
StatusIndicator,
|
||||
ListItem,
|
||||
EmptyState,
|
||||
ScoreMeter,
|
||||
SplitLayout,
|
||||
ListPanel,
|
||||
DetailPanel,
|
||||
PageMain,
|
||||
} from "../components";
|
||||
import * as api from "../api";
|
||||
import type {
|
||||
VisaSponsor,
|
||||
@ -53,44 +63,14 @@ const formatDateTime = (dateStr?: string | null) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get score styling based on match quality
|
||||
*/
|
||||
const getScoreTokens = (score: number) => {
|
||||
if (score >= 90)
|
||||
return {
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
bar: "bg-emerald-500/80",
|
||||
};
|
||||
return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" };
|
||||
if (score >= 70)
|
||||
return {
|
||||
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
bar: "bg-amber-500/80",
|
||||
};
|
||||
return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" };
|
||||
if (score >= 50)
|
||||
return {
|
||||
badge: "border-orange-500/30 bg-orange-500/10 text-orange-200",
|
||||
bar: "bg-orange-500/80",
|
||||
};
|
||||
return {
|
||||
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
bar: "bg-rose-500/80",
|
||||
};
|
||||
};
|
||||
|
||||
const ScoreMeter: React.FC<{ score: number }> = ({ score }) => {
|
||||
const tokens = getScoreTokens(score);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-12 rounded-full bg-muted/40">
|
||||
<div
|
||||
className={cn("h-1.5 rounded-full", tokens.bar)}
|
||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-foreground">{score}%</span>
|
||||
</div>
|
||||
);
|
||||
return { badge: "border-orange-500/30 bg-orange-500/10 text-orange-200" };
|
||||
return { badge: "border-rose-500/30 bg-rose-500/10 text-rose-200" };
|
||||
};
|
||||
|
||||
export const VisaSponsorsPage: React.FC = () => {
|
||||
@ -195,7 +175,6 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
const result = await api.updateVisaSponsorList();
|
||||
setStatus(result.status);
|
||||
toast.success(result.message);
|
||||
// Re-run search if there was a query
|
||||
if (searchQuery.trim()) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
@ -212,29 +191,18 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
[results, selectedOrg]
|
||||
);
|
||||
|
||||
const isUpdateInProgress = isUpdating || status?.isUpdating;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Visa Sponsors</div>
|
||||
<div className="text-xs text-muted-foreground">UK Register Search</div>
|
||||
</div>
|
||||
{(isUpdating || status?.isUpdating) && (
|
||||
<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" />
|
||||
Updating
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status info */}
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
title="Visa Sponsors"
|
||||
subtitle="UK Register Search"
|
||||
statusIndicator={isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined}
|
||||
nav={[{ icon: Sparkles, label: "Back to Orchestrator", to: "/" }]}
|
||||
actions={
|
||||
<>
|
||||
{status && (
|
||||
<div className="hidden md:flex items-center gap-4 text-xs text-muted-foreground mr-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@ -247,43 +215,37 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating || status?.isUpdating}
|
||||
disabled={isUpdateInProgress}
|
||||
aria-label="Update sponsor list"
|
||||
>
|
||||
{isUpdating || status?.isUpdating ? (
|
||||
{isUpdateInProgress ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Back to Orchestrator">
|
||||
<Link to="/">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel - Search and results */}
|
||||
<div className="flex w-[420px] flex-col border-r">
|
||||
{/* Search input */}
|
||||
<div className="border-b p-4">
|
||||
<PageMain>
|
||||
{/* Search section */}
|
||||
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Company name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search for a company name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
className="pl-10 pr-10 h-10"
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery && (
|
||||
@ -295,214 +257,186 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter a company name to check if they're a licensed UK visa sponsor.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Results list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* No data state */}
|
||||
{!isLoadingStatus && status?.totalSponsors === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-amber-400 mb-4" />
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
No sponsor data available
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4 max-w-xs">
|
||||
The visa sponsor list hasn't been downloaded yet.
|
||||
</p>
|
||||
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download List
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty search state */}
|
||||
{status && status.totalSponsors > 0 && !searchQuery && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Search className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Search for a company
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Enter a company name to check if they're on the UK visa sponsor register.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results state */}
|
||||
{searchQuery && !isSearching && results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
No matches found
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
No sponsors match "{searchQuery}". Try a different spelling.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="divide-y divide-border/50">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
key={`${result.sponsor.organisationName}-${index}`}
|
||||
onClick={() => fetchOrgDetails(result.sponsor.organisationName)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors",
|
||||
selectedOrg === result.sponsor.organisationName
|
||||
? "bg-muted/50"
|
||||
: "hover:bg-muted/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{result.sponsor.organisationName}
|
||||
</span>
|
||||
</div>
|
||||
{(result.sponsor.townCity || result.sponsor.county) && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{[result.sponsor.townCity, result.sponsor.county]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ScoreMeter score={result.score} />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results count footer */}
|
||||
{results.length > 0 && (
|
||||
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - Details */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!selectedOrg ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Building2 className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Select a company
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click on a search result to view details.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoadingDetails ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<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
|
||||
<SplitLayout>
|
||||
{/* Left panel - Results */}
|
||||
<ListPanel
|
||||
footer={
|
||||
results.length > 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
{isSearching && (
|
||||
<span className="ml-2">
|
||||
<Loader2 className="inline h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{selectedOrg}
|
||||
</h2>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!isLoadingStatus && status?.totalSponsors === 0 && (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No sponsor data available"
|
||||
description="The visa sponsor list hasn't been downloaded yet."
|
||||
action={
|
||||
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download List
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
|
||||
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>
|
||||
)}
|
||||
{status && status.totalSponsors > 0 && !searchQuery && (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="Search for a company"
|
||||
description="Enter a company name above to check the sponsor register."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Licence types / routes */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3">
|
||||
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-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<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>
|
||||
{searchQuery && !isSearching && results.length === 0 && (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No matches found"
|
||||
description={`No sponsors match "${searchQuery}". Try a different spelling.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{results.length > 0 &&
|
||||
results.map((result, index) => (
|
||||
<ListItem
|
||||
key={`${result.sponsor.organisationName}-${index}`}
|
||||
selected={selectedOrg === result.sponsor.organisationName}
|
||||
onClick={() => fetchOrgDetails(result.sponsor.organisationName)}
|
||||
className="gap-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{result.sponsor.organisationName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(result.sponsor.townCity || result.sponsor.county) && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{[result.sponsor.townCity, result.sponsor.county]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ScoreMeter score={result.score} />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListPanel>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-4 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
|
||||
with their sponsor duties.
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</SplitLayout>
|
||||
</PageMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user