feat: add sortable column headers with color-coded table headers
Some checks failed
Build and Deploy / build (push) Failing after 11m47s
Build and Deploy / deploy (push) Has been cancelled

Add reusable SortableTableHead component and useSortableTable hook
with tri-state sorting (asc/desc/none). Apply colored headers and
zebra striping to orders, inventory summary, and inventory detail
tables. Widen detail modal to 95vw.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lorentz 2026-02-16 20:35:09 +00:00
parent 9fad8af225
commit a9ba3791f7
4 changed files with 481 additions and 112 deletions

View file

@ -15,11 +15,13 @@ import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Download, Search } from 'lucide-react';
import {
SortableTableHead,
useSortableTable,
} from '@/components/ui/sortable-table-head';
type Props = {
data: InventoryDetailRow[];
@ -43,6 +45,46 @@ function formatDate(val: string | null | undefined): string {
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
type FlatDetailRow = {
CustPartNum: string;
SkidNum: string;
LotNum: string;
MfgLot: string;
Bin: string;
OnHandQty: number;
LinearFt: number;
TheoreticalWeight: number;
WIP_FG: string;
NumCoilsPerSkid: number;
CustomerPoNum: string;
SalesOrderJob: string;
AllocRelease: string;
DateProcessedToInventory: string;
};
function flattenRow(row: InventoryDetailRow): FlatDetailRow {
return {
CustPartNum: row.CustPartNum ?? '',
SkidNum: row.SkidNum ?? '',
LotNum: row.LotNum ?? '',
MfgLot: row.MfgLot ?? '',
Bin: row.Bin ?? '',
OnHandQty: Number(row.OnHandQty ?? 0),
LinearFt: Number(row.LinearFt ?? 0),
TheoreticalWeight: Number(row.TheoreticalWeight ?? 0),
WIP_FG: row.WIP_FG ?? '',
NumCoilsPerSkid: Number(row.NumCoilsPerSkid ?? 0),
CustomerPoNum: row.CustomerPoNum ?? '',
SalesOrderJob: [row.VorteqSalesOrderNum, row.VorteqJobNum]
.filter(Boolean)
.join(' / '),
AllocRelease: [row.AllocationNumber, row.ShipmentReleaseId]
.filter(Boolean)
.join(' / '),
DateProcessedToInventory: row.DateProcessedToInventory ?? '',
};
}
function DetailTableContent({
data,
partNum,
@ -52,21 +94,25 @@ function DetailTableContent({
}) {
const [searchTerm, setSearchTerm] = useState('');
const filteredData = data.filter((row) => {
const flatData = data.map(flattenRow);
const filteredData = flatData.filter((row) => {
if (!searchTerm) return true;
const s = searchTerm.toLowerCase();
return (
row.CustPartNum?.toLowerCase().includes(s) ||
row.SkidNum?.toLowerCase().includes(s) ||
row.LotNum?.toLowerCase().includes(s) ||
row.MfgLot?.toLowerCase().includes(s) ||
row.Bin?.toLowerCase().includes(s) ||
row.CustomerPoNum?.toLowerCase().includes(s) ||
row.VorteqSalesOrderNum?.toLowerCase().includes(s) ||
row.VorteqJobNum?.toLowerCase().includes(s)
row.CustPartNum.toLowerCase().includes(s) ||
row.SkidNum.toLowerCase().includes(s) ||
row.LotNum.toLowerCase().includes(s) ||
row.MfgLot.toLowerCase().includes(s) ||
row.Bin.toLowerCase().includes(s) ||
row.CustomerPoNum.toLowerCase().includes(s) ||
row.SalesOrderJob.toLowerCase().includes(s)
);
});
const { sortKey, sortDirection, handleSort, sortedData } =
useSortableTable(filteredData);
const handleExportCSV = () => {
const headers = [
'Cust Part#',
@ -80,30 +126,26 @@ function DetailTableContent({
'WIP/FG',
'# Coils Per Skid',
'PO #',
'Sales Order',
'Job Order',
'Sales Order / Job',
'Alloc # / Release Id',
'Date Processed',
];
const rows = filteredData.map((row) => [
row.CustPartNum ?? '',
row.SkidNum ?? '',
row.LotNum ?? '',
row.MfgLot ?? '',
row.Bin ?? '',
row.OnHandQty ?? '',
row.LinearFt ?? '',
row.TheoreticalWeight ?? '',
row.WIP_FG ?? '',
row.NumCoilsPerSkid ?? '',
row.CustomerPoNum ?? '',
row.VorteqSalesOrderNum ?? '',
row.VorteqJobNum ?? '',
[row.AllocationNumber, row.ShipmentReleaseId]
.filter(Boolean)
.join(' / '),
row.DateProcessedToInventory ?? '',
const rows = sortedData.map((row) => [
row.CustPartNum,
row.SkidNum,
row.LotNum,
row.MfgLot,
row.Bin,
row.OnHandQty,
row.LinearFt,
row.TheoreticalWeight,
row.WIP_FG,
row.NumCoilsPerSkid,
row.CustomerPoNum,
row.SalesOrderJob,
row.AllocRelease,
row.DateProcessedToInventory,
]);
const csvContent = [headers, ...rows]
@ -138,28 +180,130 @@ function DetailTableContent({
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<div className="overflow-x-auto overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Cust Part#</TableHead>
<TableHead>Skid#</TableHead>
<TableHead>Lot#</TableHead>
<TableHead>Mfg Lot#</TableHead>
<TableHead>Bin#</TableHead>
<TableHead className="text-right">Qty LB</TableHead>
<TableHead className="text-right">Lin. Ft</TableHead>
<TableHead className="text-right">Theo. Wt</TableHead>
<TableHead>WIP/FG</TableHead>
<TableHead className="text-right"># Coils</TableHead>
<TableHead>PO #</TableHead>
<TableHead>Sales Order / Job</TableHead>
<TableHead>Alloc # / Release</TableHead>
<TableHead>Date Processed</TableHead>
</TableRow>
</TableHeader>
<thead>
<tr className="bg-indigo-600 text-white">
<SortableTableHead
sortKey="CustPartNum"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Cust Part#
</SortableTableHead>
<SortableTableHead
sortKey="SkidNum"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Skid#
</SortableTableHead>
<SortableTableHead
sortKey="LotNum"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Lot#
</SortableTableHead>
<SortableTableHead
sortKey="MfgLot"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Mfg Lot#
</SortableTableHead>
<SortableTableHead
sortKey="Bin"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Bin#
</SortableTableHead>
<SortableTableHead
sortKey="OnHandQty"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Qty LB
</SortableTableHead>
<SortableTableHead
sortKey="LinearFt"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Lin. Ft
</SortableTableHead>
<SortableTableHead
sortKey="TheoreticalWeight"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Theo. Wt
</SortableTableHead>
<SortableTableHead
sortKey="WIP_FG"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
WIP/FG
</SortableTableHead>
<SortableTableHead
sortKey="NumCoilsPerSkid"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
# Coils
</SortableTableHead>
<SortableTableHead
sortKey="CustomerPoNum"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
PO #
</SortableTableHead>
<SortableTableHead
sortKey="SalesOrderJob"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Sales Order / Job
</SortableTableHead>
<SortableTableHead
sortKey="AllocRelease"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Alloc # / Release
</SortableTableHead>
<SortableTableHead
sortKey="DateProcessedToInventory"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date Processed
</SortableTableHead>
</tr>
</thead>
<TableBody>
{filteredData.length === 0 ? (
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={14}
@ -169,13 +313,13 @@ function DetailTableContent({
</TableCell>
</TableRow>
) : (
filteredData.map((row, i) => (
<TableRow key={i}>
<TableCell>{row.CustPartNum ?? ''}</TableCell>
<TableCell>{row.SkidNum ?? ''}</TableCell>
<TableCell>{row.LotNum ?? ''}</TableCell>
<TableCell>{row.MfgLot ?? ''}</TableCell>
<TableCell>{row.Bin ?? ''}</TableCell>
sortedData.map((row, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell>{row.CustPartNum}</TableCell>
<TableCell>{row.SkidNum}</TableCell>
<TableCell>{row.LotNum}</TableCell>
<TableCell>{row.MfgLot}</TableCell>
<TableCell>{row.Bin}</TableCell>
<TableCell className="text-right">
{formatNum(row.OnHandQty)}
</TableCell>
@ -185,21 +329,13 @@ function DetailTableContent({
<TableCell className="text-right">
{formatNum(row.TheoreticalWeight)}
</TableCell>
<TableCell>{row.WIP_FG ?? ''}</TableCell>
<TableCell>{row.WIP_FG}</TableCell>
<TableCell className="text-right">
{formatNum(row.NumCoilsPerSkid)}
</TableCell>
<TableCell>{row.CustomerPoNum ?? ''}</TableCell>
<TableCell>
{[row.VorteqSalesOrderNum, row.VorteqJobNum]
.filter(Boolean)
.join(' / ')}
</TableCell>
<TableCell>
{[row.AllocationNumber, row.ShipmentReleaseId]
.filter(Boolean)
.join(' / ')}
</TableCell>
<TableCell>{row.CustomerPoNum}</TableCell>
<TableCell>{row.SalesOrderJob}</TableCell>
<TableCell>{row.AllocRelease}</TableCell>
<TableCell>
{formatDate(row.DateProcessedToInventory)}
</TableCell>
@ -211,7 +347,7 @@ function DetailTableContent({
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {filteredData.length} of {data.length} items
Showing {sortedData.length} of {data.length} items
</div>
</>
);

View file

@ -5,8 +5,6 @@ import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
@ -22,6 +20,19 @@ import { Search, Download, Eye, Loader2 } from 'lucide-react';
import { InventorySummaryRow } from '@/services/inventory';
import type { InventoryDetailRow } from '@/services/inventory';
import { InventoryDetailTable } from './inventory-detail-table';
import {
SortableTableHead,
useSortableTable,
} from '@/components/ui/sortable-table-head';
const CATEGORY_COLORS: Record<string, string> = {
wip: 'bg-emerald-600 text-white',
'finished-goods': 'bg-blue-600 text-white',
'processed-other': 'bg-amber-600 text-white',
unprocessed: 'bg-orange-500 text-white',
'unprocessed-rr': 'bg-red-600 text-white',
'processed-rr': 'bg-rose-600 text-white',
};
type InventorySummaryTableProps = {
data: InventorySummaryRow[];
@ -49,6 +60,11 @@ export function InventorySummaryTable({
row.cust_part_num?.toLowerCase().includes(searchTerm.toLowerCase())
);
const { sortKey, sortDirection, handleSort, sortedData } =
useSortableTable(filteredData);
const headerColor = CATEGORY_COLORS[category] || 'bg-slate-700 text-white';
const handleExportCSV = () => {
const headers = [
'Plant',
@ -59,7 +75,7 @@ export function InventorySummaryTable({
'Qty LB',
'Rows',
];
const rows = filteredData.map((row) => [
const rows = sortedData.map((row) => [
row.plant,
row.cust_part_num || '',
row.part_num,
@ -143,23 +159,74 @@ export function InventorySummaryTable({
</Button>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Plant</TableHead>
<TableHead>Cust Part#</TableHead>
<TableHead>Vorteq Part#</TableHead>
<TableHead>Vorteq Desc</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead className="text-right">Qty LB</TableHead>
<TableHead className="text-right">Rows</TableHead>
<TableHead className="w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<thead>
<tr className={headerColor}>
<SortableTableHead
sortKey="plant"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Plant
</SortableTableHead>
<SortableTableHead
sortKey="cust_part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Cust Part#
</SortableTableHead>
<SortableTableHead
sortKey="part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Vorteq Part#
</SortableTableHead>
<SortableTableHead
sortKey="description"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Vorteq Desc
</SortableTableHead>
<SortableTableHead
sortKey="warehouse"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Warehouse
</SortableTableHead>
<SortableTableHead
sortKey="on_hand_qty"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Qty LB
</SortableTableHead>
<SortableTableHead
sortKey="rows"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Rows
</SortableTableHead>
<th className="w-[70px]"></th>
</tr>
</thead>
<TableBody>
{filteredData.map((row, idx) => (
<TableRow key={idx}>
{sortedData.map((row, idx) => (
<TableRow key={idx} className={idx % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell>{row.plant}</TableCell>
<TableCell>{row.cust_part_num || '-'}</TableCell>
<TableCell className="font-medium">{row.part_num}</TableCell>
@ -188,12 +255,12 @@ export function InventorySummaryTable({
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {filteredData.length} of {data.length} items
Showing {sortedData.length} of {data.length} items
</div>
{/* Detail Modal */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
<DialogContent className="max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden">
<DialogHeader>
<DialogTitle>Inventory Detail</DialogTitle>
{selectedRow && (

View file

@ -15,11 +15,13 @@ import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Download, Search } from 'lucide-react';
import {
SortableTableHead,
useSortableTable,
} from '@/components/ui/sortable-table-head';
type Props = {
data: OrderRow[];
@ -38,6 +40,9 @@ export function OrdersTable({ data }: Props) {
);
});
const { sortKey, sortDirection, handleSort, sortedData } =
useSortableTable(filteredData);
const handleExportCSV = () => {
const headers = [
'Order #',
@ -51,7 +56,7 @@ export function OrdersTable({ data }: Props) {
'Job #',
];
const rows = filteredData.map((row) => [
const rows = sortedData.map((row) => [
row.order_num?.toString() || '',
row.customer_po || '',
row.vorteq_part || '',
@ -101,23 +106,87 @@ export function OrdersTable({ data }: Props) {
</Button>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order #</TableHead>
<TableHead>Customer PO</TableHead>
<TableHead>Vorteq Part</TableHead>
<TableHead>Description</TableHead>
<TableHead>Plant</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead className="text-right">Qty Completed</TableHead>
<TableHead>Completion Date</TableHead>
<TableHead>Job #</TableHead>
</TableRow>
</TableHeader>
<thead>
<tr className="bg-slate-700 text-white">
<SortableTableHead
sortKey="order_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Order #
</SortableTableHead>
<SortableTableHead
sortKey="customer_po"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Customer PO
</SortableTableHead>
<SortableTableHead
sortKey="vorteq_part"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Vorteq Part
</SortableTableHead>
<SortableTableHead
sortKey="part_description"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Description
</SortableTableHead>
<SortableTableHead
sortKey="plant"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Plant
</SortableTableHead>
<SortableTableHead
sortKey="warehouse"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Warehouse
</SortableTableHead>
<SortableTableHead
sortKey="qty_completed"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Qty Completed
</SortableTableHead>
<SortableTableHead
sortKey="completion_date"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Completion Date
</SortableTableHead>
<SortableTableHead
sortKey="job_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Job #
</SortableTableHead>
</tr>
</thead>
<TableBody>
{filteredData.length === 0 ? (
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
@ -127,8 +196,8 @@ export function OrdersTable({ data }: Props) {
</TableCell>
</TableRow>
) : (
filteredData.map((row, i) => (
<TableRow key={i}>
sortedData.map((row, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell className="font-medium">
{row.order_num}
</TableCell>
@ -158,7 +227,7 @@ export function OrdersTable({ data }: Props) {
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {filteredData.length} of {data.length} orders
Showing {sortedData.length} of {data.length} orders
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SortDirection = 'asc' | 'desc' | null;
type SortableTableHeadProps = {
children: React.ReactNode;
sortKey: string;
currentSortKey: string | null;
currentDirection: SortDirection;
onSort: (key: string) => void;
className?: string;
};
export function SortableTableHead({
children,
sortKey,
currentSortKey,
currentDirection,
onSort,
className,
}: SortableTableHeadProps) {
const isActive = currentSortKey === sortKey;
return (
<th
className={cn(
'h-10 cursor-pointer select-none px-4 text-left align-middle text-xs font-semibold uppercase tracking-wider transition-colors hover:bg-white/10',
className
)}
onClick={() => onSort(sortKey)}
>
<div className="flex items-center gap-1">
{children}
{isActive && currentDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0" />
) : isActive && currentDirection === 'desc' ? (
<ArrowDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ArrowUpDown className="h-3.5 w-3.5 shrink-0 opacity-40" />
)}
</div>
</th>
);
}
export function useSortableTable<T>(
data: T[],
defaultSortKey: string | null = null,
defaultDirection: SortDirection = null
) {
const [sortKey, setSortKey] = useState<string | null>(defaultSortKey);
const [sortDirection, setSortDirection] =
useState<SortDirection>(defaultDirection);
const handleSort = (key: string) => {
if (sortKey === key) {
if (sortDirection === 'asc') setSortDirection('desc');
else if (sortDirection === 'desc') {
setSortKey(null);
setSortDirection(null);
} else setSortDirection('asc');
} else {
setSortKey(key);
setSortDirection('asc');
}
};
const sortedData = [...data].sort((a, b) => {
if (!sortKey || !sortDirection) return 0;
const aVal = (a as Record<string, unknown>)[sortKey];
const bVal = (b as Record<string, unknown>)[sortKey];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
let comparison = 0;
if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal;
} else if (aVal instanceof Date && bVal instanceof Date) {
comparison = aVal.getTime() - bVal.getTime();
} else {
comparison = String(aVal).localeCompare(String(bVal), undefined, {
numeric: true,
sensitivity: 'base',
});
}
return sortDirection === 'desc' ? -comparison : comparison;
});
return { sortKey, sortDirection, handleSort, sortedData };
}