refactor: the portal dashboard

This commit is contained in:
Nathnael
2026-06-17 07:02:15 +00:00
parent 1ca98f32ea
commit 4f20b07d81
19 changed files with 1333 additions and 1048 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import { Grid, Stack } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import {
FreightVolumeSection,
HelloSection,
InvoicesSection,
RecentActivitySection,
SetupPrompt,
ShipmentsSection,
StatsSection,
} from "./components";
import { useMyPortalData } from "./hooks";
export default function MyPortalPage() {
const navigate = useNavigate();
const {
customer,
bookingsQuery,
dashboardQuery,
allBookings,
activeBookings,
newActiveThisWeek,
outstandingInvoices,
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
} = useMyPortalData();
const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`);
};
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<SetupPrompt show={!customer} />
<StatsSection
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}
bookingsLoading={bookingsQuery.isPending}
outstandingInvoicesLength={outstandingInvoices.length}
totalOutstanding={totalOutstanding}
deliveredCount={dashboard?.deliveredCount.toString()}
completionRate={dashboard?.completionRate}
spendYtd={
dashboard
? formatCurrency(
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
: undefined
}
spendYtdChangePct={dashboard?.spendYtdChangePct}
dashboardLoading={dashboardQuery.isPending}
/>
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ShipmentsSection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
</Grid.Col>
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<FreightVolumeSection
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
totalValue={dashboard?.freightVolume.totalValue ?? 0}
currency={
(dashboard?.freightVolume.currency ?? "ETB") as Currency
}
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
volumePoints={volumePoints}
maxVolume={maxVolume}
isLoading={dashboardQuery.isPending}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
import { Box, Group, Text } from "@mantine/core";
import { format } from "date-fns";
import { memo } from "react";
import { STATUS_CONFIG, cv } from "../constants";
interface ActivityRowProps {
booking: any;
onClick: () => void;
}
export const ActivityRow = memo(function ActivityRow({
booking,
onClick,
}: ActivityRowProps) {
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon;
const verb =
booking.status === "IN_TRANSIT"
? "departed"
: booking.status === "COMPLETED"
? "delivered"
: booking.status === "PENDING_APPROVAL"
? "quote ready"
: booking.status === "SUBMITTED"
? "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]"
>
<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={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.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>
</Group>
);
});

View File

@@ -0,0 +1,104 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
interface BookingRowProps {
booking: any;
last: boolean;
onClick: () => void;
}
export const BookingRow = memo(function BookingRow({
booking,
last,
onClick,
}: BookingRowProps) {
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 commodity =
(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"
>
<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>
</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>
<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"
>
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<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>
</Stack>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,18 @@
import { Box } from "@mantine/core";
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: number;
}
export function Card({ children, className = "", padding = 24 }: CardProps) {
return (
<Box
p={padding}
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,18 @@
import { Box, Text } from "@mantine/core";
interface EmptyStateProps {
message: string;
}
export function EmptyState({ message }: EmptyStateProps) {
return (
<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

@@ -0,0 +1,82 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
interface FreightVolumeSectionProps {
totalTonnes: number;
totalValue: number;
currency: Currency;
ytdChangePct: number;
volumePoints: Array<{ month: string; tonnes: number }>;
maxVolume: number;
isLoading: boolean;
}
export const FreightVolumeSection = memo(function FreightVolumeSection({
totalTonnes,
totalValue,
currency,
ytdChangePct,
volumePoints,
maxVolume,
isLoading,
}: FreightVolumeSectionProps) {
return (
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
{isLoading ? (
<Skeleton height={32} width={180} radius="sm" />
) : (
<>
<Text fz={26} fw={800} c="edr-text">
{totalTonnes.toLocaleString()} t
</Text>
<Text fz={13} c="edr-muted">
{formatCurrency(totalValue, currency)}
</Text>
<Text fz={12} fw={700} c="edr-green.7">
{formatPct(ytdChangePct)} YTD
</Text>
</>
)}
</Group>
{isLoading ? (
<Skeleton height={110} radius="md" />
) : volumePoints.length === 0 ? (
<Box className="flex h-[110px] items-center">
<Text fz={13} c="edr-muted">
No freight volume yet.
</Text>
</Box>
) : (
<Group align="flex-end" gap={10} className="h-[110px]">
{volumePoints.map((point, i) => {
const isLast = i === volumePoints.length - 1;
return (
<Box
key={point.month}
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((point.tonnes / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">
{point.month}
</Text>
</Box>
);
})}
</Group>
)}
</Card>
);
});

View File

@@ -0,0 +1,51 @@
import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
interface HelloSectionProps {
greeting: string;
companyName: string;
}
export const HelloSection = memo(function HelloSection({
greeting,
companyName,
}: HelloSectionProps) {
return (
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
<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>
</Link>
</Group>
);
});

View File

@@ -0,0 +1,154 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
import { format } from "date-fns";
import { memo } from "react";
import { Link } from "react-router-dom";
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { cv, INVOICE_BADGE } from "../constants";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
invoices: Array<{
id: string;
number: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
dueDate: string;
paidDate?: string;
}>;
}
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
const outstandingInvoices = invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
0,
);
return (
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<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>
<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]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
) : (
<Stack gap={0}>
{invoices.map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
const dueText =
invoice.status === "Paid"
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
: 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");
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"
>
<Box>
<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>
</Group>
<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>
</Group>
<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>
</Box>
);
})}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,58 @@
import { ChevronRight } from "lucide-react";
import { Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
import { ActivityRow } from "./ActivityRow";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface RecentActivitySectionProps {
bookings: any[];
isLoading: boolean;
onBookingClick: (id: string) => void;
}
export const RecentActivitySection = memo(function RecentActivitySection({
bookings,
isLoading,
onBookingClick,
}: RecentActivitySectionProps) {
return (
<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>
<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>
{isLoading ? (
<Stack gap={10}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{bookings.slice(0, 6).map((booking) => (
<ActivityRow
key={booking.id}
booking={booking}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,40 @@
import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
interface SetupPromptProps {
show: boolean;
}
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
if (!show) return null;
return (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Text fz={15} fw={700} c="edr-text" mb={6}>
Setup your Company Profile
</Text>
<Text fz={13} c="edr-muted" mb={12}>
Complete your company information to unlock all features and start
booking shipments.
</Text>
<Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
Complete Setup
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,53 @@
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { BookingRow } from "./BookingRow";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface ShipmentsSectionProps {
bookings: any[];
isLoading: boolean;
onBookingClick: (id: string) => void;
}
export const ShipmentsSection = memo(function ShipmentsSection({
bookings,
isLoading,
onBookingClick,
}: ShipmentsSectionProps) {
return (
<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>
</Box>
</Group>
{isLoading ? (
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={64} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<Stack gap={0}>
{bookings.map((booking, i) => (
<BookingRow
key={booking.id}
booking={booking}
last={i === bookings.length - 1}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,46 @@
import { Box, Group, Text } from "@mantine/core";
import { memo } from "react";
import type { LucideIcon } from "lucide-react";
import { cv } from "../constants";
interface StatKpiProps {
icon: LucideIcon;
label: string;
value: string;
delta: string;
deltaColor: string;
divider?: boolean;
}
export const StatKpi = memo(function StatKpi({
icon: Icon,
label,
value,
delta,
deltaColor,
divider,
}: StatKpiProps) {
return (
<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>
</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>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,76 @@
import { Box, SimpleGrid } from "@mantine/core";
import {
CheckCircle2,
Clock3,
Truck,
Wallet,
} from "lucide-react";
import { memo } from "react";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
import { StatKpi } from "./StatKpi";
interface StatsSectionProps {
activeBookingsLength: number;
newActiveThisWeek: number;
bookingsLoading: boolean;
outstandingInvoicesLength: number;
totalOutstanding: number;
deliveredCount: string | undefined;
completionRate: string | undefined;
spendYtd: string | undefined;
spendYtdChangePct: number | undefined;
dashboardLoading: boolean;
}
export const StatsSection = memo(function StatsSection({
activeBookingsLength,
newActiveThisWeek,
bookingsLoading,
outstandingInvoicesLength,
totalOutstanding,
deliveredCount,
completionRate,
spendYtd,
spendYtdChangePct,
dashboardLoading,
}: StatsSectionProps) {
return (
<Card 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={bookingsLoading ? "—" : activeBookingsLength.toString()}
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
deltaColor="edr-green.7"
/>
<StatKpi
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoicesLength.toString()}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (YTD)"
value={deliveredCount ?? "—"}
delta={completionRate ? `${completionRate}% completed` : ""}
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value={spendYtd ?? "—"}
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
deltaColor="edr-green.7"
divider
/>
</SimpleGrid>
</Card>
);
});

View File

@@ -0,0 +1,42 @@
import { Box, Group } from "@mantine/core";
import { memo } from "react";
interface StepperProps {
stage: number;
color: string;
}
export const Stepper = memo(function Stepper({ stage, color }: StepperProps) {
return (
<Group gap={0} align="center" wrap="nowrap" className="h-3.5 w-full">
{[0, 1, 2, 3, 4].map((i) => {
const done = i < stage;
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>
);
})}
</Group>
);
});

View File

@@ -0,0 +1,13 @@
export { ActivityRow } from "./ActivityRow";
export { BookingRow } from "./BookingRow";
export { Card } from "./Card";
export { EmptyState } from "./EmptyState";
export { FreightVolumeSection } from "./FreightVolumeSection";
export { HelloSection } from "./HelloSection";
export { InvoicesSection } from "./InvoicesSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { SetupPrompt } from "./SetupPrompt";
export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";
export { Stepper } from "./Stepper";

View File

@@ -0,0 +1,338 @@
import {
ArrowRight,
CheckCircle2,
Clock3,
FileCheck2,
FilePen,
MapPin,
Truck,
Wallet,
type LucideIcon,
} from "lucide-react";
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
return `var(--mantine-color-${name}-${shade ?? "6"})`;
};
export const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
export const ACTIVE_STATUSES = [
"DRAFT",
"SUBMITTED",
"PENDING_APPROVAL",
"IN_TRANSIT",
];
export interface StageConfig {
stage: number;
icon: LucideIcon;
iconColor: string;
tile: string;
hint: string;
step: string;
badgeLabel: string;
badgeBg: string;
badgeText: string;
badgeDot: string;
action: {
label: string;
kind: "dark" | "amber" | "outline";
icon?: LucideIcon;
};
}
export const STATUS_CONFIG: Record<string, StageConfig> = {
DRAFT: {
stage: 0,
icon: FilePen,
iconColor: "edr-slate",
tile: "edr-slate-soft",
hint: "Draft saved · not yet submitted",
step: "edr-step",
badgeLabel: "Draft",
badgeBg: "edr-slate-soft",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "Continue", kind: "dark" },
},
SUBMITTED: {
stage: 1,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Quote being prepared by EDR",
step: "edr-blue-dot",
badgeLabel: "Reviewing",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CHANGES_REQUESTED: {
stage: 1,
icon: FilePen,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Changes requested · please update",
step: "edr-accent",
badgeLabel: "Revise",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Update", kind: "dark" },
},
PENDING_APPROVAL: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Pending internal approval",
step: "edr-blue-dot",
badgeLabel: "Pending",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
APPROVED_PENDING_SIGNATURE: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Approved · awaiting signature",
step: "edr-blue-dot",
badgeLabel: "For Signature",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review", kind: "outline" },
},
APPROVED: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Quote approved · ready to sign",
step: "edr-green.5",
badgeLabel: "Approved",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
CONTRACT_READY: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Contract ready · awaiting signature",
step: "edr-green.5",
badgeLabel: "Contract Ready",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Review", kind: "outline" },
},
SIGNED_CUSTOMER: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Signed by customer · internal processing",
step: "edr-green.5",
badgeLabel: "Signed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
FULLY_EXECUTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Fully executed · generating PNR",
step: "edr-green.5",
badgeLabel: "Executed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "PNR generated · awaiting payment verification",
step: "edr-blue-dot",
badgeLabel: "PNR Ready",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
PAYMENT_VERIFICATION_IN_PROGRESS: {
stage: 2,
icon: Clock3,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Verifying payment · please wait",
step: "edr-accent",
badgeLabel: "Verifying",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "View", kind: "outline" },
},
SELECTED_FOR_BATCH: {
stage: 2,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Selected for batch · payment due within 1 hour",
step: "edr-accent",
badgeLabel: "Pay Now",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
},
EXPIRED: {
stage: 1,
icon: Clock3,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Payment window expired · contact support",
step: "edr-red",
badgeLabel: "Expired",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PAID: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Payment received · awaiting dispatch",
step: "edr-green.5",
badgeLabel: "Paid",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
IN_TRANSIT: {
stage: 3,
icon: Truck,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "In transit · on schedule",
step: "edr-green.5",
badgeLabel: "In Transit",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Track", kind: "outline", icon: MapPin },
},
COMPLETED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Completed · awaiting delivery",
step: "edr-green.5",
badgeLabel: "Completed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View", kind: "outline" },
},
DELIVERED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Delivered · POD ready",
step: "edr-green.5",
badgeLabel: "Delivered",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View POD", kind: "outline" },
},
CANCELLED: {
stage: 0,
icon: FilePen,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Cancelled",
step: "edr-red",
badgeLabel: "Cancelled",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "View", kind: "outline" },
},
REJECTED: {
stage: 0,
icon: FilePen,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Rejected · contact support",
step: "edr-red",
badgeLabel: "Rejected",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PENDING_CONSOLIDATION: {
stage: 3,
icon: Clock3,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Awaiting consolidation",
step: "edr-blue-dot",
badgeLabel: "Consolidating",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CONSOLIDATED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Consolidated · ready for dispatch",
step: "edr-green.5",
badgeLabel: "Consolidated",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
};
export const ACTION_PROPS: Record<string, { bg: string; c: string; bd?: string }> =
{
dark: { bg: "edr-ink", c: "white" },
amber: { bg: "edr-accent", c: "white" },
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
};
export 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" },
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
};

View File

@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData() {
const { user, customer } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const bookingsQuery = useQuery(
api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" },
}),
);
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) =>
ACTIVE_STATUSES.includes(b.status),
);
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newActiveThisWeek = activeBookings.filter(
(b) => new Date(b.createdAt).getTime() >= weekAgo,
).length;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
0,
);
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 recentInvoices = myInvoices.slice(0, 3);
const dashboard = dashboardQuery.data;
const volumePoints = dashboard?.freightVolume.monthly ?? [];
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
return {
user,
customer,
bookingsQuery,
dashboardQuery,
allBookings,
activeBookings,
newActiveThisWeek,
outstandingInvoices,
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
myInvoices,
};
}

View File

@@ -0,0 +1 @@
export { default } from "./MyPortalPage";