mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor: the portal dashboard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
338
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal file
338
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal 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" },
|
||||
};
|
||||
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal file
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MyPortalPage";
|
||||
Reference in New Issue
Block a user