From 110956ea46fd06bba31a92d3567c1f87d2277f5f Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 15 Jan 2026 12:50:32 +0000 Subject: [PATCH] layout similar --- orchestrator/src/client/components/index.ts | 2 +- orchestrator/src/client/components/layout.tsx | 294 +++++++++++ .../src/client/pages/VisaSponsorsPage.tsx | 480 ++++++++---------- 3 files changed, 502 insertions(+), 274 deletions(-) create mode 100644 orchestrator/src/client/components/layout.tsx diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index 2f5d40c..feda6c3 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -7,4 +7,4 @@ export { JobTable } from './JobTable'; export { JobList } from './JobList'; export { PipelineProgress } from './PipelineProgress'; export { TailoringEditor } from './TailoringEditor'; - +export * from './layout'; diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx new file mode 100644 index 0000000..422f6bc --- /dev/null +++ b/orchestrator/src/client/components/layout.tsx @@ -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 = ({ + icon: Icon, + title, + subtitle, + badge, + statusIndicator, + nav, + actions, +}) => ( +
+
+
+
+ +
+
+
{title}
+
{subtitle}
+
+ {badge && ( + + {badge} + + )} + {statusIndicator} +
+ +
+ {nav?.map((item) => ( + + ))} + {actions} +
+
+
+); + +// ============================================================================ +// Status Indicator (Pipeline running, Updating, etc.) +// ============================================================================ + +interface StatusIndicatorProps { + label: string; + variant?: "amber" | "emerald" | "sky"; +} + +export const StatusIndicator: React.FC = ({ 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 ( + + + {label} + + ); +}; + +// ============================================================================ +// Split Layout (List + Detail panels) +// ============================================================================ + +interface SplitLayoutProps { + children: React.ReactNode; + className?: string; +} + +export const SplitLayout: React.FC = ({ children, className }) => ( +
+ {children} +
+); + +// ============================================================================ +// List Panel (left side of split) +// ============================================================================ + +interface ListPanelProps { + children: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; + className?: string; +} + +export const ListPanel: React.FC = ({ children, header, footer, className }) => ( +
+ {header &&
{header}
} +
{children}
+ {footer &&
{footer}
} +
+); + +// ============================================================================ +// List Item (clickable row in list) +// ============================================================================ + +interface ListItemProps { + selected?: boolean; + onClick?: () => void; + children: React.ReactNode; + className?: string; +} + +export const ListItem: React.FC = ({ selected, onClick, children, className }) => ( + +); + +// ============================================================================ +// Detail Panel (right side of split) +// ============================================================================ + +interface DetailPanelProps { + children: React.ReactNode; + className?: string; + sticky?: boolean; +} + +export const DetailPanel: React.FC = ({ children, className, sticky = true }) => ( +
+ {children} +
+); + +// ============================================================================ +// Empty State +// ============================================================================ + +interface EmptyStateProps { + icon?: LucideIcon; + title: string; + description?: string; + action?: React.ReactNode; +} + +export const EmptyState: React.FC = ({ icon: Icon, title, description, action }) => ( +
+ {Icon && } +
{title}
+ {description &&

{description}

} + {action &&
{action}
} +
+); + +// ============================================================================ +// 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 = ({ score, showLabel = true }) => { + if (score == null) { + return Not scored; + } + + const tokens = getScoreTokens(score); + return ( +
+
+
+
+ {showLabel && {score}%} +
+ ); +}; + +// ============================================================================ +// 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 = ({ + sidebar, + sidebarWidth = "w-[420px]", + children, +}) => ( +
+
{sidebar}
+
{children}
+
+); + +// ============================================================================ +// Section Card (for forms, stats, etc.) +// ============================================================================ + +interface SectionCardProps { + children: React.ReactNode; + className?: string; +} + +export const SectionCard: React.FC = ({ children, className }) => ( +
+ {children} +
+); + +// ============================================================================ +// Page Main Content Wrapper +// ============================================================================ + +interface PageMainProps { + children: React.ReactNode; + className?: string; +} + +export const PageMain: React.FC = ({ children, className }) => ( +
+ {children} +
+); diff --git a/orchestrator/src/client/pages/VisaSponsorsPage.tsx b/orchestrator/src/client/pages/VisaSponsorsPage.tsx index e7d7de4..433ee80 100644 --- a/orchestrator/src/client/pages/VisaSponsorsPage.tsx +++ b/orchestrator/src/client/pages/VisaSponsorsPage.tsx @@ -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 ( -
-
-
-
- {score}% -
- ); + 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 */} -
-
-
-
- -
-
-
Visa Sponsors
-
UK Register Search
-
- {(isUpdating || status?.isUpdating) && ( - - - Updating - - )} -
- -
- {/* Status info */} + : undefined} + nav={[{ icon: Sparkles, label: "Back to Orchestrator", to: "/" }]} + actions={ + <> {status && (
@@ -247,43 +215,37 @@ export const VisaSponsorsPage: React.FC = () => {
)} - + + } + /> - -
-
-
- - {/* Main content */} -
- {/* Left panel - Search and results */} -
- {/* Search input */} -
+ + {/* Search section */} +
+
+
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 = () => { )}
- {isSearching && ( -
- - Searching... -
- )} +

+ Enter a company name to check if they're a licensed UK visa sponsor. +

+
- {/* Results list */} -
- {/* No data state */} - {!isLoadingStatus && status?.totalSponsors === 0 && ( -
- -
- No sponsor data available -
-

- The visa sponsor list hasn't been downloaded yet. -

- -
- )} - - {/* Empty search state */} - {status && status.totalSponsors > 0 && !searchQuery && ( -
- -
- Search for a company -
-

- Enter a company name to check if they're on the UK visa sponsor register. -

-
- )} - - {/* No results state */} - {searchQuery && !isSearching && results.length === 0 && ( -
- -
- No matches found -
-

- No sponsors match "{searchQuery}". Try a different spelling. -

-
- )} - - {/* Results */} - {results.length > 0 && ( -
- {results.map((result, index) => ( - - ))} -
- )} -
- - {/* Results count footer */} - {results.length > 0 && ( -
- {results.length} result{results.length !== 1 ? "s" : ""} -
- )} -
- - {/* Right panel - Details */} -
- {!selectedOrg ? ( -
- -
- Select a company -
-

- Click on a search result to view details. -

-
- ) : isLoadingDetails ? ( -
- -
- ) : ( -
- {/* Header */} -
-
- - - Licensed Sponsor - - {selectedResult && ( - - {selectedResult.score}% Match + + {/* Left panel - Results */} + 0 ? ( +
+ {results.length} result{results.length !== 1 ? "s" : ""} + {isSearching && ( + + )}
-

- {selectedOrg} -

-
+ ) : null + } + > + {!isLoadingStatus && status?.totalSponsors === 0 && ( + + {isUpdating ? ( + <> + + Downloading... + + ) : ( + <> + + Download List + + )} + + } + /> + )} - {/* Location */} - {orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && ( -
-
- Location -
-
- - {[orgDetails[0].townCity, orgDetails[0].county] - .filter(Boolean) - .join(", ")} -
-
- )} + {status && status.totalSponsors > 0 && !searchQuery && ( + + )} - {/* Licence types / routes */} -
-
- Licensed Routes ({orgDetails.length}) -
-
- {orgDetails.map((entry, index) => ( -
-
- - {entry.route} - -
-
- Type & Rating:{" "} - {entry.typeRating} -
+ {searchQuery && !isSearching && results.length === 0 && ( + + )} + + {results.length > 0 && + results.map((result, index) => ( + fetchOrgDetails(result.sponsor.organisationName)} + className="gap-3" + > +
+
+ + + {result.sponsor.organisationName} +
- ))} -
-
+ {(result.sponsor.townCity || result.sponsor.county) && ( +
+ + {[result.sponsor.townCity, result.sponsor.county] + .filter(Boolean) + .join(", ")} +
+ )} +
+
+ + +
+ + ))} + - {/* Info box */} -
-
- What does this mean? -
-

- 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 */} + + {!selectedOrg ? ( +

+
Select a company
+

+ Pick a company from the results to see details here.

-
- )} -
-
+ ) : isLoadingDetails ? ( +
+ +
+ ) : ( +
+ {/* Header */} +
+
+ + + Licensed Sponsor + + {selectedResult && ( + + {selectedResult.score}% Match + + )} +
+

{selectedOrg}

+
+ + {/* Location */} + {orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && ( +
+
+ Location +
+
+ + {[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")} +
+
+ )} + + {/* Licence types / routes */} +
+
+ Licensed Routes ({orgDetails.length}) +
+
+ {orgDetails.map((entry, index) => ( +
+
+ + {entry.route} + +
+
+ Type & Rating:{" "} + {entry.typeRating} +
+
+ ))} +
+
+ + {/* Info box */} +
+
What does this mean?
+

+ 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. +

+
+
+ )} + + + ); };