Files
edr-platform/apps/edr-freight-web/portal/src/components/AppLayout.tsx
2026-07-06 14:09:55 +00:00

833 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
AppShell,
Avatar,
Box,
Button,
Divider,
FileInput,
Group,
Menu,
Modal,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
ChevronDown,
FileSignature,
LogOut,
Menu as MenuIcon,
Moon,
Plus,
Search,
Settings,
Sun,
Upload,
User,
X,
} from "lucide-react";
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
export interface SidebarItem {
label: string;
href: string;
icon?: ReactNode;
children?: SidebarItem[];
section?: string;
}
export interface AppLayoutProps {
title?: string;
sidebarItems: SidebarItem[];
activeHref?: string;
/**
* Navigate to a route. Accepts an optional options object (e.g. `{ state }`)
* forwarded to the router — used to pass navigation state like `fresh: true`
* to the new-booking wizard. Compatible with react-router's `navigate`.
*/
onNavigate?: (href: string, options?: { state?: unknown }) => void;
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null;
/** Create a new service profile of the given type (with business license). */
onCreateProfile?: (
type: ServiceType,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
children: ReactNode;
}
/** Service profiles a customer company can operate under and switch between. */
type ServiceType = "importer" | "exporter" | "freight_forwarder";
/** Services a customer company can select in the header. */
const CUSTOMER_SERVICES: ServiceType[] = [
"importer",
"exporter",
"freight_forwarder",
];
type SwitchResult =
| { success: true; data?: unknown }
| { success: false; error?: { message?: string } };
function getInitials(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join("");
}
function getActivePage(
items: SidebarItem[],
activePath: string,
): { label: string } | null {
const path = activePath.toLowerCase();
for (const item of items) {
if (
path === item.href.toLowerCase() ||
path.startsWith(item.href.toLowerCase() + "/")
) {
return { label: item.label };
}
if (item.children) {
const childMatch = item.children.find(
(c) =>
path === c.href.toLowerCase() ||
path.startsWith(c.href.toLowerCase() + "/"),
);
if (childMatch) return { label: childMatch.label };
}
}
return null;
}
const navClassNames = (active: boolean) => {
if (active) {
// Active tab gets the strong brand color: a green gradient pill, white
// label + icon, and a soft lifted shadow so it clearly stands out from the
// light rail around it.
return {
root: `rounded-[12px] font-medium transition-all duration-150 bg-gradient-to-r from-[#0EA371] to-[#0A8A60]! shadow-[0_6px_16px_rgba(14,163,113,0.30)]!`,
label: `text-white! font-bold!`,
section: `text-white!`,
};
}
return {
root: `rounded-[12px] font-medium transition-all duration-150 hover:bg-[#EBF4EF]!`,
label: `text-edr-text! font-semibold! hover:text-[#0A6F4D]!`,
section: `text-[#64748B]! hover:text-[#0A6F4D]!`,
};
};
export function AppLayout({
title = "EDR Freight",
sidebarItems,
activeHref = "",
onNavigate,
enableThemeToggle = false,
userName = "User",
userEmail,
companyProfiles = [],
companyType,
onCreateProfile,
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
const theme = useMantineTheme();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const borderColor = theme.colors["edr-border"][6];
const mutedColor = theme.colors["edr-muted"][6];
const textColor = theme.colors["edr-text"][6];
const primaryColor = theme.colors["edr-green"][5];
const primaryDarkColor = theme.colors["edr-green"][7];
const activePath = activeHref.toLowerCase();
const navigate = (href: string, options?: { state?: unknown }) =>
onNavigate?.(href, options);
const toggleTheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
};
const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath);
// ── Add a service (customer companies only) ──
// A customer can operate as importer, exporter and/or freight forwarder. The
// header lets them ADD a service they don't have yet (creating a profile with
// its business license). Data is no longer scoped by an "active" service —
// every page shows all the company's data, with an optional per-page filter.
const isCustomer = companyType === "customer";
const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type);
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
const canAddService = isCustomer && addableServices.length > 0;
const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const handleAddService = (type: ServiceType) => {
// Collect a business license, then create the profile.
setCreateTarget(type);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
};
const handleCreateConfirm = async () => {
if (licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file.");
return;
}
setSwitching(true);
setCreateError(null);
try {
const res = await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to create profile");
return;
}
setCreateOpen(false);
} finally {
setSwitching(false);
}
};
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() ||
activePath.startsWith(item.href.toLowerCase() + "/");
// Shared header "island" styles — every control is a consistent 36px chip.
const islandStyle: CSSProperties = {
width: 36,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
cursor: "pointer",
};
return (
<AppShell
layout="alt"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !mobileOpen },
}}
header={{ height: 72 }}
padding={0}
>
{/* ── Header (frosted glass; compact floating islands, mirrors the pen) ── */}
<AppShell.Header
withBorder={false}
style={{
// Solid white surface with a hairline base and a soft drop so it
// floats above the content area.
background: "#FFFFFF",
borderBottom: `1px solid ${borderColor}`,
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
}}
>
<Group
h="100%"
px={24}
pt={12}
pb={8}
justify="space-between"
wrap="nowrap"
>
{/* Left: sidebar toggle + page title */}
<Group gap={12} wrap="nowrap" align="center">
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Toggle sidebar"
>
<MenuIcon size={18} color={textColor} strokeWidth={1.8} />
</UnstyledButton>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontSize: 16,
fontWeight: 700,
color: textColor,
lineHeight: 1,
}}
>
{activePage ? activePage.label : title}
</Text>
</Group>
{/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Add a service (customer companies that don't yet have all three) */}
{canAddService && (
<Menu
width={220}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<Button
loading={switching}
variant="light"
color="edr-green"
radius={999}
size="sm"
leftSection={<Plus size={15} strokeWidth={1.8} />}
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
styles={{ root: { height: 36 } }}
visibleFrom="xs"
>
Add service
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Add a service</Menu.Label>
{addableServices.map((type) => (
<Menu.Item
key={type}
onClick={() => handleAddService(type)}
leftSection={<Plus size={15} strokeWidth={1.8} />}
>
{serviceLabel(type)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{/* Search pill */}
<Group
gap={8}
align="center"
visibleFrom="sm"
style={{
width: 260,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 14px",
cursor: "text",
}}
>
<Search size={15} color={mutedColor} strokeWidth={1.8} />
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings
</Text>
</Group>
{/* Notifications */}
<NotificationBellContainer />
{enableThemeToggle && (
<UnstyledButton
onClick={toggleTheme}
style={islandStyle}
aria-label="Toggle theme"
>
{computedColorScheme === "dark" ? (
<Sun size={17} color={textColor} strokeWidth={1.8} />
) : (
<Moon size={17} color={textColor} strokeWidth={1.8} />
)}
</UnstyledButton>
)}
{/* Avatar pill */}
<Menu
width={220}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<UnstyledButton
style={{
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 10px 0 4px",
display: "flex",
alignItems: "center",
gap: 7,
cursor: "pointer",
}}
>
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<ChevronDown size={15} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor }}
>
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
{companyProfiles.length > 0 && (
<>
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>
)}
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}
>
Settings
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() =>
navigate("/bookings/new", { state: { fresh: true } })
}
>
New Booking
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => navigate("/logout")}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
{/* ── Sidebar ── */}
<AppShell.Navbar
withBorder={false}
style={{
// Clean white rail.
background: "#FFFFFF",
borderRight: `1px solid ${borderColor}`,
display: "flex",
flexDirection: "column",
}}
>
{/* Brand */}
<Box
style={{
height: 60,
flexShrink: 0,
display: "flex",
alignItems: "center",
padding: "0 18px",
justifyContent: "space-between",
}}
>
<Group gap={11} wrap="nowrap">
<img
src="/assets/edr-logo.png"
alt="EDR"
style={{
width: 40,
height: 40,
objectFit: "contain",
flexShrink: 0,
}}
/>
<Box>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontWeight: 800,
fontSize: 19,
letterSpacing: "0.03em",
color: primaryColor,
lineHeight: 1.15,
}}
>
EDR FREIGHT
</Text>
<Text
style={{
fontSize: 10.5,
fontWeight: 500,
color: mutedColor,
lineHeight: 1.2,
}}
>
EthioDjibouti Railway
</Text>
</Box>
</Group>
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Close sidebar"
>
<X size={18} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Box>
{/* Nav */}
<ScrollArea flex={1} type="never" p="sm">
<Stack gap={3}>
{sidebarItems.map((item, i) => {
const active = isItemActive(item);
const hasChildren = !!item.children?.length;
const childActive =
item.children?.some((c) =>
activePath.startsWith(c.href.toLowerCase()),
) ?? false;
const prevSection = sidebarItems[i - 1]?.section;
const sectionLabel =
item.section && item.section !== prevSection ? (
<Text
key={`section-${item.section}`}
tt="uppercase"
px="sm"
mt={i === 0 ? 6 : "lg"}
mb={6}
style={{
fontWeight: 700,
color: "#94A3B8",
fontSize: 10.5,
letterSpacing: "0.08em",
}}
>
{item.section}
</Text>
) : null;
if (hasChildren) {
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active || childActive}
defaultOpened={childActive}
classNames={navClassNames(active || childActive)}
>
{item.children!.map((child) => {
const cActive = activePath === child.href.toLowerCase();
return (
<NavLink
key={child.href}
label={child.label}
active={cActive}
onClick={() => navigate(child.href)}
classNames={navClassNames(cActive)}
/>
);
})}
</NavLink>
</Fragment>
);
}
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active}
onClick={() => navigate(item.href)}
classNames={navClassNames(active)}
/>
</Fragment>
);
})}
</Stack>
</ScrollArea>
{/* Promo card */}
<Box style={{ padding: "0 12px 12px" }}>
<Box
style={{
borderRadius: 16,
overflow: "hidden",
position: "relative",
height: 260,
marginBottom: 10,
}}
>
{/* Train image fills the full card */}
<img
src="/assets/train-edr.jpg"
alt=""
style={{
position: "absolute",
bottom: "-16px",
left: 0,
right: "-70px",
width: "120%",
objectFit: "cover",
}}
/>
{/* Diagonal green overlay: lower-left → upper-right cut */}
<Box
style={{
position: "absolute",
inset: 0,
top: "-75%",
background:
"linear-gradient(165deg, #006149 40%, #0DC9A4 70%, #0DC9A402 80%)",
clipPath: "polygon(0 0, 100% 0, 100% 58%, 0 72%)",
}}
/>
{/* Text sits on top of the green overlay */}
<Box
style={{
position: "relative",
zIndex: 1,
padding: "16px 16px 0",
}}
className="max-w-[180px]"
>
<Text
style={{
fontSize: 15,
fontWeight: 800,
color: "#fff",
lineHeight: 1.3,
marginBottom: 4,
}}
>
Moving Africa Forward
</Text>
<Text
style={{
fontSize: 11,
color: "rgba(255,255,255,0.7)",
lineHeight: 1.5,
}}
>
Reliable. Efficient. Connected.
</Text>
</Box>
{/* Learn More — visible on the image area */}
<Box
style={{
position: "absolute",
bottom: 10,
right: 10,
zIndex: 2,
display: "inline-flex",
alignItems: "center",
gap: 5,
backgroundColor: "#fff",
borderRadius: 8,
padding: "6px 12px",
cursor: "pointer",
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
color: primaryDarkColor,
}}
>
Learn More
</Text>
<span style={{ fontSize: 12, color: primaryDarkColor }}></span>
</Box>
</Box>
{/* Profile */}
<Box
style={{
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: "10px",
display: "flex",
alignItems: "center",
gap: 10,
cursor: "pointer",
}}
>
<Avatar
radius="xl"
size={38}
color="edr-green.5"
style={{ flexShrink: 0 }}
>
<Text fw={600} fz={13} c="white">
{initials}
</Text>
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor, lineHeight: 1.3 }}
>
{userName}
</Text>
{userEmail && (
<Text
size="xs"
truncate
style={{ color: mutedColor, lineHeight: 1.3 }}
>
{userEmail}
</Text>
)}
</Box>
<ChevronDown
size={16}
color={mutedColor}
style={{ flexShrink: 0 }}
/>
</Box>
</Box>
</AppShell.Navbar>
{/* ── Main ── */}
<AppShell.Main
style={{
backgroundColor: "##f8fafc",
}}
>
{children}
</AppShell.Main>
{/* Create-profile modal — opens when switching to a mode the company
doesn't have a profile for yet. */}
<Modal
opened={createOpen}
onClose={() => (switching ? undefined : setCreateOpen(false))}
title={`Set up your ${serviceLabel(createTarget)} profile`}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
yet. Add your business license to create one and switch to{" "}
{serviceLabel(createTarget).toLowerCase()}.
</Text>
<FileInput
label="Business license"
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setCreateOpen(false)}
disabled={switching}
>
Cancel
</Button>
<Button
color="edr-green"
onClick={handleCreateConfirm}
loading={switching}
>
Create &amp; switch
</Button>
</Group>
</Stack>
</Modal>
</AppShell>
);
}
export default AppLayout;