Add shipments feature - page, components, and service layer
This commit is contained in:
parent
a9ba3791f7
commit
cd95bc4218
3 changed files with 324 additions and 0 deletions
58
src/app/(portal)/shipments/page.tsx
Normal file
58
src/app/(portal)/shipments/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/components/shipments/shipments-table.tsx
Normal file
210
src/components/shipments/shipments-table.tsx
Normal 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
56
src/services/shipments.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue