Files
edr-platform/apps/edr-freight-web/portal/src/components/AppLayout.tsx
2026-08-15 10:34:05 +00:00

953 lines
31 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 {
Alert,
AppShell,
Avatar,
Badge,
Box,
Button,
Divider,
FileInput,
Group,
Menu,
Modal,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Ban,
ChevronDown,
FileSignature,
LogOut,
Menu as MenuIcon,
Plus,
RefreshCw,
// Search,
Settings,
Upload,
User,
X,
} from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import SupportWidget from "@/features/support/SupportWidget";
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;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: {
id?: string;
type: string;
reference: string;
status?: string;
reviewNote?: string | null;
}[];
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null;
/**
* Render the floating support-chat launcher. Defaults to true so the customer
* portal is unaffected; shipping lines pass false — support chat is scoped to
* a company, which they do not have.
*/
showSupportWidget?: boolean;
/** Create a new service profile of the given type (with business license). */
onCreateProfile?: (
type: ServiceType,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
/** Resubmit a rejected service for approval, optionally replacing its license. */
onReapplyProfile?: (
profileId: string,
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();
// Longest match wins, for the same reason as the sidebar's isItemActive:
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
// parent, which a first-match-wins scan would report as "Home".
const best = items
.flatMap((item) => [item, ...(item.children ?? [])])
.filter(
(item) =>
path === item.href.toLowerCase() ||
path.startsWith(item.href.toLowerCase() + "/"),
)
.sort((a, b) => b.href.length - a.href.length)[0];
return best ? { label: best.label } : 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,
userName = "User",
userEmail,
companyProfiles = [],
companyType,
onCreateProfile,
onReapplyProfile,
showSupportWidget = true,
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
const theme = useMantineTheme();
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 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));
// Rejected services can't be re-added (they exist), so they'd otherwise be
// invisible here — surface them for resubmission alongside addable ones.
const rejectedServices = isCustomer
? companyProfiles.filter(
(p) =>
p.status === "rejected" &&
p.id &&
CUSTOMER_SERVICES.includes(p.type as ServiceType),
)
: [];
// Suspended services are also hidden by default (the profile exists) — surface
// them so the customer can appeal by resubmitting a fresh business license.
const suspendedServices = isCustomer
? companyProfiles.filter(
(p) =>
p.status === "suspended" &&
p.id &&
CUSTOMER_SERVICES.includes(p.type as ServiceType),
)
: [];
const canManageServices =
isCustomer &&
(addableServices.length > 0 ||
rejectedServices.length > 0 ||
suspendedServices.length > 0);
const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
const [reapplyId, setReapplyId] = useState<string | null>(null);
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
// the modal copy; null for a brand-new profile.
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
// Reason the profile was suspended/rejected, surfaced in the modal.
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const openServiceModal = (
type: ServiceType,
profile?: { id?: string; status?: string; reviewNote?: string | null },
) => {
setCreateTarget(type);
setReapplyId(profile?.id ?? null);
setReapplyStatus(profile?.status ?? null);
setReapplyNote(profile?.reviewNote ?? null);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
};
const handleAddService = (type: ServiceType) => openServiceModal(type);
const handleCreateConfirm = async () => {
const isReapply = reapplyId !== null;
// A new profile needs its license up front; a resubmit may reuse the old one.
if (!isReapply && licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file.");
return;
}
setSwitching(true);
setCreateError(null);
try {
const res = isReapply
? await onReapplyProfile?.(reapplyId, licenseFiles)
: await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to submit service");
return;
}
setCreateOpen(false);
setReapplyId(null);
} finally {
setSwitching(false);
}
};
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isSuspendedAppeal = reapplyStatus === "suspended";
// Longest matching href wins. A plain prefix test would light up every
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
// bookings", Home would stay highlighted on every page beneath it. Exact
// matches still win outright, so customer routes are unaffected — their
// sidebar hrefs are siblings, never nested inside one another.
const bestMatchHref = sidebarItems
.flatMap((item) => [item, ...(item.children ?? [])])
.map((item) => item.href.toLowerCase())
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
.sort((a, b) => b.length - a.length)[0];
const isItemActive = (item: SidebarItem) =>
bestMatchHref === item.href.toLowerCase();
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, or resubmit a rejected one (customer companies) */}
{canManageServices && (
<Menu
width={240}
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>
{addableServices.length > 0 && (
<>
<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>
))}
</>
)}
{rejectedServices.length > 0 && (
<>
{addableServices.length > 0 && <Menu.Divider />}
<Menu.Label>Rejected resubmit</Menu.Label>
{rejectedServices.map((p) => (
<Menu.Item
key={p.id}
color="red"
onClick={() =>
openServiceModal(p.type as ServiceType, p)
}
leftSection={
<RefreshCw size={15} strokeWidth={1.8} />
}
>
{serviceLabel(p.type as ServiceType)}
</Menu.Item>
))}
</>
)}
{suspendedServices.length > 0 && (
<>
{(addableServices.length > 0 ||
rejectedServices.length > 0) && <Menu.Divider />}
<Menu.Label>Suspended appeal</Menu.Label>
{suspendedServices.map((p) => (
<Menu.Item
key={p.id}
color="orange"
onClick={() =>
openServiceModal(p.type as ServiceType, p)
}
leftSection={<Ban size={15} strokeWidth={1.8} />}
rightSection={
<Badge size="xs" color="orange" variant="light">
Suspended
</Badge>
}
>
{serviceLabel(p.type as ServiceType)}
</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 />
{/* 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={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
Signature &amp; Stamp
</Menu.Item>
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</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 Contract
</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",
// Extra bottom clearance so the fixed support-chat FAB never
// overlaps page content, even at the bottom of a scrolled page.
paddingBottom: 112,
}}
>
{children}
</AppShell.Main>
{/* Floating customer-support chat launcher. Hidden when the caller opts
out: support chat resolves the user's external profile → company, and
a shipping line has neither, so every poll would 403. */}
{showSupportWidget && <SupportWidget />}
{/* 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={
isSuspendedAppeal
? `Appeal suspension — ${serviceLabel(createTarget)}`
: reapplyId
? `Resubmit your ${serviceLabel(createTarget)} service`
: `Set up your ${serviceLabel(createTarget)} profile`
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{isSuspendedAppeal
? `Your ${serviceLabel(
createTarget,
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
: reapplyId
? `Your ${serviceLabel(
createTarget,
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
: `You don't have a ${serviceLabel(
createTarget,
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
</Text>
{isSuspendedAppeal && reapplyNote && (
<Alert
color="orange"
variant="light"
icon={<Ban size={16} />}
title="Reason for suspension"
>
{reapplyNote}
</Alert>
)}
<FileInput
label={
reapplyId ? "Business license (optional)" : "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}
>
{isSuspendedAppeal
? "Submit appeal"
: reapplyId
? "Resubmit"
: "Create"}
</Button>
</Group>
</Stack>
</Modal>
</AppShell>
);
}
export default AppLayout;