diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx new file mode 100644 index 000000000..0195efe11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx @@ -0,0 +1,353 @@ +import type { ReactNode } from "react"; +import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Inbox, + LayoutList, + Plus, + RefreshCw, +} from "lucide-react"; + +import { freightBrand } from "@/theme/freight-brand"; +import type { + BookingListSummaryMetrics, + BookingListSummaryTabs, +} from "@/services/bookings.service"; + +const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; + +/** Lifecycle stages for the pipeline distribution bar (in flow order). */ +const PIPELINE_STAGES: Array<{ + key: keyof BookingListSummaryTabs; + label: string; + color: string; +}> = [ + { key: "intake", label: "Intake", color: "#38bdf8" }, + { key: "in_approval", label: "Approval", color: "#fbbf24" }, + { key: "approved_contract", label: "Contract", color: "#a78bfa" }, + { key: "payment", label: "Payment", color: "#fb923c" }, + { key: "operations", label: "Operations", color: "#2dd4bf" }, + { key: "completed", label: "Completed", color: "#86efac" }, +]; + +export interface BookingRequestsHeaderProps { + metrics?: BookingListSummaryMetrics; + tabs?: BookingListSummaryTabs; + loading?: boolean; + isFetching?: boolean; + onCreate: () => void; + onRefresh: () => void; +} + +export function BookingRequestsHeader({ + metrics, + tabs, + loading, + isFetching, + onCreate, + onRefresh, +}: BookingRequestsHeaderProps) { + const val = (n?: number) => (loading ? "—" : (n ?? 0)); + + return ( + + {/* decorative glows */} + + + + + + + + + + + + Operations + + + Booking Requests + + + Track every booking from submission through approval, payment, and + dispatch — prioritize what needs action. + + + + + + + + + + + + + + + + + {tabs ? : null} + + + ); +} + +/** Compact ring gauge with the stat icon at its center. */ +function MiniDonut({ + pct, + color = "white", + children, + size = 52, + stroke = 5, +}: { + pct?: number | null; + color?: string; + children: ReactNode; + size?: number; + stroke?: number; +}) { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = + pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; + const dash = clamped != null ? (clamped / 100) * circumference : 0; + + return ( + + + + {clamped != null ? ( + + ) : null} + + + {children} + + + ); +} + +function HeroStat({ + icon: Icon, + label, + value, + hint, + ratio, + ratioColor = "white", +}: { + icon: LucideIcon; + label: string; + value: ReactNode; + hint?: string; + ratio?: number; + ratioColor?: string; +}) { + const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null; + return ( + + + + + + + + {label} + + + {value} + + + {pct != null ? `${pct}% of queue` : hint} + + + + + ); +} + +function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { + const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 })); + const total = segments.reduce((sum, s) => sum + s.count, 0); + + return ( + + + + Booking pipeline + + + {total} active + + + + + {total > 0 ? ( + segments.map((s) => + s.count > 0 ? ( + + ) : null, + ) + ) : ( + + )} + + + + {segments.map((s) => ( + + + + {s.label} + + + {s.count} + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx index 854e121ce..1fbb859af 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx @@ -19,6 +19,7 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar {approvedCount} / {steps.length} approved diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index 90e26bed4..b0a01f625 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -15,7 +15,7 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; return ( - + {containers.length} line{containers.length === 1 ? "" : "s"} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx index aebb0f52f..1e86a7d2a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx @@ -10,7 +10,7 @@ export interface BookingContractSummaryCardProps { /** Generated contract terms, shown verbatim. */ export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) { return ( - + {files.length} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx index 24042c18f..8d82f51e2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx @@ -43,7 +43,7 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) { ]; return ( - + {facts.map((fact, index) => (
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 2b09618df..3236e5cae 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -17,7 +17,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp } return ( - + {booking.firstMilePickupAddress && ( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index 193ea5daa..67a7fbc52 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -1,13 +1,28 @@ -import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react"; -import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core"; +import type { ReactNode } from "react"; +import { + ArrowLeft, + Building2, + Calendar, + Clock, + Container as ContainerIcon, + Flame, + RefreshCw, + Wallet, + Weight, +} from "lucide-react"; +import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; +import { freightBrand } from "@/theme/freight-brand"; -import { detailStyles, formatDate } from "./booking-detail.styles"; +import { formatDate } from "./booking-detail.styles"; + +const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; export interface BookingRequestHeroProps { booking: BookingDetail; @@ -17,7 +32,7 @@ export interface BookingRequestHeroProps { isFetching?: boolean; } -/** Top hero for the request detail page: identity, status, next step, total value. */ +/** Top hero for the request detail page: identity, status, next step, key figures. */ export function BookingRequestHero({ booking, customerLabel, @@ -26,93 +41,198 @@ export function BookingRequestHero({ isFetching, }: BookingRequestHeroProps) { const amount = Number(booking.totalAmount); + const containers = booking.bookingContainers ?? []; + const containerCount = containers.reduce( + (sum, c) => sum + Number(c.quantity ?? 0), + 0, + ); + const weight = Number(booking.cargoTotalWeightVgm ?? 0); return ( - - + + - - - - Booking reference - - - - {booking.reference} - - - - {booking.schedulingStatus ? ( - - ) : null} - - {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} - - ) : null} - - {booking.nextStep && ( - - - - )} - - - - - - {customerLabel} - - - - - - Scheduled {booking.scheduledDate} - - - - - - Created {formatDate(booking.createdAt)} - - - - - - - - - Total value - - - {booking.paymentCurrency}{" "} - {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} - - - {booking.paymentStatus} - - + + + + + + + + + Booking reference + + + + {booking.reference} + + + + {booking.schedulingStatus ? ( + + ) : null} + + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + + ) : null} + + + + + + + + + + {booking.nextStep ? ( + + + + ) : null} + + + + + + + + + + ); +} + +function MetaItem({ + icon: Icon, + text, + strong, +}: { + icon: LucideIcon; + text: ReactNode; + strong?: boolean; +}) { + return ( + + + + {text} + + + ); +} + +function HeroTile({ + icon: Icon, + label, + value, + hint, +}: { + icon: LucideIcon; + label: string; + value: ReactNode; + hint?: ReactNode; +}) { + return ( + + + + + + + + {label} + + + {value} + + {hint ? ( + + {hint} + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx index 6f2f1f76f..218f0c7c5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx @@ -12,7 +12,7 @@ export interface BookingReviewNotesCardProps { export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) { if (notes.length === 0) { return ( - + No review notes have been added yet. diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx index 964d83803..05fa58100 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx @@ -10,7 +10,7 @@ export interface BookingRouteCardProps { export function BookingRouteCard({ booking }: BookingRouteCardProps) { return ( - + {/* Origin */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index ea2aa491e..a4976ec61 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -63,7 +63,7 @@ export function BookingRouteServiceCard({ ]; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx index 1eadf24bc..1756600c9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx @@ -7,20 +7,68 @@ import { detailStyles } from "./booking-detail.styles"; export interface SectionCardProps { icon: LucideIcon; title: string; + /** Optional one-line context shown under the title. */ + subtitle?: string; + /** Mantine palette key used to tint the icon chip + top accent (default green). */ + accent?: string; extra?: ReactNode; children: ReactNode; } -/** Consistent flat card with a minimal icon + title header used by every detail section. */ -export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) { +/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */ +export function SectionCard({ + icon: Icon, + title, + subtitle, + accent = "green", + extra, + children, +}: SectionCardProps) { return ( - - - - - - {title} - + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + {extra} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css new file mode 100644 index 000000000..f360d4139 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css @@ -0,0 +1,126 @@ +/* ============================================================ + EDR Freight — Header styles + ============================================================ */ + +.fdh-root { + display: flex; + height: 80px; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 22px; +} + +/* eyebrow above the page title */ +.fdh-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #15803d; + margin-bottom: 3px; +} +.fdh-eyebrow-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16); +} + +/* action icon buttons */ +.fdh-icon-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: #f7f9fb; + border: 1px solid #eef1f4; + color: #475569; + cursor: pointer; + transition: all 160ms ease; +} +.fdh-icon-btn:hover { + background: #ffffff; + border-color: rgba(21, 128, 61, 0.28); + color: #15803d; + box-shadow: 0 4px 12px -4px rgba(21, 128, 61, 0.28); + transform: translateY(-1px); +} +.fdh-icon-btn:active { + transform: translateY(0); +} + +/* notification badge */ +.fdh-badge { + position: absolute; + top: -5px; + right: -5px; + min-width: 17px; + height: 17px; + padding: 0 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); + color: #ffffff; + font-size: 10px; + font-weight: 700; + line-height: 1; + border: 2px solid #ffffff; + box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45); +} + +.fdh-divider { + width: 1px; + height: 30px; + background: #e9eef3; + margin: 0 2px; +} + +/* user button */ +.fdh-user { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 12px 5px 5px; + border-radius: 13px; + cursor: pointer; + border: 1px solid transparent; + transition: all 160ms ease; +} +.fdh-user:hover { + background: #f7f9fb; + border-color: #eef1f4; +} + +.fdh-avatar-ring { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: 2px; + background: linear-gradient(135deg, #22c55e 0%, #15803d 100%); + box-shadow: 0 4px 10px -3px rgba(21, 128, 61, 0.4); +} +.fdh-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: linear-gradient(135deg, #16a34a 0%, #166534 100%); + color: #ffffff; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.3px; + border: 2px solid #ffffff; +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index ba69363d4..14336e70a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -9,10 +9,10 @@ import { Sun, User, } from "lucide-react"; -import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core"; +import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core"; import type { PageMeta } from "./types"; -import { freightBrand } from "@/theme/freight-brand"; +import "./FreightDashboardHeader.css"; export interface FreightDashboardHeaderProps { pageMeta: PageMeta; @@ -39,12 +39,13 @@ const FreightDashboardHeader = ({ }: FreightDashboardHeaderProps) => { const initials = userInitials ?? - userName + (userName .split(" ") .filter(Boolean) .slice(0, 2) .map((n) => n[0].toUpperCase()) - .join(""); + .join("") || + "U"); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const userMenuRef = useRef(null); @@ -74,133 +75,137 @@ const FreightDashboardHeader = ({ }, [isUserMenuOpen]); return ( -
- - +
+ + + + Freight Backoffice + + {pageMeta.title} - + {pageMeta.subtitle} - + {enableThemeToggle && ( - - {theme === "dark" ? : } - + + )} - - - + + + - - - - + + + - - - - + + + - setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}> +
+ + setIsUserMenuOpen(true)} + onClose={() => setIsUserMenuOpen(false)} + > - - - - - - - - - +
+
+
{initials}
+
+ + {userName} - {userEmail && ( - - {userEmail} - - )} + + {userEmail ?? "Administrator"} + - + +
+ + + + +
+
{initials}
+
+ + + {userName} + + {userEmail && ( + + {userEmail} + + )} + +
+
} + leftSection={} onClick={() => setIsUserMenuOpen(false)} > Profile } + leftSection={} color="red" onClick={() => { setIsUserMenuOpen(false); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css new file mode 100644 index 000000000..961afb39c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css @@ -0,0 +1,304 @@ +/* ============================================================ + EDR Freight — Sidebar styles + Polished, professional navigation surface. + ============================================================ */ + +.fsb-aside { + height: 100%; + max-height: 100%; + width: 280px; + flex-shrink: 0; + border-radius: 16px; + border: 1px solid #eef1f4; + background: #ffffff; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 24px -16px rgba(15, 23, 42, 0.12); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ---- Brand header ---- */ +.fsb-brand { + position: relative; + display: flex; + align-items: center; + gap: 12px; + height: 80px; + padding: 0 20px; + flex-shrink: 0; + border-bottom: 1px solid #f1f5f9; + overflow: hidden; +} + +.fsb-brand::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%); + pointer-events: none; +} + +.fsb-logo { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 13px; + background: linear-gradient(135deg, #22c55e 0%, #15803d 60%, #166534 100%); + box-shadow: + 0 6px 16px -4px rgba(21, 128, 61, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.25); + flex-shrink: 0; +} + +/* ---- Nav scroll region ---- */ +.fsb-nav { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 14px 12px 12px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.fsb-nav::-webkit-scrollbar { + width: 6px; +} +.fsb-nav::-webkit-scrollbar-thumb { + background: #e2e8f0; + border-radius: 3px; +} +.fsb-nav::-webkit-scrollbar-thumb:hover { + background: #cbd5e1; +} +.fsb-nav::-webkit-scrollbar-track { + background: transparent; +} + +.fsb-section-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.7px; + text-transform: uppercase; + color: #94a3b8; + padding: 0 12px; + margin-bottom: 6px; +} + +/* ---- Top-level item ---- */ +.fsb-item { + position: relative; + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 9px 12px; + border-radius: 11px; + cursor: pointer; + color: #475569; + font-size: 14px; + font-weight: 500; + line-height: 1.2; + text-align: left; + text-decoration: none; + transition: + background-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +.fsb-item:hover { + background-color: #f5f7fa; + color: #0f172a; +} + +.fsb-item[data-active="true"] { + background: linear-gradient( + 135deg, + rgba(34, 197, 94, 0.12) 0%, + rgba(21, 128, 61, 0.06) 100% + ); + color: #15803d; + font-weight: 600; +} + +.fsb-item[data-active="true"]::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 22px; + border-radius: 0 4px 4px 0; + background: linear-gradient(180deg, #22c55e 0%, #15803d 100%); +} + +.fsb-item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- Icon well ---- */ +.fsb-icon { + display: flex; + align-items: center; + justify-content: center; + width: 31px; + height: 31px; + border-radius: 9px; + flex-shrink: 0; + background: #f1f5f9; + color: #64748b; + transition: all 160ms ease; +} + +.fsb-item:hover .fsb-icon { + background: #e6ebf1; + color: #334155; +} + +.fsb-item[data-active="true"] .fsb-icon { + background: linear-gradient(135deg, #22c55e 0%, #15803d 100%); + color: #ffffff; + box-shadow: 0 5px 12px -2px rgba(21, 128, 61, 0.45); +} + +.fsb-chevron { + flex-shrink: 0; + color: #94a3b8; + transition: transform 220ms ease; +} + +/* ---- Nested branch ---- */ +.fsb-branch { + margin: 2px 0 2px 22px; + padding-left: 12px; + border-left: 1.5px solid #eef2f6; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* group header (non-navigable) */ +.fsb-group { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + background: transparent; + transition: background-color 150ms ease; +} +.fsb-group:hover { + background-color: #f5f7fa; +} +.fsb-group-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #94a3b8; +} +.fsb-group[data-active="true"] .fsb-group-label { + color: #15803d; +} + +/* child leaf */ +.fsb-child { + position: relative; + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + color: #64748b; + font-size: 13px; + font-weight: 500; + text-decoration: none; + transition: + background-color 150ms ease, + color 150ms ease; +} +.fsb-child:hover { + background-color: #f5f7fa; + color: #0f172a; +} +.fsb-child[data-active="true"] { + color: #15803d; + font-weight: 600; + background-color: rgba(21, 128, 61, 0.08); +} + +.fsb-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: #cbd5e1; + transition: all 150ms ease; +} +.fsb-child:hover .fsb-dot { + background: #94a3b8; +} +.fsb-child[data-active="true"] .fsb-dot { + background: #15803d; + box-shadow: 0 0 0 3px rgba(21, 128, 61, 0.16); +} + +/* ---- Footer status card ---- */ +.fsb-footer { + flex-shrink: 0; + padding: 12px; + border-top: 1px solid #f1f5f9; +} +.fsb-status { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 11px; + background: linear-gradient(135deg, #f0fdf4 0%, #f8fafc 100%); + border: 1px solid #e7f3ec; +} +.fsb-pulse { + position: relative; + width: 9px; + height: 9px; + border-radius: 50%; + background: #22c55e; + flex-shrink: 0; +} +.fsb-pulse::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: #22c55e; + animation: fsb-pulse 2s ease-out infinite; +} +@keyframes fsb-pulse { + 0% { + transform: scale(1); + opacity: 0.6; + } + 100% { + transform: scale(2.6); + opacity: 0; + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 35d43e946..c33d9e269 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -1,15 +1,16 @@ import { type MouseEvent, + type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; -import { ChevronDown, ChevronRight, Train } from "lucide-react"; -import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core"; +import { ChevronDown, Train } from "lucide-react"; +import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; -import { freightBrand } from "@/theme/freight-brand"; +import "./FreightSidebar.css"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -105,7 +106,7 @@ const FreightSidebar = ({ children: SidebarItem[], depth: number, parentKey: string, - ) => + ): ReactNode => children.map((child) => { const key = sidebarItemKey(child, parentKey); const isGroup = Boolean(child.children?.length) && !child.href; @@ -115,87 +116,50 @@ const FreightSidebar = ({ const groupActive = branchContainsActive(child.children!); return ( - - + {isOpen && ( - +
{renderNavBranch(child.children!, depth + 1, key)} - +
)} -
+
); } if (!child.href) return null; - const childHref = child.href.toLowerCase(); - const childActiveHref = isHrefActive(childHref); + const childActive = isHrefActive(child.href); return ( - navigateTo(e as any, child.href!)} - label={child.label} - active={childActiveHref} - color="green" - style={{ - borderRadius: "8px", - cursor: "pointer", - fontSize: "14px", - }} - rightSection={} - /> + className="fsb-child" + data-active={childActive} + onClick={(e) => navigateTo(e, child.href!)} + > + + {child.label} + ); }); - const renderIconWell = (icon: React.ReactNode, active: boolean) => { - if (!icon) return null; - return ( - - {icon} - - ); - }; - const renderTopLevelItem = (item: SidebarItem) => { if (!item.href) return null; @@ -207,132 +171,92 @@ const FreightSidebar = ({ const isCurrentItem = hasChildren ? activePath === itemHref : isHrefActive(itemHref); - const isSectionActive = childActive && !isCurrentItem; - const isActive = isCurrentItem || isSectionActive; + const isActive = isCurrentItem || childActive; const isOpen = expanded[item.href] ?? false; - const leafActive = isCurrentItem && !hasChildren; return ( - - + navigateTo(e as any, item.href!)} - label={item.label} - leftSection={renderIconWell(item.icon, isActive)} - active={leafActive} - color="green" - variant="light" - style={{ - borderRadius: "10px", - cursor: "pointer", - fontSize: "14px", - fontWeight: 500, - padding: "8px 10px", - }} - rightSection={ - hasChildren ? ( - { - e.preventDefault(); - toggleExpanded(item.href!); - }} - /> - ) : ( - - ) - } - /> + className="fsb-item" + data-active={isActive} + onClick={(e) => navigateTo(e, item.href!)} + > + {item.icon && {item.icon}} + {item.label} + {hasChildren && ( + { + e.preventDefault(); + e.stopPropagation(); + toggleExpanded(item.href!); + }} + /> + )} + {hasChildren && isOpen && ( - +
{renderNavBranch(item.children!, 0, item.href)} - +
)} -
+ ); }; return ( - - - - - - - + +
+
+ +
+ + EDR Freight - - Backoffice + + Backoffice Console - +
- + + +
+
+ + + + All systems operational + + + EDR Platform · v1.0 + + +
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx index bdbfc8850..25e63b163 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx @@ -1,6 +1,12 @@ +import { useId } from "react"; import type { LucideIcon } from "lucide-react"; import { Card, Group, Stack, Text } from "@mantine/core"; +import { + overviewAccentGradients, + type OverviewAccent, +} from "./overview.styles"; + const accentColors = { default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" }, amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" }, @@ -9,18 +15,95 @@ const accentColors = { sky: { bg: "var(--mantine-color-blue-1)", color: "var(--mantine-color-blue-6)" }, }; +const ACCENT_TINT: Record = { + default: "#f8fafc", + amber: "#fffbeb", + emerald: "#f0fdf4", + rose: "#fff1f2", + sky: "#f0f9ff", +}; + export interface OverviewKpiItem { label: string; value: number | string; hint?: string; icon: LucideIcon; accent?: keyof typeof accentColors; + /** Share 0..1 used to fill the radial gauge. Auto-computed by the strip when omitted. */ + progress?: number; +} + +/** Radial gauge with the KPI icon at its center. */ +function KpiGauge({ + progress, + accent, + Icon, +}: { + progress: number; + accent: OverviewAccent; + Icon: LucideIcon; +}) { + const gradientId = useId(); + const size = 76; + const stroke = 9; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = Math.min(1, Math.max(0, progress)); + const dash = clamped * circumference; + const [from, to] = overviewAccentGradients[accent] ?? overviewAccentGradients.default; + + return ( +
+ + + + + + + + + + +
+ +
+
+ ); } export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) { const Icon = item.icon; const accent = item.accent ?? "default"; - const accentStyle = accentColors[accent]; + const accentKey = (accent === "emerald" ? "emerald" : accent) as OverviewAccent; + const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default; + const progress = item.progress ?? 0; + const pct = Math.round(Math.min(1, Math.max(0, progress)) * 100); return ( { + e.currentTarget.style.transform = "translateY(-3px)"; + e.currentTarget.style.boxShadow = "0 12px 28px -12px rgba(15,23,42,0.22)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = "translateY(0)"; + e.currentTarget.style.boxShadow = "none"; }} > - - - +
+ + + {item.label} - + {item.value} - {item.hint && ( - - {item.hint} + + + + {item.hint ?? (item.progress != null ? `${pct}% of peak` : "")} - )} + -
- -
+
); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx index e4f388c8f..0923eb6e8 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx @@ -7,26 +7,41 @@ interface OverviewKpiStripProps { items: OverviewKpiItem[]; } +/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */ +function toNumber(value: number | string): number { + if (typeof value === "number") return value; + const parsed = Number(String(value).replace(/[^0-9.-]/g, "")); + return Number.isFinite(parsed) ? parsed : 0; +} + export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) { + const max = Math.max(...items.map((item) => toNumber(item.value)), 0); + return ( {title && ( - + {title} )} - + {items.map((item) => ( - + 0 ? toNumber(item.value) / max : 0), + }} + /> ))} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx index 6e261e615..e9f90afd4 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -1,7 +1,17 @@ -import { ActionIcon, Group, SegmentedControl, Stack, Text, Title } from "@mantine/core"; -import { RefreshCw } from "lucide-react"; +import { + ActionIcon, + Box, + Group, + SegmentedControl, + Stack, + Text, + Title, +} from "@mantine/core"; +import { Activity, RefreshCw } from "lucide-react"; +import { freightBrand } from "@/theme/freight-brand"; import type { OverviewRange } from "@/types/overview"; +import "./overview.css"; const RANGE_OPTIONS = [ { label: "7 days", value: "7d" }, @@ -9,6 +19,8 @@ const RANGE_OPTIONS = [ { label: "90 days", value: "90d" }, ]; +const HERO_GRADIENT = `linear-gradient(125deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 50%, ${freightBrand.primaryLight} 125%)`; + function formatRelativeTime(iso: string | undefined) { if (!iso) return "—"; const diffMs = Date.now() - new Date(iso).getTime(); @@ -20,6 +32,51 @@ function formatRelativeTime(iso: string | undefined) { return new Date(iso).toLocaleString(); } +/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */ +function TrainArtwork() { + return ( + + + {/* rails */} + + + {/* locomotive body */} + + {/* cab windows */} + + + + {/* lower stripe */} + + {/* wheels */} + + + + {/* coupling */} + + {/* headlight beam */} + + + + ); +} + interface OverviewPageHeaderProps { range: OverviewRange; onRangeChange: (range: OverviewRange) => void; @@ -36,34 +93,85 @@ export function OverviewPageHeader({ isRefreshing, }: OverviewPageHeaderProps) { return ( - - - - Operations overview - - - Updated {formatRelativeTime(generatedAt)} - - + + {/* decorative glows */} + + - - onRangeChange(value as OverviewRange)} - data={RANGE_OPTIONS} - size="sm" - /> - - - + + + + + + + + Freight Backoffice · Live + + + + Operations Overview + + + Real-time freight performance · updated {formatRelativeTime(generatedAt)} + + + + + onRangeChange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + classNames={{ + root: "ov-seg-root", + indicator: "ov-seg-indicator", + label: "ov-seg-label", + }} + /> + + + + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css new file mode 100644 index 000000000..148ba0613 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -0,0 +1,57 @@ +/* ============================================================ + EDR Freight — Overview page styles (hero controls + tabs) + ============================================================ */ + +/* ---- Hero range segmented control (on gradient) ---- */ +.ov-seg-root { + background: rgba(255, 255, 255, 0.18) !important; + border: 1px solid rgba(255, 255, 255, 0.28); +} +.ov-seg-indicator { + background: #ffffff !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); +} +.ov-seg-label { + color: rgba(255, 255, 255, 0.9); + font-weight: 600; +} +.ov-seg-label[data-active] { + color: #15803d; +} + +/* ---- Premium tab bar ---- */ +.ov-tablist { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px; + background: #f1f5f9; + border-radius: 16px; + border: 1px solid #e2e8f0; +} +.ov-tab { + border-radius: 11px; + padding: 10px 18px; + font-weight: 600; + color: #475569; + border: 1px solid transparent; + transition: + background-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} +.ov-tab:hover { + background: #ffffff; + color: #0f172a; + box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18); +} +.ov-tab[data-active] { + background: linear-gradient(135deg, #22c55e 0%, #15803d 100%) !important; + color: #ffffff !important; + box-shadow: 0 10px 20px -8px rgba(21, 128, 61, 0.55); + transform: translateY(-1px); +} +.ov-tab[data-active]:hover { + color: #ffffff; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts index 81758126e..7654da0a2 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts @@ -7,17 +7,33 @@ export const overviewChartColors = { muted: freightBrand.mutedBg, etb: freightBrand.primary, usd: "#0369a1", + /** Vibrant, well-separated categorical palette for charts. */ pipeline: [ - freightBrand.primary, - "#22c55e", - "#0ea5e9", - "#6366f1", - "#f59e0b", - "#14b8a6", - "#64748b", + "#16a34a", // green + "#0ea5e9", // sky + "#8b5cf6", // violet + "#f59e0b", // amber + "#14b8a6", // teal + "#ec4899", // pink + "#f43f5e", // rose + "#6366f1", // indigo + "#eab308", // yellow + "#06b6d4", // cyan ], } as const; +/** Two-stop gradients keyed by KPI accent — used for radial gauges + accent bars. */ +export const overviewAccentGradients = { + default: ["#94a3b8", "#475569"], + emerald: ["#34d399", "#059669"], + amber: ["#fbbf24", "#d97706"], + rose: ["#fb7185", "#e11d48"], + sky: ["#38bdf8", "#0284c7"], + violet: ["#a78bfa", "#7c3aed"], +} as const; + +export type OverviewAccent = keyof typeof overviewAccentGradients; + export const overviewCardStyle = { background: "white", border: "1px solid var(--mantine-color-gray-2)", diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index fabac4abf..c294a5199 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -29,29 +29,34 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps value: data.kpis.totalActive, icon: FileText, accent: "emerald", + hint: "Currently in workflow", }, { label: "Needs action", value: data.kpis.needsAction, icon: AlertCircle, accent: "amber", + hint: "Awaiting your review", }, { label: "Urgent", value: data.kpis.urgent, icon: Clock, accent: "rose", + hint: "High priority queue", }, { label: "In approval", value: data.kpis.inApproval, icon: UserCheck, accent: "sky", + hint: "Pending sign-off", }, { label: "Submitted today", value: data.kpis.submittedToday, icon: FileText, + hint: "New since midnight", }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 922a70821..abf9649f3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,20 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - AlertCircle, - ArrowRight, - Calendar, - Clock, - FileText, - Inbox, - LayoutList, - Package, - Plus, - RefreshCw, - Search, - User, - X, -} from "lucide-react"; +import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react"; import { Container, Stack, @@ -33,7 +19,7 @@ import { BookingStatusTabs, type BookingStatusTabKey, } from "@/components/bookings/BookingStatusTabs"; -import { BookingStatGrid } from "@/components/bookings/BookingStatGrid"; +import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; @@ -58,8 +44,6 @@ import { type ColumnDef, usePagination, Badge, - Button, - Input, } from "@edr/ui-common"; function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { @@ -159,8 +143,6 @@ export default function BookingRequestsPage() { const metrics = summary?.metrics; const tabCounts = summary?.tabs; - const statValue = (value: number | undefined) => - summaryLoading ? "—" : (value ?? 0); const handleRefresh = useCallback(() => { void refetch(); @@ -315,88 +297,15 @@ export default function BookingRequestsPage() {
-{/* - - - - - - - - - Operations - - - Booking Requests - - - Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals. - - - - } - disabled={isFetching} - onClick={handleRefresh} - loading={isFetching} - > - Refresh - - - */} -
- - 0 - ? "amber" - : "default", - }, - { - label: "Urgent", - value: statValue(metrics?.urgent), - hint: "High priority score", - icon: AlertCircle, - accent: - !summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default", - }, - ]} + + navigate("/dashboard/booking-requests/new")} + onRefresh={handleRefresh} /> - - - - {total} record{total !== 1 ? "s" : ""} - - + + {total} record{total !== 1 ? "s" : ""} + {isOperationsTab ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 4d05930ac..0c0d848c2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks"; import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; +import "@/components/overview/overview.css"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useOverview } from "@/hooks/useOverview"; import type { OverviewRange, OverviewTabKey } from "@/types/overview"; @@ -127,60 +128,53 @@ const OverviewPage = () => { )} - setActiveTab((value as OverviewTabKey) ?? "bookings")} + variant="pills" + color="green" + keepMounted={false} + classNames={{ list: "ov-tablist", tab: "ov-tab" }} > - setActiveTab((value as OverviewTabKey) ?? "bookings")} - variant="pills" - color="green" - keepMounted={false} - > - - {TAB_ITEMS.map((tab) => { - const Icon = tab.icon; - return ( - } - rightSection={ - summary ? ( - - {getTabBadge(tab)} - - ) : undefined - } - style={{ fontWeight: 600 }} - > - {tab.label} - - ); - })} - + + {TAB_ITEMS.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.value; + return ( + } + rightSection={ + summary ? ( + + {getTabBadge(tab)} + + ) : undefined + } + > + {tab.label} + + ); + })} + - {TAB_ITEMS.map((tab) => ( - - - - ))} - - + {TAB_ITEMS.map((tab) => ( + + + + ))} +