layout similar

This commit is contained in:
DaKheera47 2026-01-15 12:50:32 +00:00
parent f7347a180d
commit 110956ea46
3 changed files with 502 additions and 274 deletions

View File

@ -7,4 +7,4 @@ export { JobTable } from './JobTable';
export { JobList } from './JobList';
export { PipelineProgress } from './PipelineProgress';
export { TailoringEditor } from './TailoringEditor';
export * from './layout';

View 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>
);

View File

@ -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,26 +257,34 @@ 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...
<p className="text-xs text-muted-foreground">
Enter a company name to check if they're a licensed UK visa sponsor.
</p>
</div>
</section>
<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>
{/* Results list */}
<div className="flex-1 overflow-y-auto">
{/* No data state */}
) : null
}
>
{!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>
<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 ? (
<>
@ -328,50 +298,34 @@ export const VisaSponsorsPage: React.FC = () => {
</>
)}
</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>
<EmptyState
icon={Search}
title="Search for a company"
description="Enter a company name above to check the sponsor register."
/>
)}
{/* 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>
<EmptyState
icon={AlertCircle}
title="No matches found"
description={`No sponsors match "${searchQuery}". Try a different spelling.`}
/>
)}
{/* Results */}
{results.length > 0 && (
<div className="divide-y divide-border/50">
{results.map((result, index) => (
<button
{results.length > 0 &&
results.map((result, index) => (
<ListItem
key={`${result.sponsor.organisationName}-${index}`}
selected={selectedOrg === result.sponsor.organisationName}
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"
)}
className="gap-3"
>
<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" />
@ -392,41 +346,27 @@ export const VisaSponsorsPage: React.FC = () => {
<ScoreMeter score={result.score} />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</button>
</ListItem>
))}
</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>
</ListPanel>
{/* Right panel - Details */}
<div className="flex-1 overflow-y-auto">
<DetailPanel>
{!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.
<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-full">
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="p-6">
<div className="space-y-4">
{/* Header */}
<div className="mb-6">
<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" />
@ -443,38 +383,34 @@ export const VisaSponsorsPage: React.FC = () => {
</span>
)}
</div>
<h2 className="text-xl font-semibold text-foreground">
{selectedOrg}
</h2>
<h2 className="text-lg font-semibold text-foreground">{selectedOrg}</h2>
</div>
{/* 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">
<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(", ")}
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
</div>
</div>
)}
{/* Licence types / routes */}
<div className="mb-6">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3">
<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-4"
className="rounded-lg border border-border/60 bg-muted/20 p-3"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-start justify-between gap-2 mb-1">
<Badge variant="secondary" className="text-xs">
{entry.route}
</Badge>
@ -489,20 +425,18 @@ export const VisaSponsorsPage: React.FC = () => {
</div>
{/* 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>
<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
with their sponsor duties.
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>
)}
</div>
</div>
</DetailPanel>
</SplitLayout>
</PageMain>
</>
);
};