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 { useQuery } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
@@ -14,7 +22,7 @@ import {
Zap, Zap,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
@@ -30,7 +38,12 @@ const cv = (token: string) => {
return `var(--mantine-color-${name}-${shade ?? "6"})`; 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 { interface StageConfig {
stage: number; stage: number;
@@ -43,7 +56,11 @@ interface StageConfig {
badgeBg: string; badgeBg: string;
badgeText: string; badgeText: string;
badgeDot: 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> = { 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" }, 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" }, Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, 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 MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const VOLUME_DATA = [420, 680, 510, 820, 750, 940]; const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
type Tab = "all" | "needs" | "completed";
const NEEDS_ACTION = ["DRAFT", "PENDING_APPROVAL"];
export default function MyPortalPage() { export default function MyPortalPage() {
const { user, customer } = useAuth(); const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []); const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []); const myInvoices = useMemo(() => getMyInvoices(), []);
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState<Tab>("all");
const bookingsQuery = useQuery( 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 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( const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue", (inv) => inv.status === "Sent" || inv.status === "Overdue",
); );
const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0); const totalOutstanding = outstandingInvoices.reduce(
const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12; (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 displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const companyName = (customer as any)?.companyName ?? displayName; const companyName = (customer as any)?.companyName ?? displayName;
const hour = new Date().getHours(); 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 recentInvoices = myInvoices.slice(0, 3);
const maxVolume = Math.max(...VOLUME_DATA); const maxVolume = Math.max(...VOLUME_DATA);
return ( return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}> <Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
{/* ── Hello Row ─────────────────────────────────────────────────────── */} {/* ── Hello Row ─────────────────────────────────────────────────────── */}
<Group <Group justify="space-between" align="center" gap="md">
justify="space-between"
align="center"
gap="md"
className="!flex-col !items-stretch md:!flex-row md:!items-center"
>
<Box> <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"> <Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋 {companyName} 👋
</Text> </Text>
</Box> </Box>
{/* Book a shipment CTA */} {/* Book a shipment CTA */}
<Group <Link to="/bookings/new">
component={Link as any} <Group
to="/bookings/new" gap={14}
gap={14} align="center"
align="center" wrap="nowrap"
wrap="nowrap" bg="edr-green"
bg="edr-green" px={18}
px={18} py={14}
py={14} className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
className="w-full md:!w-[240px] rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]" >
>
<Truck size={22} color="#fff" /> <Truck size={22} color="#fff" />
<Box className="min-w-0 flex-1"> <Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>Book a shipment</Text> <Text fz={14} fw={700} c="white" lh={1.3}>
</Box> Book a shipment
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white"> </Text>
<ArrowRight size={18} color={cv("edr-green.7")} /> </Box>
</Box> <Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
</Group> <ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Link>
</Group> </Group>
{/* ── Stats Strip ───────────────────────────────────────────────────── */} {/* ── Stats Strip ───────────────────────────────────────────────────── */}
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5"> <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}> <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
<StatKpi icon={Clock3} label="Awaiting Payment" value={outstandingInvoices.length.toString()} delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`} deltaColor="edr-amber-text" divider /> icon={Truck}
<StatKpi icon={CheckCircle2} label="Delivered (May)" value={deliveredCount.toString()} delta="96% on-time" deltaColor="edr-muted" divider /> label="Active Shipments"
<StatKpi icon={Wallet} label="Spend YTD" value="ETB 1.24M" delta="+16% YoY" deltaColor="edr-green.7" divider /> 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> </SimpleGrid>
</Box> </Box>
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
<Grid align="stretch"> <Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<Card className="h-full" padding={28}> <Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap"> <Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box> <Box>
<Text fz={19} fw={800} c="edr-text">My Shipments</Text> <Text fz={19} fw={800} c="edr-text">
<Text fz={13} c="edr-muted">From draft to delivery every booking in one place</Text> My Shipments
</Text>
<Text fz={13} c="edr-muted">
From draft to delivery every booking in one place
</Text>
</Box> </Box>
</Group> </Group>
{bookingsQuery.isPending ? ( {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 ? ( ) : visibleBookings.length === 0 ? (
<EmptyState message="No bookings in this view." /> <EmptyState message="No bookings in this view." />
) : ( ) : (
<Stack gap={0}> <Stack gap={0}>
{visibleBookings.map((booking, i) => ( {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> </Stack>
)} )}
@@ -269,22 +335,48 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Card className="h-full" padding={24}> <Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}> <Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Invoices</Text> <Text fz={17} fw={700} c="edr-text">
<Group component={Link as any} to="/billing" gap={3} align="center" className="no-underline"> Invoices
<Text fz={13} fw={600} c="edr-green.7">View all</Text> </Text>
<ChevronRight size={15} color={cv("edr-green.7")} /> <Link to="/billing">
</Group> <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> </Group>
{/* Outstanding card */} {/* Outstanding card */}
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]"> <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={12} fw={600} c="edr-amber-text">
<Text fz={24} fw={800} mt={4} c="edr-text">{formatCurrency(totalOutstanding || 377500, "ETB")}</Text> Outstanding balance
<Group justify="space-between" align="center" mt={8} wrap="nowrap"> </Text>
<Text fz={12} c="edr-amber-text">{outstandingInvoices.length || 2} invoices unpaid</Text> <Text fz={24} fw={800} mt={4} c="edr-text">
<Group gap={5} align="center" px={14} py={8} bg="edr-accent" className="cursor-pointer rounded-[9px]"> {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" /> <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>
</Group> </Group>
</Box> </Box>
@@ -302,26 +394,53 @@ export default function MyPortalPage() {
: invoice.status === "Overdue" : invoice.status === "Overdue"
? "Overdue 3 days" ? "Overdue 3 days"
: `Due ${invoice.dueDate}`; : `Due ${invoice.dueDate}`;
const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3; const DueIcon =
const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted"); invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
return ( return (
<Box key={invoice.id}> <Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />} {i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}> <Stack gap={8} py={10}>
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Box> <Box>
<Text fz={13} fw={700} c="edr-text">{invoice.number}</Text> <Text fz={13} fw={700} c="edr-text">
<Text fz={11} c="edr-muted">{invoice.bookingReference}</Text> {invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box> </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>
<Group justify="space-between" align="center" wrap="nowrap"> <Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center"> <Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} /> <DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">{dueText}</Text> <Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group> </Group>
<Box bg={badge.bg} px={10} py={4} className="rounded-full"> <Box
<Text fz={11} fw={700} c={badge.text}>{badge.label}</Text> bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box> </Box>
</Group> </Group>
</Stack> </Stack>
@@ -335,27 +454,40 @@ export default function MyPortalPage() {
</Grid> </Grid>
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
<Grid gutter={20} align="stretch"> <Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}> <Grid.Col span={{ base: 12, md: 5 }}>
<Card className="h-full" padding={24}> <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}> <Group gap={10} align="baseline" mt={4} mb={22}>
<Text fz={26} fw={800} c="edr-text">4,180 t</Text> <Text fz={26} fw={800} c="edr-text">
<Text fz={13} c="edr-muted">ETB 1.24M</Text> 4,180 t
<Text fz={12} fw={700} c="edr-green.7">+16% YTD</Text> </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>
<Group align="flex-end" gap={10} className="h-[110px]"> <Group align="flex-end" gap={10} className="h-[110px]">
{VOLUME_DATA.map((val, i) => { {VOLUME_DATA.map((val, i) => {
const isLast = i === VOLUME_DATA.length - 1; const isLast = i === VOLUME_DATA.length - 1;
return ( 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 <Box
bg={isLast ? "edr-green" : "edr-soft"} bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"} bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((val / maxVolume) * 86)} h={Math.round((val / maxVolume) * 86)}
className="w-full rounded-t-md" 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> </Box>
); );
})} })}
@@ -366,21 +498,35 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, md: 7 }}> <Grid.Col span={{ base: 12, md: 7 }}>
<Card className="h-full" padding={24}> <Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}> <Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Recent Activity</Text> <Text fz={17} fw={700} c="edr-text">
<Group component={Link as any} to="/bookings" gap={3} align="center" className="no-underline"> Recent Activity
<Text fz={13} fw={600} c="edr-green.7">View all</Text> </Text>
<ChevronRight size={15} color={cv("edr-green.7")} /> <Link to="/bookings">
</Group> <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> </Group>
{bookingsQuery.isPending ? ( {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 ? ( ) : allBookings.length === 0 ? (
<EmptyState message="No recent activity." /> <EmptyState message="No recent activity." />
) : ( ) : (
<Stack gap={2}> <Stack gap={2}>
{allBookings.slice(0, 6).map((booking) => ( {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> </Stack>
)} )}
@@ -403,7 +549,10 @@ function Card({
padding?: number; padding?: number;
}) { }) {
return ( 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} {children}
</Box> </Box>
); );
@@ -425,14 +574,25 @@ function StatKpi({
divider?: boolean; divider?: boolean;
}) { }) {
return ( 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"> <Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" /> <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>
<Group gap={8} align="flex-end" wrap="nowrap"> <Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>{value}</Text> <Text fz={22} fw={800} lh={1} c="edr-text" truncate>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>{delta}</Text> {value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
</Group> </Group>
</Box> </Box>
); );
@@ -446,9 +606,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
const active = i === stage; const active = i === stage;
const size = active ? 12 : done ? 9 : 8; const size = active ? 12 : done ? 9 : 8;
return ( return (
<Group key={i} gap={0} align="center" wrap="nowrap" className={i < 4 ? "flex-1" : undefined}> <Group
<Box w={size} h={size} bg={done || active ? color : "edr-step-idle"} className="shrink-0 rounded-full" /> key={i}
{i < 4 && <Box h={3} bg={i < stage ? color : "edr-conn-idle"} className="flex-1 rounded-full" />} 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> </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 cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon; const Icon = cfg.icon;
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; const ap = ACTION_PROPS[cfg.action.kind];
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; 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 = const commodity =
(typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ?? (typeof booking.cargoType === "string"
? booking.cargoType
: booking.cargoType?.name) ??
booking.commodity ?? booking.commodity ??
"Freight"; "Freight";
return ( return (
<Box className={last ? undefined : "border-b border-edr-divider"}> <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}> <Group
<Box w={46} h={46} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-xl"> 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)} /> <Icon size={22} color={cv(cfg.iconColor)} />
</Box> </Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]"> <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={15} fw={700} c="edr-text" truncate>
<Text fz={12} c="edr-muted" truncate>{commodity} · {origin} {dest}</Text> {booking.reference}
</Text>
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>
</Box> </Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block"> <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} /> <Stepper stage={cfg.stage} color={cfg.step} />
</Box> </Box>
<Stack gap={9} align="flex-end" className="shrink-0"> <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" /> <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>
<Group gap={5} align="center" px={15} py={8} bg={ap.bg} bd={ap.bd} className="cursor-pointer rounded-[9px]"> <Group
<Text fz={13} fw={700} c={ap.c}>{cfg.action.label}</Text> gap={5}
{AIcon && <AIcon size={15} color={ap.c === "white" ? "#fff" : cv("edr-text")} />} 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> </Group>
</Stack> </Stack>
</Group> </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 cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon; const Icon = cfg.icon;
const verb = const verb =
@@ -514,26 +751,49 @@ function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }
? "submitted for review" ? "submitted for review"
: "created"; : "created";
return ( return (
<Group gap={12} align="center" wrap="nowrap" py={9} className="cursor-pointer" onClick={onClick}> <Group
<Box w={36} h={36} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-[10px]"> 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)} /> <Icon size={17} color={cv(cfg.iconColor)} />
</Box> </Box>
<Box className="min-w-0 flex-1"> <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> <Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "} {booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} {booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text> </Text>
</Box> </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> </Group>
); );
} }
function EmptyState({ message }: { message: string }) { function EmptyState({ message }: { message: string }) {
return ( return (
<Box py="xl" className="rounded-xl border border-dashed border-edr-border text-center"> <Box
<Text size="sm" c="edr-muted">{message}</Text> py="xl"
className="rounded-xl border border-dashed border-edr-border text-center"
>
<Text size="sm" c="edr-muted">
{message}
</Text>
</Box> </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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowDownToLine, ArrowDownToLine,
ArrowUpFromLine, ArrowUpFromLine,
Building2, Building2,
ChevronRight, ChevronRight,
Ship,
Truck,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -27,37 +33,37 @@ const USER_TYPE_CARDS: {
description: string; description: string;
icon: React.ReactNode; icon: React.ReactNode;
}[] = [ }[] = [
{ {
id: "importer", id: "importer",
label: "Importer", label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.", description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />, icon: <ArrowDownToLine size={22} />,
}, },
{ {
id: "exporter", id: "exporter",
label: "Exporter", label: "Exporter",
description: "Export goods from Ethiopia via rail.", description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />, icon: <ArrowUpFromLine size={22} />,
}, },
{ {
id: "freight-forwarder-et", id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)", label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.", description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />, icon: <Building2 size={22} />,
}, },
{ // {
id: "freight-forwarder-dj", // id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)", // label: "FF Agent (Djibouti)",
description: "Djibouti-based agent coordinating cross-border logistics.", // description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship size={22} />, // icon: <Ship size={22} />,
}, // },
{ // {
id: "transporter", // id: "transporter",
label: "Transporter", // label: "Transporter",
description: "Trucking company providing first/last-mile services.", // description: "Trucking company providing first/last-mile services.",
icon: <Truck size={22} />, // icon: <Truck size={22} />,
}, // },
]; ];
const USER_TYPE_LEFT_MAP: Record< const USER_TYPE_LEFT_MAP: Record<
OnboardingUserType, OnboardingUserType,
@@ -105,7 +111,12 @@ const PREFLIGHT_LEFT = {
"Freight Forwarders (Ethiopia & Djibouti)", "Freight Forwarders (Ethiopia & Djibouti)",
"Transporters & Fleet Operators", "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> = { const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
@@ -120,7 +131,9 @@ export default function OnboardingPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null); 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> = { const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer", importer: "customer",
@@ -131,7 +144,8 @@ export default function OnboardingPage() {
}; };
const createCompanyMutation = useMutation({ const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload), mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: async (data) => { onSuccess: async (data) => {
const hasFiles = Object.values(documentFiles).some( const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
@@ -139,14 +153,19 @@ export default function OnboardingPage() {
if (hasFiles) { if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles); 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; if (!user) return null;
const handleSubmit = (payload: CreateCompanyPayload) => { 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); createCompanyMutation.mutate(enriched);
}; };
@@ -209,11 +228,28 @@ export default function OnboardingPage() {
...leftConfig, ...leftConfig,
features: features:
userType === "transporter" 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" : userType === "freight-forwarder-dj"
? ["Company details", "Representative information", "Cross-border operations"] ? [
: ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"], "Company details",
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, "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 ( return (

View File

@@ -71,10 +71,12 @@ export function DraftBookingView({
).length; ).length;
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery({ const { data: generatedPricing } = useQuery(
...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}); enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}),
);
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
const uploadMutation = useMutation({ const uploadMutation = useMutation({