From cd95bc42186db2e327ca84e32ce24242f6495c50 Mon Sep 17 00:00:00 2001 From: Lorentz Date: Tue, 17 Feb 2026 01:43:07 +0000 Subject: [PATCH] Add shipments feature - page, components, and service layer --- src/app/(portal)/shipments/page.tsx | 58 +++++ src/components/shipments/shipments-table.tsx | 210 +++++++++++++++++++ src/services/shipments.ts | 56 +++++ 3 files changed, 324 insertions(+) create mode 100644 src/app/(portal)/shipments/page.tsx create mode 100644 src/components/shipments/shipments-table.tsx create mode 100644 src/services/shipments.ts 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); +}