This commit is contained in:
ghost2023
2026-06-13 11:24:16 +03:00
parent 6058b870a1
commit 407cf3ed1f
3 changed files with 461 additions and 163 deletions

View File

@@ -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<string, StageConfig> = {
@@ -146,7 +163,10 @@ const ACTION_PROPS: Record<string, { bg: string; c: string; bd?: string }> = {
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
};
const INVOICE_BADGE: Record<InvoiceStatus, { label: string; bg: string; text: string }> = {
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<InvoiceStatus, { label: string; bg: string; text: st
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
type Tab = "all" | "needs" | "completed";
const NEEDS_ACTION = ["DRAFT", "PENDING_APPROVAL"];
export default function MyPortalPage() {
const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>("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 (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
{/* ── Hello Row ─────────────────────────────────────────────────────── */}
<Group
justify="space-between"
align="center"
gap="md"
className="!flex-col !items-stretch md:!flex-row md:!items-center"
>
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">{greeting}</Text>
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
{/* Book a shipment CTA */}
<Group
component={Link as any}
to="/bookings/new"
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:!w-[240px] rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Link to="/bookings/new">
<Group
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Truck size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>Book a shipment</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Book a shipment
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Link>
</Group>
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<StatKpi icon={Truck} label="Active Shipments" value={activeBookings.length.toString()} delta="+2 this week" deltaColor="edr-green.7" />
<StatKpi icon={Clock3} label="Awaiting Payment" value={outstandingInvoices.length.toString()} delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`} deltaColor="edr-amber-text" divider />
<StatKpi icon={CheckCircle2} label="Delivered (May)" value={deliveredCount.toString()} delta="96% on-time" deltaColor="edr-muted" divider />
<StatKpi icon={Wallet} label="Spend YTD" value="ETB 1.24M" delta="+16% YoY" deltaColor="edr-green.7" divider />
<StatKpi
icon={Truck}
label="Active Shipments"
value={activeBookings.length.toString()}
delta="+2 this week"
deltaColor="edr-green.7"
/>
<StatKpi
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoices.length.toString()}
delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (May)"
value={deliveredCount.toString()}
delta="96% on-time"
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value="ETB 1.24M"
delta="+16% YoY"
deltaColor="edr-green.7"
divider
/>
</SimpleGrid>
</Box>
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
<Grid align="stretch">
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box>
<Text fz={19} fw={800} c="edr-text">My Shipments</Text>
<Text fz={13} c="edr-muted">From draft to delivery every booking in one place</Text>
<Text fz={19} fw={800} c="edr-text">
My Shipments
</Text>
<Text fz={13} c="edr-muted">
From draft to delivery every booking in one place
</Text>
</Box>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={6}>{[1, 2, 3, 4].map((i) => <Skeleton key={i} height={64} radius="md" />)}</Stack>
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={64} radius="md" />
))}
</Stack>
) : visibleBookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<Stack gap={0}>
{visibleBookings.map((booking, i) => (
<BookingRow key={booking.id} booking={booking} last={i === visibleBookings.length - 1} onClick={() => navigate(`/bookings/${booking.id}`)} />
<BookingRow
key={booking.id}
booking={booking}
last={i === visibleBookings.length - 1}
onClick={() => navigate(`/bookings/${booking.id}`)}
/>
))}
</Stack>
)}
@@ -269,22 +335,48 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Invoices</Text>
<Group component={Link as any} to="/billing" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
<Text fz={17} fw={700} c="edr-text">
Invoices
</Text>
<Link to="/billing">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{/* Outstanding card */}
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
<Text fz={12} fw={600} c="edr-amber-text">Outstanding balance</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">{formatCurrency(totalOutstanding || 377500, "ETB")}</Text>
<Group justify="space-between" align="center" mt={8} wrap="nowrap">
<Text fz={12} c="edr-amber-text">{outstandingInvoices.length || 2} invoices unpaid</Text>
<Group gap={5} align="center" px={14} py={8} bg="edr-accent" className="cursor-pointer rounded-[9px]">
<Text fz={12} fw={600} c="edr-amber-text">
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">Pay all</Text>
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
</Box>
@@ -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 (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Box>
<Text fz={13} fw={700} c="edr-text">{invoice.number}</Text>
<Text fz={11} c="edr-muted">{invoice.bookingReference}</Text>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">{formatCurrency(invoice.amount, invoice.currency)}</Text>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group justify="space-between" align="center" wrap="nowrap">
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">{dueText}</Text>
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
<Box bg={badge.bg} px={10} py={4} className="rounded-full">
<Text fz={11} fw={700} c={badge.text}>{badge.label}</Text>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
@@ -335,27 +454,40 @@ export default function MyPortalPage() {
</Grid>
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
<Grid gutter={20} align="stretch">
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">Freight Volume</Text>
<Text fz={17} fw={700} c="edr-text">
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
<Text fz={26} fw={800} c="edr-text">4,180 t</Text>
<Text fz={13} c="edr-muted">ETB 1.24M</Text>
<Text fz={12} fw={700} c="edr-green.7">+16% YTD</Text>
<Text fz={26} fw={800} c="edr-text">
4,180 t
</Text>
<Text fz={13} c="edr-muted">
ETB 1.24M
</Text>
<Text fz={12} fw={700} c="edr-green.7">
+16% YTD
</Text>
</Group>
<Group align="flex-end" gap={10} className="h-[110px]">
{VOLUME_DATA.map((val, i) => {
const isLast = i === VOLUME_DATA.length - 1;
return (
<Box key={i} className="flex flex-1 flex-col items-center gap-2">
<Box
key={i}
className="flex flex-1 flex-col items-center gap-2"
>
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((val / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">{MONTHS[i]}</Text>
<Text fz={11} c="edr-muted">
{MONTHS[i]}
</Text>
</Box>
);
})}
@@ -366,21 +498,35 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, md: 7 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Recent Activity</Text>
<Group component={Link as any} to="/bookings" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
<Text fz={17} fw={700} c="edr-text">
Recent Activity
</Text>
<Link to="/bookings">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={10}>{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} height={44} radius="md" />)}</Stack>
<Stack gap={10}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : allBookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{allBookings.slice(0, 6).map((booking) => (
<ActivityRow key={booking.id} booking={booking} onClick={() => navigate(`/bookings/${booking.id}`)} />
<ActivityRow
key={booking.id}
booking={booking}
onClick={() => navigate(`/bookings/${booking.id}`)}
/>
))}
</Stack>
)}
@@ -403,7 +549,10 @@ function Card({
padding?: number;
}) {
return (
<Box p={padding} className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}>
<Box
p={padding}
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
>
{children}
</Box>
);
@@ -425,14 +574,25 @@ function StatKpi({
divider?: boolean;
}) {
return (
<Box px={4} className={divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined}>
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>{label}</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>{value}</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>{delta}</Text>
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
</Group>
</Box>
);
@@ -446,9 +606,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
const active = i === stage;
const size = active ? 12 : done ? 9 : 8;
return (
<Group key={i} gap={0} align="center" wrap="nowrap" className={i < 4 ? "flex-1" : undefined}>
<Box w={size} h={size} bg={done || active ? color : "edr-step-idle"} className="shrink-0 rounded-full" />
{i < 4 && <Box h={3} bg={i < stage ? color : "edr-conn-idle"} className="flex-1 rounded-full" />}
<Group
key={i}
gap={0}
align="center"
wrap="nowrap"
className={i < 4 ? "flex-1" : undefined}
>
<Box
w={size}
h={size}
bg={done || active ? color : "edr-step-idle"}
className="shrink-0 rounded-full"
/>
{i < 4 && (
<Box
h={3}
bg={i < stage ? color : "edr-conn-idle"}
className="flex-1 rounded-full"
/>
)}
</Group>
);
})}
@@ -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 (
<Box className={last ? undefined : "border-b border-edr-divider"}>
<Group gap={16} align="center" wrap="nowrap" py={14} px={4} className="cursor-pointer" onClick={onClick}>
<Box w={46} h={46} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-xl">
<Group
gap={16}
align="center"
wrap="nowrap"
py={14}
px={4}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={46}
h={46}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-xl"
>
<Icon size={22} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
<Text fz={15} fw={700} c="edr-text" truncate>{booking.reference}</Text>
<Text fz={12} c="edr-muted" truncate>{commodity} · {origin} {dest}</Text>
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>
</Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>{cfg.hint}</Text>
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
{cfg.hint}
</Text>
<Stepper stage={cfg.stage} color={cfg.step} />
</Box>
<Stack gap={9} align="flex-end" className="shrink-0">
<Group gap={6} align="center" px={11} py={5} bg={cfg.badgeBg} className="rounded-full">
<Group
gap={6}
align="center"
px={11}
py={5}
bg={cfg.badgeBg}
className="rounded-full"
>
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<Text fz={11} fw={700} c={cfg.badgeText}>{cfg.badgeLabel}</Text>
<Text fz={11} fw={700} c={cfg.badgeText}>
{cfg.badgeLabel}
</Text>
</Group>
<Group gap={5} align="center" px={15} py={8} bg={ap.bg} bd={ap.bd} className="cursor-pointer rounded-[9px]">
<Text fz={13} fw={700} c={ap.c}>{cfg.action.label}</Text>
{AIcon && <AIcon size={15} color={ap.c === "white" ? "#fff" : cv("edr-text")} />}
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
</Stack>
</Group>
@@ -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 (
<Group gap={12} align="center" wrap="nowrap" py={9} className="cursor-pointer" onClick={onClick}>
<Box w={36} h={36} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-[10px]">
<Group
gap={12}
align="center"
wrap="nowrap"
py={9}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={36}
h={36}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-[10px]"
>
<Icon size={17} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1">
<Text fz={13} fw={600} c="edr-text" truncate>Booking {booking.reference} {verb}</Text>
<Text fz={13} fw={600} c="edr-text" truncate>
Booking {booking.reference} {verb}
</Text>
<Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">{format(new Date(booking.createdAt), "MMM d")}</Text>
<Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")}
</Text>
</Group>
);
}
function EmptyState({ message }: { message: string }) {
return (
<Box py="xl" className="rounded-xl border border-dashed border-edr-border text-center">
<Text size="sm" c="edr-muted">{message}</Text>
<Box
py="xl"
className="rounded-xl border border-dashed border-edr-border text-center"
>
<Text size="sm" c="edr-muted">
{message}
</Text>
</Box>
);
}

View File

@@ -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: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
{
id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)",
description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship size={22} />,
},
{
id: "transporter",
label: "Transporter",
description: "Trucking company providing first/last-mile services.",
icon: <Truck size={22} />,
},
];
{
id: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
// {
// id: "freight-forwarder-dj",
// label: "FF Agent (Djibouti)",
// description: "Djibouti-based agent coordinating cross-border logistics.",
// icon: <Ship size={22} />,
// },
// {
// id: "transporter",
// label: "Transporter",
// description: "Trucking company providing first/last-mile services.",
// icon: <Truck size={22} />,
// },
];
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<OnboardingUserType, string> = {
@@ -120,7 +131,9 @@ export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
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 (

View File

@@ -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({