mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fixes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user