diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 5d6e9e808..cd72a826f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -1,4 +1,12 @@ -import { Box, Grid, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core"; +import { + Box, + Grid, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { @@ -14,7 +22,7 @@ import { Zap, type LucideIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Link, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; @@ -30,7 +38,12 @@ const cv = (token: string) => { return `var(--mantine-color-${name}-${shade ?? "6"})`; }; -const ACTIVE_STATUSES = ["DRAFT", "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT"]; +const ACTIVE_STATUSES = [ + "DRAFT", + "SUBMITTED", + "PENDING_APPROVAL", + "IN_TRANSIT", +]; interface StageConfig { stage: number; @@ -43,7 +56,11 @@ interface StageConfig { badgeBg: string; badgeText: string; badgeDot: string; - action: { label: string; kind: "dark" | "amber" | "outline"; icon?: LucideIcon }; + action: { + label: string; + kind: "dark" | "amber" | "outline"; + icon?: LucideIcon; + }; } const STATUS_CONFIG: Record = { @@ -146,7 +163,10 @@ const ACTION_PROPS: Record = { outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, }; -const INVOICE_BADGE: Record = { +const INVOICE_BADGE: Record< + InvoiceStatus, + { label: string; bg: string; text: string } +> = { Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, @@ -157,108 +177,154 @@ const INVOICE_BADGE: Record getMyShipments(), []); const myInvoices = useMemo(() => getMyInvoices(), []); const navigate = useNavigate(); - const [tab, setTab] = useState("all"); const bookingsQuery = useQuery( - api.bookings.list.queryOptions({ input: { sortBy: "createdAt", sortOrder: "DESC" } }), + api.bookings.list.queryOptions({ + input: { sortBy: "createdAt", sortOrder: "DESC" }, + }), ); const allBookings = bookingsQuery.data?.items ?? []; - const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status)); + const activeBookings = allBookings.filter((b) => + ACTIVE_STATUSES.includes(b.status), + ); - const visibleBookings = allBookings + const visibleBookings = allBookings; const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); - const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0); - const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12; + const totalOutstanding = outstandingInvoices.reduce( + (sum, inv) => sum + inv.amount, + 0, + ); + const deliveredCount = + myShipments.filter((s) => s.status === "Delivered").length || 12; const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; const companyName = (customer as any)?.companyName ?? displayName; const hour = new Date().getHours(); - const greeting = hour < 12 ? "Good morning," : hour < 18 ? "Good afternoon," : "Good evening,"; + const greeting = + hour < 12 + ? "Good morning," + : hour < 18 + ? "Good afternoon," + : "Good evening,"; const recentInvoices = myInvoices.slice(0, 3); const maxVolume = Math.max(...VOLUME_DATA); return ( {/* ── Hello Row ─────────────────────────────────────────────────────── */} - + - {greeting} + + {greeting} + {companyName} 👋 {/* Book a shipment CTA */} - - + + - - - Book a shipment - - - - - + + + + Book a shipment + + + + + + + {/* ── Stats Strip ───────────────────────────────────────────────────── */} - - - - + + + + {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} - + - My Shipments - From draft to delivery — every booking in one place + + My Shipments + + + From draft to delivery — every booking in one place + - {bookingsQuery.isPending ? ( - {[1, 2, 3, 4].map((i) => )} + + {[1, 2, 3, 4].map((i) => ( + + ))} + ) : visibleBookings.length === 0 ? ( ) : ( {visibleBookings.map((booking, i) => ( - navigate(`/bookings/${booking.id}`)} /> + navigate(`/bookings/${booking.id}`)} + /> ))} )} @@ -269,22 +335,48 @@ export default function MyPortalPage() { - Invoices - - View all - - + + Invoices + + + + + View all + + + + {/* Outstanding card */} - Outstanding balance - {formatCurrency(totalOutstanding || 377500, "ETB")} - - {outstandingInvoices.length || 2} invoices unpaid - + + Outstanding balance + + + {formatCurrency(totalOutstanding || 377500, "ETB")} + + + + {outstandingInvoices.length || 2} invoices unpaid + + - Pay all + + Pay all + @@ -302,26 +394,53 @@ export default function MyPortalPage() { : invoice.status === "Overdue" ? "Overdue 3 days" : `Due ${invoice.dueDate}`; - const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3; - const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted"); + const DueIcon = + invoice.status === "Paid" ? CheckCircle2 : Clock3; + const dueIconColor = + invoice.status === "Paid" + ? cv("edr-green.5") + : cv("edr-muted"); return ( {i > 0 && } - + - {invoice.number} - {invoice.bookingReference} + + {invoice.number} + + + {invoice.bookingReference} + - {formatCurrency(invoice.amount, invoice.currency)} + + {formatCurrency(invoice.amount, invoice.currency)} + - + - {dueText} + + {dueText} + - - {badge.label} + + + {badge.label} + @@ -335,27 +454,40 @@ export default function MyPortalPage() { {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} - + - Freight Volume + + Freight Volume + - 4,180 t - ETB 1.24M - +16% YTD + + 4,180 t + + + ETB 1.24M + + + +16% YTD + {VOLUME_DATA.map((val, i) => { const isLast = i === VOLUME_DATA.length - 1; return ( - + - {MONTHS[i]} + + {MONTHS[i]} + ); })} @@ -366,21 +498,35 @@ export default function MyPortalPage() { - Recent Activity - - View all - - + + Recent Activity + + + + + View all + + + + {bookingsQuery.isPending ? ( - {[1, 2, 3, 4, 5].map((i) => )} + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + ) : allBookings.length === 0 ? ( ) : ( {allBookings.slice(0, 6).map((booking) => ( - navigate(`/bookings/${booking.id}`)} /> + navigate(`/bookings/${booking.id}`)} + /> ))} )} @@ -403,7 +549,10 @@ function Card({ padding?: number; }) { return ( - + {children} ); @@ -425,14 +574,25 @@ function StatKpi({ divider?: boolean; }) { return ( - + - {label} + + {label} + - {value} - {delta} + + {value} + + + {delta} + ); @@ -446,9 +606,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) { const active = i === stage; const size = active ? 12 : done ? 9 : 8; return ( - - - {i < 4 && } + + + {i < 4 && ( + + )} ); })} @@ -456,43 +633,97 @@ function Stepper({ stage, color }: { stage: number; color: string }) { ); } -function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; onClick: () => void }) { +function BookingRow({ + booking, + last, + onClick, +}: { + booking: any; + last: boolean; + onClick: () => void; +}) { const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; const Icon = cfg.icon; const AIcon = cfg.action.icon; const ap = ACTION_PROPS[cfg.action.kind]; const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; - const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; + const dest = + booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; const commodity = - (typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ?? + (typeof booking.cargoType === "string" + ? booking.cargoType + : booking.cargoType?.name) ?? booking.commodity ?? "Freight"; return ( - - + + - {booking.reference} - {commodity} · {origin} → {dest} + + {booking.reference} + + + {commodity} · {origin} → {dest} + - {cfg.hint} + + {cfg.hint} + - + - {cfg.badgeLabel} + + {cfg.badgeLabel} + - - {cfg.action.label} - {AIcon && } + + + {cfg.action.label} + + {AIcon && ( + + )} @@ -500,7 +731,13 @@ function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; o ); } -function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }) { +function ActivityRow({ + booking, + onClick, +}: { + booking: any; + onClick: () => void; +}) { const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; const Icon = cfg.icon; const verb = @@ -514,26 +751,49 @@ function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void } ? "submitted for review" : "created"; return ( - - + + - Booking {booking.reference} {verb} + + Booking {booking.reference} {verb} + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} - {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} - {format(new Date(booking.createdAt), "MMM d")} + + {format(new Date(booking.createdAt), "MMM d")} + ); } function EmptyState({ message }: { message: string }) { return ( - - {message} + + + {message} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index 939088973..861fbbb75 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,12 +1,18 @@ -import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; +import { + Box, + Group, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, ArrowUpFromLine, Building2, ChevronRight, - Ship, - Truck, } from "lucide-react"; import { useState } from "react"; @@ -27,37 +33,37 @@ const USER_TYPE_CARDS: { description: string; icon: React.ReactNode; }[] = [ - { - id: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - id: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, - { - id: "freight-forwarder-et", - label: "Freight Forwarder (Ethiopia)", - description: "Ethiopian freight forwarding company handling client cargo.", - icon: , - }, - { - id: "freight-forwarder-dj", - label: "FF Agent (Djibouti)", - description: "Djibouti-based agent coordinating cross-border logistics.", - icon: , - }, - { - id: "transporter", - label: "Transporter", - description: "Trucking company providing first/last-mile services.", - icon: , - }, -]; + { + id: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + id: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, + { + id: "freight-forwarder-et", + label: "Freight Forwarder (Ethiopia)", + description: "Ethiopian freight forwarding company handling client cargo.", + icon: , + }, + // { + // id: "freight-forwarder-dj", + // label: "FF Agent (Djibouti)", + // description: "Djibouti-based agent coordinating cross-border logistics.", + // icon: , + // }, + // { + // id: "transporter", + // label: "Transporter", + // description: "Trucking company providing first/last-mile services.", + // icon: , + // }, + ]; const USER_TYPE_LEFT_MAP: Record< OnboardingUserType, @@ -105,7 +111,12 @@ const PREFLIGHT_LEFT = { "Freight Forwarders (Ethiopia & Djibouti)", "Transporters & Fleet Operators", ], - stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, }; const DOCUMENT_SETTING_CODE_MAP: Record = { @@ -120,7 +131,9 @@ export default function OnboardingPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [userType, setUserType] = useState(null); - const [documentFiles, setDocumentFiles] = useState>({}); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); const COMPANY_TYPE_MAP: Record = { importer: "customer", @@ -131,7 +144,8 @@ export default function OnboardingPage() { }; const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload), + mutationFn: (payload: CreateCompanyPayload) => + api.companies.create.call(payload), onSuccess: async (data) => { const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), @@ -139,14 +153,19 @@ export default function OnboardingPage() { if (hasFiles) { await companiesService.uploadDocuments(data.company.id, documentFiles); } - await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() }); + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); }, }); if (!user) return null; const handleSubmit = (payload: CreateCompanyPayload) => { - const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] }; + const enriched: CreateCompanyPayload = { + ...payload, + companyType: COMPANY_TYPE_MAP[userType!], + }; createCompanyMutation.mutate(enriched); }; @@ -209,11 +228,28 @@ export default function OnboardingPage() { ...leftConfig, features: userType === "transporter" - ? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"] + ? [ + "Vehicle & fleet registration", + "TIN & FAN verification", + "First-mile / Last-mile eligibility", + ] : userType === "freight-forwarder-dj" - ? ["Company details", "Representative information", "Cross-border operations"] - : ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"], - stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, + ? [ + "Company details", + "Representative information", + "Cross-border operations", + ] + : [ + "Company registration details", + "Contact and management personnel", + "Power of Attorney (optional)", + ], + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, }; return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index e69a024cc..38238680a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -71,10 +71,12 @@ export function DraftBookingView({ ).length; const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; - const { data: generatedPricing } = useQuery({ - ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), - enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, - }); + const { data: generatedPricing } = useQuery( + api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, + + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + }), + ); const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; const uploadMutation = useMutation({