diff --git a/src/app/(portal)/shipments/page.tsx b/src/app/(portal)/shipments/page.tsx
new file mode 100644
index 0000000..02af895
--- /dev/null
+++ b/src/app/(portal)/shipments/page.tsx
@@ -0,0 +1,58 @@
+import { Suspense } from 'react';
+import { redirect } from 'next/navigation';
+import { getTop100Shipments } from '@/services/shipments';
+import { getQuestSession, getActiveCompany } from '@/lib/permissions';
+import { ShipmentsTable } from '@/components/shipments/shipments-table';
+import { Card, CardContent } from '@/components/ui/card';
+
+export const dynamic = 'force-dynamic';
+
+async function ShipmentsData() {
+ const session = await getQuestSession();
+ const activeCompany = await getActiveCompany();
+
+ if (!session || !activeCompany) {
+ redirect('/select-company');
+ }
+
+ const shipments = await getTop100Shipments(
+ activeCompany.epicor_cust_id
+ ).catch((err) => {
+ console.error('Failed to fetch shipments:', err);
+ return [];
+ });
+
+ return ;
+}
+
+function LoadingSkeleton() {
+ return (
+
+
+
+ {[...Array(10)].map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default function ShipmentsPage() {
+ return (
+
+
Shipments
+
+ View your most recent shipments and BOL details
+
+
+
}>
+
+
+
+ );
+}
diff --git a/src/components/shipments/shipments-table.tsx b/src/components/shipments/shipments-table.tsx
new file mode 100644
index 0000000..4c9740a
--- /dev/null
+++ b/src/components/shipments/shipments-table.tsx
@@ -0,0 +1,210 @@
+'use client';
+
+import { useState } from 'react';
+import Link from 'next/link';
+import type { ShipmentRow } from '@/services/shipments';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableRow,
+} from '@/components/ui/table';
+import { Download, Search } from 'lucide-react';
+import {
+ SortableTableHead,
+ useSortableTable,
+} from '@/components/ui/sortable-table-head';
+import { formatDate } from '@/lib/utils';
+
+type Props = {
+ data: ShipmentRow[];
+};
+
+export function ShipmentsTable({ data }: Props) {
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const filteredData = data.filter((row) => {
+ const s = searchTerm.toLowerCase();
+ return (
+ row.pack_num.toLowerCase().includes(s) ||
+ row.ship_to.toLowerCase().includes(s) ||
+ row.carrier.toLowerCase().includes(s) ||
+ row.tracking_num.toLowerCase().includes(s)
+ );
+ });
+
+ const { sortKey, sortDirection, handleSort, sortedData } =
+ useSortableTable(filteredData);
+
+ const handleExportCSV = () => {
+ const headers = [
+ 'BOL #',
+ 'Ship Date',
+ 'Ship To',
+ 'Carrier',
+ 'Weight',
+ 'Tracking #',
+ ];
+
+ const rows = sortedData.map((row) => [
+ row.pack_num,
+ row.ship_date
+ ? new Date(row.ship_date).toLocaleDateString()
+ : '',
+ row.ship_to.replace(/\n/g, ', '),
+ row.carrier,
+ row.weight,
+ row.tracking_num,
+ ]);
+
+ const csvContent = [headers, ...rows]
+ .map((row) => row.map((cell) => `"${cell}"`).join(','))
+ .join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `shipments-${new Date().toISOString().split('T')[0]}.csv`;
+ a.click();
+ window.URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+ Shipments
+ Top 100 most recent shipments
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+
+
+
+
+
+ BOL #
+
+
+ Ship Date
+
+
+ Ship To
+
+
+ Carrier
+
+
+ Weight
+
+
+ Tracking #
+
+
+
+
+ {sortedData.length === 0 ? (
+
+
+ No shipments found
+
+
+ ) : (
+ sortedData.map((row, i) => (
+
+
+
+ {row.pack_num}
+
+
+
+ {row.ship_date ? formatDate(new Date(row.ship_date)) : '-'}
+
+
+ {row.ship_to || '-'}
+
+ {row.carrier || '-'}
+
+ {row.weight
+ ? `${row.weight.toLocaleString()} lbs`
+ : '-'}
+
+ {row.tracking_num || '-'}
+
+ ))
+ )}
+
+
+
+
+
+ Showing {sortedData.length} of {data.length} shipments
+
+
+
+ );
+}
diff --git a/src/services/shipments.ts b/src/services/shipments.ts
new file mode 100644
index 0000000..1cf1109
--- /dev/null
+++ b/src/services/shipments.ts
@@ -0,0 +1,56 @@
+/**
+ * Shipments Service
+ *
+ * Fetches shipment data from Epicor via the portal_GetShipmentsV1 stored procedure.
+ * SP columns are PascalCase; we map them to snake_case for the UI.
+ */
+
+import { execStoredProc, getPortalDbName } from '@/lib/epicor';
+
+export type ShipmentRow = {
+ pack_num: string;
+ ship_date: string;
+ ship_to: string;
+ carrier: string;
+ weight: number;
+ tracking_num: string;
+ [key: string]: unknown;
+};
+
+/**
+ * Map raw Epicor SP row to our normalized type.
+ * ShipToLoc comes as comma-separated; we convert to newline-separated for display.
+ */
+function mapShipmentRow(raw: Record): ShipmentRow {
+ const shipToLoc = String(raw.ShipToLoc ?? '');
+
+ return {
+ pack_num: String(raw.PackNum ?? ''),
+ ship_date: raw.ShipDate ? String(raw.ShipDate) : '',
+ ship_to: shipToLoc.replace(/, /g, '\n'),
+ carrier: String(raw.CarrierName ?? raw.Carrier ?? ''),
+ weight: Number(raw.Weight ?? 0),
+ tracking_num: String(raw.TrackingNum ?? raw.TrackingNumber ?? ''),
+ };
+}
+
+/**
+ * Get top 100 shipments for a customer.
+ * Uses portal_GetShipmentsV1 SP with CustID and DBNAME params.
+ * No SUBUSER param — all users see the same shipment data.
+ */
+export async function getTop100Shipments(
+ custId: string
+): Promise {
+ const dbName = getPortalDbName();
+
+ const result = await execStoredProc[]>(
+ 'portal_GetShipmentsV1',
+ {
+ CustID: custId,
+ DBNAME: dbName,
+ }
+ );
+
+ return result.map(mapShipmentRow);
+}