Add shipments feature - page, components, and service layer
Some checks failed
Build and Deploy / build (push) Successful in 3m20s
Build and Deploy / deploy (push) Failing after 2s

This commit is contained in:
Lorentz 2026-02-17 01:43:07 +00:00
parent a9ba3791f7
commit cd95bc4218
3 changed files with 324 additions and 0 deletions

View file

@ -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 <ShipmentsTable data={shipments} />;
}
function LoadingSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
export default function ShipmentsPage() {
return (
<div>
<h1 className="mb-2 text-3xl font-bold">Shipments</h1>
<p className="mb-6 text-muted-foreground">
View your most recent shipments and BOL details
</p>
<Suspense fallback={<LoadingSkeleton />}>
<ShipmentsData />
</Suspense>
</div>
);
}

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Shipments</CardTitle>
<CardDescription>Top 100 most recent shipments</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search BOL #, ship to, carrier, or tracking #..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="pack_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
BOL #
</SortableTableHead>
<SortableTableHead
sortKey="ship_date"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Ship Date
</SortableTableHead>
<SortableTableHead
sortKey="ship_to"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Ship To
</SortableTableHead>
<SortableTableHead
sortKey="carrier"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Carrier
</SortableTableHead>
<SortableTableHead
sortKey="weight"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Weight
</SortableTableHead>
<SortableTableHead
sortKey="tracking_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Tracking #
</SortableTableHead>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
No shipments found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => (
<TableRow
key={i}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell>
<Link
href={`/shipments/${row.pack_num}`}
className="font-medium text-blue-600 hover:underline"
>
{row.pack_num}
</Link>
</TableCell>
<TableCell>
{row.ship_date ? formatDate(new Date(row.ship_date)) : '-'}
</TableCell>
<TableCell className="max-w-xs whitespace-pre-line">
{row.ship_to || '-'}
</TableCell>
<TableCell>{row.carrier || '-'}</TableCell>
<TableCell className="text-right">
{row.weight
? `${row.weight.toLocaleString()} lbs`
: '-'}
</TableCell>
<TableCell>{row.tracking_num || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} shipments
</div>
</CardContent>
</Card>
);
}

56
src/services/shipments.ts Normal file
View file

@ -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<string, unknown>): 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<ShipmentRow[]> {
const dbName = getPortalDbName();
const result = await execStoredProc<Record<string, unknown>[]>(
'portal_GetShipmentsV1',
{
CustID: custId,
DBNAME: dbName,
}
);
return result.map(mapShipmentRow);
}