From 20289621f3e9b3a20e1d495357baab8857e74478 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 11 Jun 2026 08:19:16 +0000 Subject: [PATCH] fix ui --- .../bookings/BookingRequestsHeader.tsx | 149 +--- .../bookings/detail/BookingRequestHero.tsx | 83 +- .../overview/OverviewPageHeader.tsx | 74 +- .../trainScheduling/scheduleVisuals.tsx | 2 +- .../src/pages/bookings/NewBookingPage.tsx | 842 ++++++++++++++---- .../TrainScheduleV2DetailPage.tsx | 48 +- .../TrainScheduleV2ListPage.tsx | 63 +- 7 files changed, 799 insertions(+), 462 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx index 0195efe11..3d6eeb398 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx @@ -11,14 +11,11 @@ import { 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; @@ -26,13 +23,18 @@ const PIPELINE_STAGES: Array<{ color: string; }> = [ { key: "intake", label: "Intake", color: "#38bdf8" }, - { key: "in_approval", label: "Approval", color: "#fbbf24" }, - { key: "approved_contract", label: "Contract", color: "#a78bfa" }, + { key: "in_approval", label: "Approval", color: "#f59e0b" }, + { key: "approved_contract", label: "Contract", color: "#8b5cf6" }, { key: "payment", label: "Payment", color: "#fb923c" }, - { key: "operations", label: "Operations", color: "#2dd4bf" }, - { key: "completed", label: "Completed", color: "#86efac" }, + { key: "operations", label: "Operations", color: "#14b8a6" }, + { key: "completed", label: "Completed", color: "#22c55e" }, ]; +const CARD_STYLE = { + background: "var(--mantine-color-gray-0)", + border: "1px solid var(--mantine-color-gray-2)", +} as const; + export interface BookingRequestsHeaderProps { metrics?: BookingListSummaryMetrics; tabs?: BookingListSummaryTabs; @@ -59,74 +61,40 @@ export function BookingRequestsHeader({ style={{ position: "relative", overflow: "hidden", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - {/* decorative glows */} - - - - + - + Operations - + <Title order={2} fw={700} style={{ color: "#0f172a" }}> Booking Requests - + Track every booking from submission through approval, payment, and dispatch — prioritize what needs action. - @@ -134,19 +102,14 @@ export function BookingRequestsHeader({ - + - + {tabs ? : null} @@ -173,36 +131,31 @@ export function BookingRequestsHeader({ /** Compact ring gauge with the stat icon at its center. */ function MiniDonut({ pct, - color = "white", + color, children, size = 52, stroke = 5, }: { pct?: number | null; - color?: string; + 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 clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; const dash = clamped != null ? (clamped / 100) * circumference : 0; return ( - + {clamped != null ? ( @@ -226,7 +179,7 @@ function MiniDonut({ display: "flex", alignItems: "center", justifyContent: "center", - color: "white", + color, }} > {children} @@ -241,7 +194,7 @@ function HeroStat({ value, hint, ratio, - ratioColor = "white", + ratioColor = "var(--mantine-color-green-6)", }: { icon: LucideIcon; label: string; @@ -252,29 +205,19 @@ function HeroStat({ }) { const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null; return ( - + - + {label} - + {value} - + {pct != null ? `${pct}% of queue` : hint} @@ -288,20 +231,12 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { const total = segments.reduce((sum, s) => sum + s.count, 0); return ( - + - + Booking pipeline - + {total} active @@ -312,7 +247,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { height: 14, borderRadius: 999, overflow: "hidden", - background: "rgba(255,255,255,0.18)", + background: "var(--mantine-color-gray-2)", gap: 2, }} > @@ -322,11 +257,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { ) : null, ) @@ -339,10 +270,10 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { {segments.map((s) => ( - + {s.label} - + {s.count} 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 67a7fbc52..e396ec4c2 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 @@ -10,7 +10,7 @@ import { Wallet, Weight, } from "lucide-react"; -import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; @@ -18,12 +18,9 @@ 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 { 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; customerLabel: string; @@ -55,40 +52,25 @@ export function BookingRequestHero({ style={{ position: "relative", overflow: "hidden", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - - } onClick={onBack} - style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.25)" }} > Back to list } @@ -101,11 +83,11 @@ export function BookingRequestHero({ - + Booking reference - + {booking.reference} @@ -116,7 +98,7 @@ export function BookingRequestHero({ {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} ) : null} @@ -130,7 +112,12 @@ export function BookingRequestHero({ {booking.nextStep ? ( - + ) : null} @@ -143,19 +130,22 @@ export function BookingRequestHero({ minimumFractionDigits: 2, })}`} hint={booking.paymentStatus} + accent="green" /> - + @@ -174,8 +164,8 @@ function MetaItem({ }) { return ( - - + + {text} @@ -187,11 +177,13 @@ function HeroTile({ label, value, hint, + accent = "green", }: { icon: LucideIcon; label: string; value: ReactNode; hint?: ReactNode; + accent?: string; }) { return ( - + - + - + {label} - + {value} {hint ? ( - + {hint} ) : null} 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 e9f90afd4..dbea71656 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -5,13 +5,12 @@ import { SegmentedControl, Stack, Text, + ThemeIcon, 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" }, @@ -19,8 +18,6 @@ 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(); @@ -32,7 +29,7 @@ function formatRelativeTime(iso: string | undefined) { return new Date(iso).toLocaleString(); } -/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */ +/** Decorative line-art locomotive + rails — faint brand tint on the right. */ function TrainArtwork() { return ( - {/* rails */} - {/* locomotive body */} - - {/* cab windows */} + - {/* lower stripe */} - {/* wheels */} - {/* coupling */} - {/* headlight beam */} @@ -98,50 +84,28 @@ export function OverviewPageHeader({ position: "relative", overflow: "hidden", borderRadius: 20, - padding: "28px 28px", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + padding: "26px 28px", + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - {/* decorative glows */} - - - - - + + + + Freight Backoffice · Live - + <Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}> Operations Overview - + Real-time freight performance · updated {formatRelativeTime(generatedAt)} @@ -153,14 +117,10 @@ export function OverviewPageHeader({ data={RANGE_OPTIONS} size="sm" radius="lg" - classNames={{ - root: "ov-seg-root", - indicator: "ov-seg-indicator", - label: "ov-seg-label", - }} + color="green" /> ; - service?: Array<{ id: string; name: string; code: string }>; - containers?: Array<{ - size: string; - types: Array<{ id: string; name: string; code: string }>; - }>; - cargo_type?: Array<{ id: string; name: string; code: string }>; + yard?: RefNamed[]; + service?: RefNamed[]; + shipping_line?: RefNamed[]; + containers?: RefContainerGroup[]; + cargo_type?: RefCargoGroup[]; +} + +interface ContainerLine { + key: string; + containerTypeId: string | null; + quantity: number; + vgmPerUnitTons: number; +} + +let lineCounter = 0; +const newLine = (): ContainerLine => ({ + key: `line-${lineCounter++}`, + containerTypeId: null, + quantity: 1, + vgmPerUnitTons: 20, +}); + +const fmtTons = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`; + +/** Section card with a colored icon chip header. */ +function FormSection({ + icon: Icon, + title, + subtitle, + accent = "green", + right, + children, +}: { + icon: typeof Package; + title: string; + subtitle?: string; + accent?: string; + right?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {right} + + {children} + + ); } export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + const [isGovernment, setIsGovernment] = useState(false); const [governmentInstitution, setGovernmentInstitution] = useState(""); - const [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER"); + const [companyId, setCompanyId] = useState(null); + const [freightType, setFreightType] = useState("CONTAINER"); const [originYardId, setOriginYardId] = useState(null); const [destinationYardId, setDestinationYardId] = useState(null); const [serviceTypeId, setServiceTypeId] = useState(null); const [scheduledDate, setScheduledDate] = useState(""); - const [weight, setWeight] = useState(100); - const [containerTypeId, setContainerTypeId] = useState(null); + const [tradeDirection, setTradeDirection] = useState("IMPORT"); + const [paymentCurrency, setPaymentCurrency] = useState("ETB"); + + // container freight + const [lines, setLines] = useState([newLine()]); + + // bulk freight const [cargoTypeId, setCargoTypeId] = useState(null); + const [cargoFreeText, setCargoFreeText] = useState(""); + const [bulkWeight, setBulkWeight] = useState(100); + + // extra options + const [equipmentReturn, setEquipmentReturn] = useState("NA"); + const [isHazardous, setIsHazardous] = useState(false); + const [shippingLineId, setShippingLineId] = useState(null); + const [firstMilePickupAddress, setFirstMile] = useState(""); + const [lastMileDeliveryAddress, setLastMile] = useState(""); const { data: refData, isLoading } = useQuery({ queryKey: ["bookings", "reference-data"], queryFn: () => bookingsService.getReferenceData() as Promise, }); + const { data: customers, isLoading: customersLoading } = useQuery({ + queryKey: ["customers", "list"], + queryFn: async () => { + const res = await api.get(URL_CONSTANTS.CUSTOMERS.BASE); + return unwrap(res.data) as CustomerOption[]; + }, + }); + + const customerOptions = (customers ?? []).map((c) => ({ + value: c.id, + label: + c.companyName || + [c.firstName, c.lastName].filter(Boolean).join(" ") || + c.email || + c.id, + })); + + const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code })); + const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); + const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); + + const containerGroupData = useMemo( + () => + (refData?.containers ?? []).map((g) => ({ + group: g.size, + items: g.types.map((t) => ({ + value: t.id, + label: `${t.code}${t.name && t.name !== t.code ? ` — ${t.name}` : ""}`, + })), + })), + [refData?.containers], + ); + + const { cargoData, freeTextById } = useMemo(() => { + const groups = refData?.cargo_type ?? []; + const freeText = new Map(); + const data = groups.map((g) => { + if (g.children?.length) { + g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box))); + return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) }; + } + return { value: g.id, label: g.name }; + }); + return { cargoData: data, freeTextById: freeText }; + }, [refData?.cargo_type]); + + const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false; + + // ---- derived totals ---- + const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0); + const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0); + const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight; + + // ---- validation ---- + const lineValid = (l: ContainerLine) => + Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0; + const allLinesValid = lines.length > 0 && lines.every(lineValid); + const sameYard = Boolean(originYardId && originYardId === destinationYardId); + + const canSubmit = + Boolean(originYardId) && + Boolean(destinationYardId) && + !sameYard && + Boolean(serviceTypeId) && + Boolean(scheduledDate) && + (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && + (freightType === "BULK" + ? Boolean(cargoTypeId) && bulkWeight > 0 + : allLinesValid); + + const updateLine = (key: string, patch: Partial) => + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); + const removeLine = (key: string) => + setLines((prev) => (prev.length === 1 ? prev : prev.filter((l) => l.key !== key))); + const createMutation = useMutation({ mutationFn: () => bookingsService.create({ isGovernment, governmentInstitution: isGovernment ? governmentInstitution : undefined, + companyId: isGovernment ? undefined : companyId || undefined, freightType, contractType: "NEW", - equipmentReturn: "NA", - tradeDirection: "IMPORT", - paymentCurrency: "ETB", - scheduledDate: scheduledDate || new Date().toISOString(), + equipmentReturn, + tradeDirection, + paymentCurrency, + isHazardous, + scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(), originYardId, destinationYardId, serviceTypeId, - cargoTotalWeightVgm: weight, + shippingLineId: shippingLineId || undefined, + firstMilePickupAddress: firstMilePickupAddress.trim() || undefined, + lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined, + cargoTotalWeightVgm, cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined, + cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined, containers: - freightType === "CONTAINER" && containerTypeId - ? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }] + freightType === "CONTAINER" + ? lines.map((l) => ({ + containerTypeId: l.containerTypeId, + quantity: l.quantity, + vgmPerUnitTons: l.vgmPerUnitTons, + })) : undefined, }), onSuccess: async (booking) => { @@ -82,33 +309,8 @@ export default function NewBookingPage() { onError: () => toast.error("Failed to create booking"), }); - const yards = (refData?.yard ?? []).map((y) => ({ - value: y.id, - label: y.name ?? y.code, - })); - const services = (refData?.service ?? []).map((s) => ({ - value: s.id, - label: s.name ?? s.code, - })); - const containerTypes = - refData?.containers?.flatMap((g) => - g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })), - ) ?? []; - const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({ - value: c.id, - label: c.name ?? c.code, - })); - - const canSubmit = - originYardId && - destinationYardId && - serviceTypeId && - scheduledDate && - (!isGovernment || governmentInstitution.trim().length >= 2) && - (freightType === "BULK" ? cargoTypeId : containerTypeId); - return ( - + - - Create booking (staff) - - - - setIsGovernment(e.currentTarget.checked)} - /> - {isGovernment ? ( - setGovernmentInstitution(e.currentTarget.value)} - required - /> - ) : null} - - - + + {/* LEFT — form */} + + + + + setIsGovernment(e.currentTarget.checked)} + /> + {isGovernment ? ( + setGovernmentInstitution(e.currentTarget.value)} + required + /> + ) : ( + + + - ) : ( - setPaymentCurrency(v ?? "ETB")} + /> + + - - - - + {/* Cargo */} + {freightType === "CONTAINER" ? ( + + {totalContainers} container{totalContainers === 1 ? "" : "s"} + + } + > + + {lines.map((line, idx) => { + const invalid = !lineValid(line); + return ( + + + + {idx + 1} + + + {showFreeText ? ( +