Enhance sticky columns in import, enhance forecasting page

This commit is contained in:
2026-04-02 16:20:24 -04:00
parent e43abdafd0
commit 4b2b3d5a9f
6 changed files with 1809 additions and 633 deletions

View File

@@ -163,6 +163,148 @@ router.get('/forecast', async (req, res) => {
}
});
// Enhanced forecast endpoint: returns flat product list with line/artist/category/metrics
// for client-side grouping by line or category
router.get('/forecast-v2', async (req, res) => {
try {
const pool = req.app.locals.pool;
const brand = (req.query.brand || '').toString();
const startDateStr = req.query.startDate;
const endDateStr = req.query.endDate;
if (!brand) {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
const endDate = endDateStr ? new Date(endDateStr) : new Date();
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
const sql = `
WITH params AS (
SELECT $1::date AS start_date, $2::date AS end_date, $3::text AS brand
),
category_path AS (
WITH RECURSIVE cp AS (
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
FROM categories c WHERE c.parent_id IS NULL
UNION ALL
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
FROM categories c JOIN cp ON c.parent_id = cp.cat_id
)
SELECT * FROM cp
),
product_first_received AS (
SELECT
p.pid,
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
FROM products p
LEFT JOIN receivings r ON r.pid = p.pid
GROUP BY p.pid, p.first_received
),
recent_products AS (
SELECT p.pid
FROM products p
JOIN product_first_received fr ON fr.pid = p.pid
JOIN params pr ON 1=1
WHERE p.visible = true
AND COALESCE(p.brand,'Unbranded') = pr.brand
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
),
product_pick_category AS (
(
SELECT DISTINCT ON (pc.pid)
pc.pid,
c.name AS category_name,
COALESCE(cp.path, c.name) AS path
FROM product_categories pc
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
WHERE pc.pid IN (SELECT pid FROM recent_products)
AND (cp.path IS NULL OR (
cp.path NOT ILIKE '%Black Friday%'
AND cp.path NOT ILIKE '%Deals%'
))
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
)
UNION ALL
(
SELECT rp.pid, 'Uncategorized'::text, 'Uncategorized'::text
FROM recent_products rp
WHERE NOT EXISTS (
SELECT 1
FROM product_categories pc
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
WHERE pc.pid = rp.pid
AND (cp.path IS NULL OR (
cp.path NOT ILIKE '%Black Friday%'
AND cp.path NOT ILIKE '%Deals%'
))
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
)
)
)
SELECT
p.pid, p.title, p.sku,
COALESCE(p.line, '') AS line,
COALESCE(p.artist, '') AS artist,
ppc.category_name,
ppc.path AS category_path,
COALESCE(pm.lifetime_sales, 0) AS lifetime_sales,
COALESCE(pm.first_30_days_sales, 0) AS first_30d_sales,
COALESCE(pm.first_60_days_sales, 0) AS first_60d_sales,
COALESCE(pm.first_90_days_sales, 0) AS first_90d_sales,
COALESCE(pm.current_stock, 0) AS current_stock,
COALESCE(pm.date_first_received, pfr.first_received_date)::date AS date_first_received,
COALESCE(p.stock_quantity, 0) AS stock_quantity,
COALESCE(p.price, 0) AS price
FROM recent_products rp
JOIN products p ON p.pid = rp.pid
JOIN product_pick_category ppc ON ppc.pid = p.pid
LEFT JOIN product_metrics pm ON pm.pid = p.pid
LEFT JOIN product_first_received pfr ON pfr.pid = p.pid
ORDER BY COALESCE(p.line, ''), ppc.category_name, pm.lifetime_sales DESC NULLS LAST
`;
const { rows } = await pool.query(sql, [startISO, endISO, brand]);
// Collect distinct artists
const artistSet = new Set();
const products = rows.map(r => {
if (r.artist) artistSet.add(r.artist);
return {
pid: String(r.pid),
title: r.title,
sku: r.sku,
line: r.line || '',
artist: r.artist || '',
category: r.category_name,
categoryPath: r.category_path,
lifetimeSales: Number(r.lifetime_sales) || 0,
first30dSales: Number(r.first_30d_sales) || 0,
first60dSales: Number(r.first_60d_sales) || 0,
first90dSales: Number(r.first_90d_sales) || 0,
currentStock: Number(r.current_stock) || 0,
dateFirstReceived: r.date_first_received || null,
price: Number(r.price) || 0,
};
});
res.json({
products,
artists: [...artistSet].sort(),
});
} catch (error) {
console.error('Error fetching forecast-v2 data:', error);
res.status(500).json({ error: 'Failed to fetch forecast data' });
}
});
// ─── Inventory Intelligence Endpoints ────────────────────────────────────────
// Inventory KPI summary cards

View File

@@ -134,11 +134,10 @@ router.post('/', async (req, res) => {
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
// Check for unique constraint violation (PostgreSQL error code 23505)
if (error?.code === '23505') {
return res.status(409).json({
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
});
}
res.status(500).json({
@@ -232,11 +231,10 @@ router.put('/:id', async (req, res) => {
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
// Check for unique constraint violation (PostgreSQL error code 23505)
if (error?.code === '23505') {
return res.status(409).json({
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
});
}
res.status(500).json({

View File

@@ -3,182 +3,814 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface Product {
// ─── Raw product from API ───────────────────────────────────────────────────
export interface ProductDetail {
pid: string;
sku: string;
title: string;
total_sold: number;
}
export interface ForecastItem {
sku: string;
line: string;
artist: string;
category: string;
categoryPath: string;
totalSold: number;
numProducts: number;
avgTotalSold: number;
minSold: number;
maxSold: number;
products?: Product[];
lifetimeSales: number;
first30dSales: number;
first60dSales: number;
first90dSales: number;
currentStock: number;
dateFirstReceived: string | null;
price: number;
}
export const columns: ColumnDef<ForecastItem>[] = [
// ─── Grouped row types ──────────────────────────────────────────────────────
export interface GroupStats {
productCount: number;
avgLifetimeSales: number;
medianLifetimeSales: number;
avgFirst30dSales: number;
minSales: number;
maxSales: number;
totalSales: number;
}
export interface CategoryBreakdown extends GroupStats {
category: string;
categoryPath: string;
products: ProductDetail[];
}
/** Line view: one row per collection */
export interface LineGroup extends GroupStats {
line: string;
artist: string;
dateFirstReceived: string | null;
categories: CategoryBreakdown[];
products: ProductDetail[];
}
/** Category view: one row per product type (enhanced) */
export interface CategoryGroup extends GroupStats {
category: string;
categoryPath: string;
products: ProductDetail[];
}
/** Designer view: one row per artist, with line + category breakdowns */
export interface LineSummary extends GroupStats {
line: string;
dateFirstReceived: string | null;
}
export interface DesignerGroup extends GroupStats {
artist: string;
lineCount: number;
lines: LineSummary[];
categories: CategoryBreakdown[];
products: ProductDetail[];
}
/** Cross-line average (one per category across all lines) */
export interface CrossLineAverage {
category: string;
lineCount: number;
avgProductCount: number;
avgLifetimeSales: number;
medianLifetimeSales: number;
avgFirst30dSales: number;
minAvgSales: number;
maxAvgSales: number;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
export function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function computeGroupStats(products: ProductDetail[]): GroupStats {
const sales = products.map(p => p.lifetimeSales);
const total = sales.reduce((s, v) => s + v, 0);
return {
productCount: products.length,
avgLifetimeSales: products.length > 0 ? total / products.length : 0,
medianLifetimeSales: median(sales),
avgFirst30dSales: products.length > 0
? products.reduce((s, p) => s + p.first30dSales, 0) / products.length
: 0,
minSales: sales.length > 0 ? Math.min(...sales) : 0,
maxSales: sales.length > 0 ? Math.max(...sales) : 0,
totalSales: total,
};
}
// ─── Grouping logic ─────────────────────────────────────────────────────────
export function groupByLine(products: ProductDetail[]): LineGroup[] {
const lineMap = new Map<string, ProductDetail[]>();
for (const p of products) {
const key = p.line || '(No Line)';
if (!lineMap.has(key)) lineMap.set(key, []);
lineMap.get(key)!.push(p);
}
return [...lineMap.entries()].map(([line, prods]) => {
// Build category breakdown within this line
const catMap = new Map<string, ProductDetail[]>();
for (const p of prods) {
const key = p.category || 'Uncategorized';
if (!catMap.has(key)) catMap.set(key, []);
catMap.get(key)!.push(p);
}
const categories: CategoryBreakdown[] = [...catMap.entries()]
.map(([cat, catProds]) => ({
category: cat,
categoryPath: catProds[0]?.categoryPath || '',
...computeGroupStats(catProds),
products: catProds,
}))
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
const stats = computeGroupStats(prods);
const firstArtist = prods.find(p => p.artist)?.artist || '';
const dates = prods
.map(p => p.dateFirstReceived)
.filter(Boolean)
.sort();
return {
line,
artist: firstArtist,
dateFirstReceived: dates[0] || null,
...stats,
categories,
products: prods,
};
}).sort((a, b) => {
// Sort by received date descending (newest first)
if (a.dateFirstReceived && b.dateFirstReceived) {
return b.dateFirstReceived.localeCompare(a.dateFirstReceived);
}
if (a.dateFirstReceived) return -1;
if (b.dateFirstReceived) return 1;
return b.avgLifetimeSales - a.avgLifetimeSales;
});
}
export function groupByCategory(products: ProductDetail[]): CategoryGroup[] {
const catMap = new Map<string, ProductDetail[]>();
for (const p of products) {
const key = p.category || 'Uncategorized';
if (!catMap.has(key)) catMap.set(key, []);
catMap.get(key)!.push(p);
}
return [...catMap.entries()]
.map(([cat, prods]) => ({
category: cat,
categoryPath: prods[0]?.categoryPath || '',
...computeGroupStats(prods),
products: prods,
}))
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
}
export function groupByDesigner(products: ProductDetail[]): DesignerGroup[] {
const artistMap = new Map<string, ProductDetail[]>();
for (const p of products) {
const key = p.artist || '(Unknown Designer)';
if (!artistMap.has(key)) artistMap.set(key, []);
artistMap.get(key)!.push(p);
}
return [...artistMap.entries()]
.map(([artist, prods]) => {
// Line breakdown within this designer
const lineMap = new Map<string, ProductDetail[]>();
for (const p of prods) {
const key = p.line || '(No Line)';
if (!lineMap.has(key)) lineMap.set(key, []);
lineMap.get(key)!.push(p);
}
const lines: LineSummary[] = [...lineMap.entries()]
.map(([line, lineProds]) => {
const dates = lineProds.map(p => p.dateFirstReceived).filter(Boolean).sort();
return {
line,
dateFirstReceived: dates[0] || null,
...computeGroupStats(lineProds),
};
})
.sort((a, b) => {
if (a.dateFirstReceived && b.dateFirstReceived)
return b.dateFirstReceived.localeCompare(a.dateFirstReceived);
if (a.dateFirstReceived) return -1;
if (b.dateFirstReceived) return 1;
return b.avgLifetimeSales - a.avgLifetimeSales;
});
// Category breakdown across all this designer's lines
const catMap = new Map<string, ProductDetail[]>();
for (const p of prods) {
const key = p.category || 'Uncategorized';
if (!catMap.has(key)) catMap.set(key, []);
catMap.get(key)!.push(p);
}
const categories: CategoryBreakdown[] = [...catMap.entries()]
.map(([cat, catProds]) => ({
category: cat,
categoryPath: catProds[0]?.categoryPath || '',
...computeGroupStats(catProds),
products: catProds,
}))
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
return {
artist,
lineCount: lines.filter(l => l.line !== '(No Line)').length,
...computeGroupStats(prods),
lines,
categories,
products: prods,
};
})
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
}
export function computeCrossLineAverages(lines: LineGroup[]): CrossLineAverage[] {
// For each category that appears across lines, compute the average of each line's average
const catLineData = new Map<string, { avgSales: number[]; avgFirst30d: number[]; productCounts: number[] }>();
for (const line of lines) {
if (line.line === '(No Line)') continue;
for (const cat of line.categories) {
if (!catLineData.has(cat.category)) {
catLineData.set(cat.category, { avgSales: [], avgFirst30d: [], productCounts: [] });
}
const d = catLineData.get(cat.category)!;
d.avgSales.push(cat.avgLifetimeSales);
d.avgFirst30d.push(cat.avgFirst30dSales);
d.productCounts.push(cat.productCount);
}
}
return [...catLineData.entries()]
.filter(([, d]) => d.avgSales.length >= 2) // Need at least 2 lines to be meaningful
.map(([category, d]) => ({
category,
lineCount: d.avgSales.length,
avgProductCount: d.productCounts.reduce((s, v) => s + v, 0) / d.productCounts.length,
avgLifetimeSales: d.avgSales.reduce((s, v) => s + v, 0) / d.avgSales.length,
medianLifetimeSales: median(d.avgSales),
avgFirst30dSales: d.avgFirst30d.reduce((s, v) => s + v, 0) / d.avgFirst30d.length,
minAvgSales: Math.min(...d.avgSales),
maxAvgSales: Math.max(...d.avgSales),
}))
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
}
// ─── Line view columns ─────────────────────────────────────────────────────
const fmt = (v: number, decimals = 0) =>
v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
export const lineColumns: ColumnDef<LineGroup>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.getCanExpand() ? (
<Button
variant="ghost"
onClick={() => row.toggleExpanded()}
className="p-0 h-auto"
>
cell: ({ row }) =>
row.getCanExpand() ? (
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
) : null;
) : null,
},
{
accessorKey: "line",
header: "Line / Collection",
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.line}</div>
{row.original.artist && (
<div className="text-xs text-muted-foreground">{row.original.artist}</div>
)}
</div>
),
},
{
id: "received",
header: "Received",
cell: ({ row }) => {
const d = row.original.dateFirstReceived;
if (!d) return <span className="text-muted-foreground"></span>;
return <span className="text-sm whitespace-nowrap">{new Date(d).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>;
},
},
{
accessorKey: "productCount",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.productCount),
},
{
accessorKey: "avgLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
},
{
accessorKey: "medianLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Median <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
},
{
accessorKey: "avgFirst30dSales",
header: ({ column }) => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
</Tooltip>
</TooltipProvider>
),
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
},
{
accessorKey: "minSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Min <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.minSales),
},
{
accessorKey: "maxSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Max <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.maxSales),
},
{
accessorKey: "totalSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Total <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.totalSales),
},
];
// ─── Category view columns (enhanced) ───────────────────────────────────────
export const categoryColumns: ColumnDef<CategoryGroup>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
) : null,
},
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.category}</div>
{row.original.categoryPath && (
<div className="text-sm text-muted-foreground">
{row.original.categoryPath}
</div>
{row.original.categoryPath && row.original.categoryPath !== row.original.category && (
<div className="text-xs text-muted-foreground">{row.original.categoryPath}</div>
)}
</div>
),
},
{
accessorKey: "avgTotalSold",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="whitespace-nowrap"
>
Avg Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgTotalSold") as number;
return value?.toFixed(2) || "0.00";
},
accessorKey: "productCount",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.productCount),
},
{
accessorKey: "minSold",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="whitespace-nowrap"
>
Min Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("minSold") as number;
return value?.toLocaleString() || "0";
},
accessorKey: "avgLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
},
{
accessorKey: "maxSold",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="whitespace-nowrap"
>
Max Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("maxSold") as number;
return value?.toLocaleString() || "0";
},
accessorKey: "medianLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Median <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
},
{
accessorKey: "totalSold",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("totalSold") as number;
return value?.toLocaleString() || "0";
},
accessorKey: "avgFirst30dSales",
header: ({ column }) => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
</Tooltip>
</TooltipProvider>
),
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
},
{
accessorKey: "numProducts",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="whitespace-nowrap"
>
# Products
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("numProducts") as number;
return value?.toLocaleString() || "0";
},
accessorKey: "minSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Min <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.minSales),
},
{
accessorKey: "maxSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Max <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.maxSales),
},
{
accessorKey: "totalSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Total <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.totalSales),
},
];
export const renderSubComponent = ({ row }: { row: any }) => {
const products = row.original.products || [];
// ─── Sub-component for Line view: category breakdown + product list ─────────
export function LineSubComponent({ row }: { row: { original: LineGroup } }) {
const { categories, products } = row.original;
return (
<ScrollArea className="h-[400px] w-full rounded-md border p-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="text-right">Sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product: Product) => (
<TableRow key={product.pid}>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
<div className="p-4 space-y-4 bg-muted/30">
{/* Category breakdown summary */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
Category breakdown {categories.length} categories, {products.length} products
</p>
<div className="rounded border bg-background">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="py-1.5">Category</TableHead>
<TableHead className="py-1.5 text-right"># Products</TableHead>
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
<TableHead className="py-1.5 text-right">Median</TableHead>
<TableHead className="py-1.5 text-right">First 30d</TableHead>
<TableHead className="py-1.5 text-right">Min</TableHead>
<TableHead className="py-1.5 text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((cat) => (
<TableRow key={cat.category} className="text-xs">
<TableCell className="py-1.5 font-medium">{cat.category}</TableCell>
<TableCell className="py-1.5 text-right">{cat.productCount}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.avgLifetimeSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.medianLifetimeSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.avgFirst30dSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.minSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.maxSales)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Full product list */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">All products in this line</p>
<ScrollArea className="h-[350px] rounded border bg-background">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="text-xs">
<TableHead className="py-1.5">Product</TableHead>
<TableHead className="py-1.5">Category</TableHead>
<TableHead className="py-1.5 text-right">Lifetime</TableHead>
<TableHead className="py-1.5 text-right">First 30d</TableHead>
<TableHead className="py-1.5 text-right">Stock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products
.sort((a, b) => b.lifetimeSales - a.lifetimeSales)
.map((p) => (
<TableRow key={p.pid} className="text-xs">
<TableCell className="py-1.5 max-w-[300px]">
<a
href={`https://backend.acherryontop.com/product/${p.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline block truncate"
>
{p.title}
</a>
<span className="text-muted-foreground">{p.sku}</span>
</TableCell>
<TableCell className="py-1.5 text-muted-foreground">{p.category}</TableCell>
<TableCell className="py-1.5 text-right font-medium">{fmt(p.lifetimeSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(p.first30dSales)}</TableCell>
<TableCell className="py-1.5 text-right">
<span className={p.currentStock === 0 ? 'text-red-500' : ''}>
{fmt(p.currentStock)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</div>
);
};
}
// ─── Sub-component for Category view: product list with richer detail ────────
export function CategorySubComponent({ row }: { row: { original: CategoryGroup } }) {
const { products } = row.original;
return (
<div className="p-4 bg-muted/30">
<p className="text-xs font-medium text-muted-foreground mb-2">
{products.length} products
</p>
<ScrollArea className="h-[350px] rounded border bg-background">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="text-xs">
<TableHead className="py-1.5">Product</TableHead>
<TableHead className="py-1.5">Line</TableHead>
<TableHead className="py-1.5 text-right">Lifetime</TableHead>
<TableHead className="py-1.5 text-right">First 30d</TableHead>
<TableHead className="py-1.5 text-right">First 90d</TableHead>
<TableHead className="py-1.5 text-right">Stock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products
.sort((a, b) => b.lifetimeSales - a.lifetimeSales)
.map((p) => (
<TableRow key={p.pid} className="text-xs">
<TableCell className="py-1.5 max-w-[280px]">
<a
href={`https://backend.acherryontop.com/product/${p.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline block truncate"
>
{p.title}
</a>
<span className="text-muted-foreground">{p.sku}</span>
</TableCell>
<TableCell className="py-1.5 text-muted-foreground text-xs">{p.line || '—'}</TableCell>
<TableCell className="py-1.5 text-right font-medium">{fmt(p.lifetimeSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(p.first30dSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(p.first90dSales)}</TableCell>
<TableCell className="py-1.5 text-right">
<span className={p.currentStock === 0 ? 'text-red-500' : ''}>
{fmt(p.currentStock)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
);
}
// ─── Designer view columns ──────────────────────────────────────────────────
export const designerColumns: ColumnDef<DesignerGroup>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
) : null,
},
{
accessorKey: "artist",
header: "Designer",
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.artist}</div>
<div className="text-xs text-muted-foreground">
{row.original.lineCount} line{row.original.lineCount !== 1 ? 's' : ''}
</div>
</div>
),
},
{
accessorKey: "productCount",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.productCount),
},
{
accessorKey: "avgLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
},
{
accessorKey: "medianLifetimeSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Median <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
},
{
accessorKey: "avgFirst30dSales",
header: ({ column }) => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
</Tooltip>
</TooltipProvider>
),
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
},
{
accessorKey: "minSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Min <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.minSales),
},
{
accessorKey: "maxSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Max <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.maxSales),
},
{
accessorKey: "totalSales",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
Total <ArrowUpDown className="ml-1 h-4 w-4" />
</Button>
),
cell: ({ row }) => fmt(row.original.totalSales),
},
];
// ─── Sub-component for Designer view ────────────────────────────────────────
export function DesignerSubComponent({ row }: { row: { original: DesignerGroup } }) {
const { lines, categories } = row.original;
return (
<div className="p-4 space-y-4 bg-muted/30">
{/* Per-line performance */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
Line performance {lines.length} lines
</p>
<div className="rounded border bg-background">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="py-1.5">Line</TableHead>
<TableHead className="py-1.5">Received</TableHead>
<TableHead className="py-1.5 text-right"># Products</TableHead>
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
<TableHead className="py-1.5 text-right">Median</TableHead>
<TableHead className="py-1.5 text-right">First 30d</TableHead>
<TableHead className="py-1.5 text-right">Min</TableHead>
<TableHead className="py-1.5 text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((l) => (
<TableRow key={l.line} className="text-xs">
<TableCell className="py-1.5 font-medium">{l.line}</TableCell>
<TableCell className="py-1.5 text-muted-foreground whitespace-nowrap">
{l.dateFirstReceived
? new Date(l.dateFirstReceived).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
: '—'}
</TableCell>
<TableCell className="py-1.5 text-right">{l.productCount}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(l.avgLifetimeSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(l.medianLifetimeSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(l.avgFirst30dSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(l.minSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(l.maxSales)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Category averages across all their lines */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
Category averages across all {row.original.artist} lines
</p>
<div className="rounded border bg-background">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="py-1.5">Category</TableHead>
<TableHead className="py-1.5 text-right"># Products</TableHead>
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
<TableHead className="py-1.5 text-right">Median</TableHead>
<TableHead className="py-1.5 text-right">First 30d</TableHead>
<TableHead className="py-1.5 text-right">Min</TableHead>
<TableHead className="py-1.5 text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((cat) => (
<TableRow key={cat.category} className="text-xs">
<TableCell className="py-1.5 font-medium">{cat.category}</TableCell>
<TableCell className="py-1.5 text-right">{cat.productCount}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.avgLifetimeSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.medianLifetimeSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.avgFirst30dSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.minSales)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(cat.maxSales)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
);
}
// ─── Legacy exports for backward compatibility with QuickOrderBuilder ───────
// The old ForecastItem type mapped to CategoryGroup
export type ForecastItem = CategoryGroup;
export const columns = categoryColumns;
export const renderSubComponent = CategorySubComponent;

View File

@@ -1014,7 +1014,9 @@ CellWrapper.displayName = 'CellWrapper';
* Template column width
*/
const TEMPLATE_COLUMN_WIDTH = 200;
const NAME_COLUMN_STICKY_LEFT = 0;
/** Maximum number of columns that can be pinned simultaneously */
const MAX_PINNED_COLUMNS = 3;
/**
* TemplateCell Component
@@ -1273,6 +1275,15 @@ TemplateCell.displayName = 'TemplateCell';
* for a given row. It passes all data down to CellWrapper as props.
* This reduces subscriptions from ~2100 (7 per cell) to ~150 (5 per row).
*/
/** Layout info for a single pinned column */
type PinnedColumnEntry = {
left: number; // Cumulative left offset for sticky-left positioning
right: number; // Cumulative right offset for sticky-right positioning
naturalLeft: number; // Natural left position in the full-width row (for stuck detection)
width: number;
zIndex: number;
};
interface VirtualRowProps {
rowIndex: number;
rowId: string;
@@ -1280,10 +1291,14 @@ interface VirtualRowProps {
columns: ColumnDef<RowData>[];
fields: Field<string>[];
totalRowCount: number;
/** Whether the name column sticky behavior is enabled */
nameColumnSticky: boolean;
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
stickyDirection: 'left' | 'right' | null;
/** Map of field key → sticky layout for all currently pinned columns */
pinnedColumnLayout: Map<string, PinnedColumnEntry>;
/** Per-column stick direction: only columns that are actually stuck appear in this map */
stickyStates: Map<string, 'left' | 'right'>;
/** Field key of the left-shadow column (rightmost column stuck left) */
shadowLeftKey: string | null;
/** Field key of the right-shadow column (leftmost column stuck right) */
shadowRightKey: string | null;
}
const VirtualRow = memo(({
@@ -1293,8 +1308,10 @@ const VirtualRow = memo(({
columns,
fields,
totalRowCount,
nameColumnSticky,
stickyDirection,
pinnedColumnLayout,
stickyStates,
shadowLeftKey,
shadowRightKey,
}: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore(
@@ -1478,10 +1495,14 @@ const VirtualRow = memo(({
const isNameColumn = field.key === 'name';
// Determine sticky behavior for name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
// Determine sticky behavior per column (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
const pinnedEntry = pinnedColumnLayout.get(field.key);
const columnStickDir = pinnedEntry ? stickyStates.get(field.key) : undefined;
const shouldBeSticky = !!columnStickDir;
const stickyLeft = columnStickDir === 'left';
const stickyRight = columnStickDir === 'right';
// Shadow on the outermost actually-stuck column per side
const isShadowColumn = field.key === shadowLeftKey || field.key === shadowRightKey;
return (
<div
@@ -1492,16 +1513,13 @@ const VirtualRow = memo(({
// Use box-shadow for right border - renders more consistently with transforms
// last:shadow-none removes the shadow from the last cell
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
// Name column needs overflow-visible for the floating AI suggestion badge
// Description handles AI suggestions inside its popover, so no overflow needed
isNameColumn ? "overflow-visible" : "overflow-hidden",
// Name column sticky behavior - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky lg:z-10",
// Add left border when sticky-right since content scrolls behind from the left
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
// Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
// overflow-visible needed for: name column (AI suggestion badge) and shadow columns (drop shadow)
(isNameColumn || isShadowColumn) ? "overflow-visible" : "overflow-hidden",
// Pinned column sticky behavior - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky",
// Drop shadow only on the outermost actually-stuck column
isShadowColumn && stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
isShadowColumn && stickyRight && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
// Solid background when sticky to overlay content
// Use explicit [background:] syntax for consistent specificity
// Selection (blue) takes priority over errors (red)
@@ -1519,9 +1537,11 @@ const VirtualRow = memo(({
width: columnWidth,
minWidth: columnWidth,
flexShrink: 0,
// Z-index layered per pinned column so rightmost renders on top
...(shouldBeSticky && { zIndex: pinnedEntry!.zIndex }),
// Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
...(stickyLeft && { left: pinnedEntry!.left }),
...(stickyRight && { right: pinnedEntry!.right }),
}}
>
<CellWrapper
@@ -1652,6 +1672,8 @@ interface PriceColumnHeaderProps {
fieldKey: 'msrp' | 'cost_each';
label: string;
isRequired: boolean;
pinButton?: React.ReactNode;
isPinned?: boolean;
}
const MSRP_MULTIPLIERS = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
@@ -1667,7 +1689,7 @@ const roundToNine = (value: number): number => {
return wholePart + (tenths / 10) + 0.09;
};
const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: PriceColumnHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [hasFillableCells, setHasFillableCells] = useState(false);
@@ -1788,7 +1810,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
return (
<div
className="flex items-center gap-1 truncate w-full group relative"
className="flex items-center gap-1 min-w-0 w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => {
if (!isPopoverOpen) setIsHovered(false);
@@ -1798,111 +1820,109 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{(isHovered || isPopoverOpen) && hasFillableCells && (
isMsrp ? (
// MSRP: Show popover with multiplier options
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
{(isHovered || isPopoverOpen) && hasFillableCells && (
isMsrp ? (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-52 p-3" align="end">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Calculate MSRP from Cost
</p>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Multiplier</p>
<div className="grid grid-cols-3 gap-1">
{MSRP_MULTIPLIERS.map((m) => (
<Button
key={m}
variant={selectedMultiplier === m ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedMultiplier(m)}
>
{m}x
</Button>
))}
</div>
</div>
{selectedMultiplier > 2.0 && (
<div className="flex items-center gap-2">
<Checkbox
id="round-to-nine"
checked={shouldRoundToNine}
onCheckedChange={(checked) => setShouldRoundToNine(checked === true)}
/>
<label htmlFor="round-to-nine" className="text-xs cursor-pointer">
Round to .X9 (e.g., 12.39)
</label>
</div>
)}
{selectedMultiplier === 2.0 && (
<p className="text-xs text-muted-foreground">
Auto-adjusts ±1¢ for .99 pricing
</p>
)}
<Button
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleCalculateMsrp(selectedMultiplier, shouldRoundToNine)}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
>
<Calculator className="h-3 w-3" />
</button>
</PopoverTrigger>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCalculateCostEach();
}}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-52 p-3" align="end">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Calculate MSRP from Cost
</p>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Multiplier</p>
<div className="grid grid-cols-3 gap-1">
{MSRP_MULTIPLIERS.map((m) => (
<Button
key={m}
variant={selectedMultiplier === m ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedMultiplier(m)}
>
{m}x
</Button>
))}
</div>
</div>
{selectedMultiplier > 2.0 && (
<div className="flex items-center gap-2">
<Checkbox
id="round-to-nine"
checked={shouldRoundToNine}
onCheckedChange={(checked) => setShouldRoundToNine(checked === true)}
/>
<label htmlFor="round-to-nine" className="text-xs cursor-pointer">
Round to .X9 (e.g., 12.39)
</label>
</div>
)}
{selectedMultiplier === 2.0 && (
<p className="text-xs text-muted-foreground">
Auto-adjusts ±1¢ for .99 pricing
</p>
)}
<Button
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleCalculateMsrp(selectedMultiplier, shouldRoundToNine)}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
) : (
// Cost Each: Simple click behavior
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCalculateCostEach();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
)
)}
{pinButton}
</div>
</div>
);
});
@@ -1923,6 +1943,8 @@ interface UnitConversionColumnHeaderProps {
fieldKey: 'weight' | 'length' | 'width' | 'height';
label: string;
isRequired: boolean;
pinButton?: React.ReactNode;
isPinned?: boolean;
}
type ConversionOption = {
@@ -1942,7 +1964,7 @@ const DIMENSION_CONVERSIONS: ConversionOption[] = [
{ label: 'Millimeters → Inches', factor: 0.0393701, roundTo: 2 },
];
const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitConversionColumnHeaderProps) => {
const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: UnitConversionColumnHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [hasConvertibleCells, setHasConvertibleCells] = useState(false);
@@ -2008,7 +2030,7 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
return (
<div
className="flex items-center gap-1 truncate w-full group relative"
className="flex items-center gap-1 min-w-0 w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => {
if (!isPopoverOpen) setIsHovered(false);
@@ -2018,54 +2040,56 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Scale className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Convert units for entire column</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-48 p-2" align="end">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground mb-2">
Convert {isWeightField ? 'Weight' : 'Dimensions'}
</p>
{conversions.map((conversion) => (
<Button
key={conversion.label}
variant="ghost"
size="sm"
className="w-full justify-start text-xs h-7"
onClick={() => handleConversion(conversion)}
>
<Scale className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Convert units for entire column</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-48 p-2" align="end">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground mb-2">
Convert {isWeightField ? 'Weight' : 'Dimensions'}
</p>
{conversions.map((conversion) => (
<Button
key={conversion.label}
variant="ghost"
size="sm"
className="w-full justify-start text-xs h-7"
onClick={() => handleConversion(conversion)}
>
{conversion.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
)}
{conversion.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
)}
{pinButton}
</div>
</div>
);
});
@@ -2084,6 +2108,8 @@ interface DefaultValueColumnHeaderProps {
fieldKey: 'tax_cat' | 'ship_restrictions';
label: string;
isRequired: boolean;
pinButton?: React.ReactNode;
isPinned?: boolean;
}
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
@@ -2091,7 +2117,7 @@ const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string;
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
};
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: DefaultValueColumnHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
const [hasEmptyCells, setHasEmptyCells] = useState(false);
@@ -2139,7 +2165,7 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
return (
<div
className="flex items-center gap-1 truncate w-full group relative"
className="flex items-center gap-1 min-w-0 w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
@@ -2147,33 +2173,36 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{isHovered && hasEmptyCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetDefault();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity whitespace-nowrap'
)}
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Fill empty cells with "{config.displayName}"</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
{isHovered && hasEmptyCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetDefault();
}}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'whitespace-nowrap'
)}
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Fill empty cells with "{config.displayName}"</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{pinButton}
</div>
</div>
);
});
@@ -2181,55 +2210,49 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
/**
* NameColumnHeader Component
*
* Renders the Name column header with a sticky toggle button.
* Pin icon toggles whether the name column sticks to edges when scrolling.
* PinButton Component - renders a pin/unpin toggle for any column header.
* Hidden by default, visible on column header hover (via parent `group` class).
* Always visible when the column is pinned.
*/
interface NameColumnHeaderProps {
label: string;
isRequired: boolean;
isSticky: boolean;
onToggleSticky: () => void;
interface PinButtonProps {
isPinned: boolean;
onToggle: () => void;
}
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
const PinButton = memo(({ isPinned, onToggle }: PinButtonProps) => {
return (
<div className="flex items-center gap-1 truncate w-full group relative">
<span className="truncate">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleSticky();
}}
className={cn(
'ml-auto flex items-center justify-center w-6 h-6 rounded',
'transition-colors',
isSticky
? 'text-primary bg-primary/10 hover:bg-primary/20'
: 'text-muted-foreground hover:bg-muted'
)}
>
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className={cn(
// Matches existing action button style (Calculator, Scale, Wand2)
// No absolute positioning — parent container handles placement
'flex items-center justify-center',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity',
// Visible when pinned, otherwise show on hover (via parent group)
isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
>
{isPinned ? <Pin className="h-3 w-3" /> : <PinOff className="h-3 w-3" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isPinned ? 'Unpin column' : 'Pin column'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
NameColumnHeader.displayName = 'NameColumnHeader';
PinButton.displayName = 'PinButton';
/**
* Main table component
@@ -2250,57 +2273,145 @@ export const ValidationTable = () => {
const tableContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
// Calculate name column's natural left position (before it becomes sticky)
// Selection (40) + Template (200) + all field columns before 'name'
const nameColumnLeftOffset = useMemo(() => {
let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
// Set of currently pinned field keys — defaults to 'name' pinned (same as before)
const [pinnedColumns, setPinnedColumns] = useState<Set<string>>(() => new Set(['name']));
// Toggle pin for a column — enforces MAX_PINNED_COLUMNS cap
const togglePinColumn = useCallback((fieldKey: string) => {
setPinnedColumns(prev => {
const next = new Set(prev);
if (next.has(fieldKey)) {
next.delete(fieldKey);
} else {
if (next.size >= MAX_PINNED_COLUMNS) {
toast.warning(`Maximum of ${MAX_PINNED_COLUMNS} pinned columns`);
return prev;
}
next.add(fieldKey);
}
return next;
});
}, []);
// Compute layout info for each pinned column: cumulative left/right offsets, natural position, width, z-index
// Pinned columns maintain their natural field order (no reordering)
const pinnedColumnLayout = useMemo(() => {
const layout = new Map<string, PinnedColumnEntry>();
if (pinnedColumns.size === 0) return layout;
// Collect pinned fields in field order with their natural left positions
const baseOffset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
const pinnedInOrder: { key: string; width: number; naturalLeft: number }[] = [];
let runningOffset = baseOffset;
for (const field of fields) {
if (field.key === 'name') break;
offset += field.width || 150;
const w = field.width || 150;
if (pinnedColumns.has(field.key)) {
pinnedInOrder.push({ key: field.key, width: w, naturalLeft: runningOffset });
}
runningOffset += w;
}
return offset;
}, [fields]);
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
const [nameColumnSticky, setNameColumnSticky] = useState(true);
// Compute cumulative right offsets (from the right edge inward)
const rightOffsets: number[] = new Array(pinnedInOrder.length).fill(0);
let cumulativeRight = 0;
for (let i = pinnedInOrder.length - 1; i >= 0; i--) {
rightOffsets[i] = cumulativeRight;
cumulativeRight += pinnedInOrder[i].width;
}
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
// Assign cumulative left offsets, right offsets, and z-indexes
let cumulativeLeft = 0;
for (let i = 0; i < pinnedInOrder.length; i++) {
const { key, width, naturalLeft } = pinnedInOrder[i];
layout.set(key, {
left: cumulativeLeft,
right: rightOffsets[i],
naturalLeft,
width,
zIndex: 10 + i, // Increasing z so rightmost pinned is on top
});
cumulativeLeft += width;
}
return layout;
}, [fields, pinnedColumns]);
// Calculate name column width
const nameColumnWidth = useMemo(() => {
const nameField = fields.find(f => f.key === 'name');
return nameField?.width || 400;
}, [fields]);
// Per-column sticky state: each pinned column independently sticks left, right, or not at all.
// Only columns that are actually stuck appear in this map.
const [stickyStates, setStickyStates] = useState<Map<string, 'left' | 'right'>>(() => new Map());
const stickyStatesRef = useRef<Map<string, 'left' | 'right'>>(new Map());
// Shadow keys: the outermost stuck column on each side
const [shadowLeftKey, setShadowLeftKey] = useState<string | null>(null);
const [shadowRightKey, setShadowRightKey] = useState<string | null>(null);
const shadowLeftRef = useRef<string | null>(null);
const shadowRightRef = useRef<string | null>(null);
// Sync header scroll with body scroll + track sticky direction
// Compute per-column sticky state and shadow keys from scroll position
const updateStickyState = useCallback((scrollLeft: number, viewportWidth: number) => {
let newLeftShadow: string | null = null;
let newRightShadow: string | null = null;
let changed = false;
const newStates = new Map<string, 'left' | 'right'>();
for (const [key, entry] of pinnedColumnLayout) {
// CSS sticky; left: L engages when naturalLeft < scrollLeft + L
const stuckLeft = entry.naturalLeft < scrollLeft + entry.left;
// CSS sticky; right: R engages when naturalLeft + width > scrollLeft + viewportWidth - R
const stuckRight = entry.naturalLeft + entry.width > scrollLeft + viewportWidth - entry.right;
if (stuckLeft) {
newStates.set(key, 'left');
newLeftShadow = key; // Keep overwriting — last match = rightmost stuck-left
} else if (stuckRight) {
newStates.set(key, 'right');
if (newRightShadow === null) newRightShadow = key; // First match = leftmost stuck-right
}
}
// Compare with previous states to avoid unnecessary re-renders
if (newStates.size !== stickyStatesRef.current.size) {
changed = true;
} else {
for (const [key, dir] of newStates) {
if (stickyStatesRef.current.get(key) !== dir) { changed = true; break; }
}
}
if (changed) {
stickyStatesRef.current = newStates;
setStickyStates(newStates);
}
if (newLeftShadow !== shadowLeftRef.current) {
shadowLeftRef.current = newLeftShadow;
setShadowLeftKey(newLeftShadow);
}
if (newRightShadow !== shadowRightRef.current) {
shadowRightRef.current = newRightShadow;
setShadowRightKey(newRightShadow);
}
}, [pinnedColumnLayout]);
// Body scroll → sync header + update sticky state
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
const scrollLeft = tableContainerRef.current.scrollLeft;
const viewportWidth = tableContainerRef.current.clientWidth;
headerRef.current.scrollLeft = scrollLeft;
if (headerRef.current.scrollLeft !== scrollLeft) {
headerRef.current.scrollLeft = scrollLeft;
}
updateStickyState(scrollLeft, viewportWidth);
}
}, [updateStickyState]);
// Calculate name column's position relative to viewport
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
const nameRightEdge = namePositionInViewport + nameColumnWidth;
// Determine sticky direction for name column
if (nameColumnSticky) {
if (scrollLeft > nameColumnLeftOffset) {
// Scrolled right past name column - stick to left
setStickyDirection('left');
} else if (nameRightEdge > viewportWidth) {
// Name column extends beyond viewport to the right - stick to right
setStickyDirection('right');
} else {
// Name column is fully visible - no sticky needed
setStickyDirection(null);
}
} else {
setStickyDirection(null);
// Header scroll → sync body (handles scroll-wheel over header area)
const handleHeaderScroll = useCallback(() => {
if (headerRef.current && tableContainerRef.current) {
const scrollLeft = headerRef.current.scrollLeft;
if (tableContainerRef.current.scrollLeft !== scrollLeft) {
tableContainerRef.current.scrollLeft = scrollLeft;
}
}
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
}, []);
// Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row
@@ -2343,12 +2454,7 @@ export const ValidationTable = () => {
return { filteredIndices: indices, rowIdMap: idMap };
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
// Toggle for sticky name column
const toggleNameColumnSticky = useCallback(() => {
setNameColumnSticky(prev => !prev);
}, []);
// Build columns - ONLY depends on fields, NOT selection state
// Build columns - ONLY depends on fields and pinnedColumns, NOT selection state
// Selection state is handled by isolated HeaderCheckbox component
const columns = useMemo<ColumnDef<RowData>[]>(() => {
// Selection column - uses isolated HeaderCheckbox to prevent cascading re-renders
@@ -2371,26 +2477,26 @@ export const ValidationTable = () => {
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
const isNameColumn = field.key === 'name';
const isPinned = pinnedColumns.has(field.key);
// Pin button passed to each header — rendered alongside action buttons
const pinBtn = (
<PinButton
isPinned={isPinned}
onToggle={() => togglePinColumn(field.key)}
/>
);
// Determine which header component to render
const renderHeader = () => {
if (isNameColumn) {
return (
<NameColumnHeader
label={field.label}
isRequired={isRequired}
isSticky={nameColumnSticky}
onToggleSticky={toggleNameColumnSticky}
/>
);
}
if (isPriceColumn) {
return (
<PriceColumnHeader
fieldKey={field.key as 'msrp' | 'cost_each'}
label={field.label}
isRequired={isRequired}
pinButton={pinBtn}
isPinned={isPinned}
/>
);
}
@@ -2400,6 +2506,8 @@ export const ValidationTable = () => {
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
label={field.label}
isRequired={isRequired}
pinButton={pinBtn}
isPinned={isPinned}
/>
);
}
@@ -2409,15 +2517,25 @@ export const ValidationTable = () => {
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
label={field.label}
isRequired={isRequired}
pinButton={pinBtn}
isPinned={isPinned}
/>
);
}
// Plain header — pin button in its own absolutely-positioned container
return (
<div className="flex items-center gap-1 truncate">
<div className="flex items-center gap-1 min-w-0 w-full group relative">
<span className="truncate">{field.label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
<div className={cn(
'absolute right-0 top-1/2 -translate-y-1/2',
'transition-opacity',
!isPinned && 'opacity-0 group-hover:opacity-100'
)}>
{pinBtn}
</div>
</div>
);
};
@@ -2430,7 +2548,7 @@ export const ValidationTable = () => {
});
return [selectionColumn, templateColumn, ...dataColumns];
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
}, [fields, pinnedColumns, togglePinColumn]);
// Calculate total table width for horizontal scrolling
const totalTableWidth = useMemo(() => {
@@ -2454,22 +2572,25 @@ export const ValidationTable = () => {
{/* Copy-down banner - shows when copy-down mode is active */}
<CopyDownBanner />
{/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */}
{/* Fixed header - syncs horizontal scroll bidirectionally with body */}
<div
ref={headerRef}
className="flex-shrink-0 bg-muted/50 border-b overflow-hidden"
className="flex-shrink-0 bg-muted/50 border-b overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
style={{ height: HEADER_HEIGHT }}
onScroll={handleHeaderScroll}
>
<div
className="flex h-full"
style={{ minWidth: totalTableWidth }}
>
{columns.map((column, index) => {
const isNameColumn = column.id === 'name';
// Determine sticky behavior for header name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
// Per-column sticky state (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
const pinnedEntry = column.id ? pinnedColumnLayout.get(column.id) : undefined;
const columnStickDir = pinnedEntry && column.id ? stickyStates.get(column.id) : undefined;
const shouldBeSticky = !!columnStickDir;
const stickyLeft = columnStickDir === 'left';
const stickyRight = columnStickDir === 'right';
const isShadowColumn = column.id === shadowLeftKey || column.id === shadowRightKey;
return (
<div
@@ -2479,18 +2600,20 @@ export const ValidationTable = () => {
// Use box-shadow for right border - renders more consistently
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
// Sticky header - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
shouldBeSticky && "lg:sticky lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
// Drop shadow only on the outermost actually-stuck column per side
isShadowColumn && stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
isShadowColumn && stickyRight && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
)}
style={{
width: column.size || 150,
minWidth: column.size || 150,
flexShrink: 0,
// Z-index layered per pinned column (header z is 10 above cell z)
...(shouldBeSticky && { zIndex: pinnedEntry!.zIndex + 10 }),
// Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
...(stickyLeft && { left: pinnedEntry!.left }),
...(stickyRight && { right: pinnedEntry!.right }),
}}
>
{typeof column.header === 'function'
@@ -2527,8 +2650,10 @@ export const ValidationTable = () => {
columns={columns}
fields={fields}
totalRowCount={rowCount}
nameColumnSticky={nameColumnSticky}
stickyDirection={stickyDirection}
pinnedColumnLayout={pinnedColumnLayout}
stickyStates={stickyStates}
shadowLeftKey={shadowLeftKey}
shadowRightKey={shadowRightKey}
/>
);
})}

View File

@@ -241,10 +241,13 @@ export function TemplateForm({
} catch (error: any) {
console.error('Error saving template:', error);
console.error('Error response:', error.response?.data);
const serverMessage = error.response?.data?.error;
toast.error(
'Failed to save template',
error.response?.status === 409
? 'Duplicate template'
: 'Failed to save template',
{
description: error.response?.data?.message || error.message || 'An unknown error occurred'
description: serverMessage || error.message || 'An unknown error occurred'
}
);
} finally {

View File

@@ -14,33 +14,59 @@ import {
Header,
HeaderGroup,
} from "@tanstack/react-table";
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
import {
ProductDetail,
LineGroup,
CategoryGroup,
DesignerGroup,
lineColumns,
categoryColumns,
designerColumns,
LineSubComponent,
CategorySubComponent,
DesignerSubComponent,
groupByLine,
groupByCategory,
groupByDesigner,
computeCrossLineAverages,
} from "@/components/forecasting/columns";
import { DateRange } from "react-day-picker";
import { addDays, addMonths } from "date-fns";
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
type GroupMode = "line" | "category" | "designer";
const FILTERS_KEY = "forecastingFilters";
const fmt = (v: number, decimals = 0) =>
v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
export default function Forecasting() {
const [selectedBrand, setSelectedBrand] = useState<string>("");
const [dateRange, setDateRange] = useState<DateRange>({
from: addDays(addMonths(new Date(), -1), 1),
from: addDays(addMonths(new Date(), -6), 1),
to: new Date(),
});
const [sorting, setSorting] = useState<SortingState>([]);
const [groupMode, setGroupMode] = useState<GroupMode>("line");
const [selectedArtist, setSelectedArtist] = useState<string>("all");
const [lineSorting, setLineSorting] = useState<SortingState>([]);
const [catSorting, setCatSorting] = useState<SortingState>([]);
const [designerSorting, setDesignerSorting] = useState<SortingState>([]);
const [search, setSearch] = useState<string>("");
const FILTERS_KEY = "forecastingFilters";
const queryClient = useQueryClient();
// Restore saved brand and date range on first mount
// Restore saved filters on first mount
useEffect(() => {
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (!raw) return;
const saved = JSON.parse(raw);
if (typeof saved.brand === 'string') setSelectedBrand(saved.brand);
if (typeof saved.brand === "string") setSelectedBrand(saved.brand);
if (saved.from && saved.to) {
const from = new Date(saved.from);
const to = new Date(saved.to);
@@ -48,148 +74,180 @@ export default function Forecasting() {
setDateRange({ from, to });
}
}
// Force a refetch once state settles
if (saved.groupMode === "line" || saved.groupMode === "category" || saved.groupMode === "designer") {
setGroupMode(saved.groupMode);
}
if (typeof saved.artist === "string") setSelectedArtist(saved.artist);
setTimeout(() => {
try { queryClient.invalidateQueries({ queryKey: ["forecast"] }); } catch {}
try {
queryClient.invalidateQueries({ queryKey: ["forecast-v2"] });
} catch {}
}, 0);
} catch {}
}, []);
// Persist brand and date range
// Persist filters
useEffect(() => {
try {
localStorage.setItem(
FILTERS_KEY,
JSON.stringify({ brand: selectedBrand, from: dateRange.from?.toISOString(), to: dateRange.to?.toISOString() })
JSON.stringify({
brand: selectedBrand,
from: dateRange.from?.toISOString(),
to: dateRange.to?.toISOString(),
groupMode,
artist: selectedArtist,
})
);
} catch {}
}, [selectedBrand, dateRange]);
}, [selectedBrand, dateRange, groupMode, selectedArtist]);
const handleDateRangeChange = (range: DateRange | undefined) => {
if (range) {
setDateRange(range);
}
if (range) setDateRange(range);
};
// ─── Data fetching ──────────────────────────────────────────────────────
const { data: brands = [], isLoading: brandsLoading } = useQuery({
queryKey: ["brands"],
queryFn: async () => {
const response = await fetch("/api/products/brands");
if (!response.ok) {
throw new Error("Failed to fetch brands");
}
if (!response.ok) throw new Error("Failed to fetch brands");
const data = await response.json();
return Array.isArray(data) ? data : [];
},
});
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ["forecast", selectedBrand, dateRange],
const { data: rawData, isLoading: dataLoading } = useQuery<{
products: ProductDetail[];
artists: string[];
}>({
queryKey: ["forecast-v2", selectedBrand, dateRange],
queryFn: async () => {
const params = new URLSearchParams({
brand: selectedBrand,
startDate: dateRange.from?.toISOString() || "",
endDate: dateRange.to?.toISOString() || "",
});
const response = await fetch(`/api/analytics/forecast?${params}`);
if (!response.ok) {
throw new Error("Failed to fetch forecast data");
}
const data = await response.json();
return data.map((item: any) => ({
category: item.category_name,
categoryPath: item.path,
totalSold: Number(item.total_sold) || 0,
numProducts: Number(item.num_products) || 0,
avgTotalSold: Number(item.avgTotalSold) || 0,
minSold: Number(item.minSold) || 0,
maxSold: Number(item.maxSold) || 0,
products: item.products?.map((p: any) => ({
pid: p.pid,
title: p.title,
sku: p.sku,
total_sold: Number(p.total_sold) || 0,
categoryPath: item.path
}))
}));
const response = await fetch(`/api/analytics/forecast-v2?${params}`);
if (!response.ok) throw new Error("Failed to fetch forecast data");
return response.json();
},
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
});
// Local, instant filter + summary for title substring matches within category groups
// ─── Client-side filtering & grouping ───────────────────────────────────
type ProductLite = { pid: string; title: string; sku: string; total_sold: number; categoryPath: string };
const filteredProducts = useMemo(() => {
if (!rawData?.products) return [];
let products = rawData.products;
const displayData = useMemo(() => {
if (!forecastData) return [] as ForecastItem[];
// Artist filter (skip when in designer mode — show all designers)
if (selectedArtist !== "all" && groupMode !== "designer") {
products = products.filter((p) => p.artist === selectedArtist);
}
// Title search
const term = search.trim().toLowerCase();
if (!term) return forecastData;
const filteredGroups: ForecastItem[] = [];
const allMatchedProducts: ProductLite[] = [];
for (const g of forecastData) {
const matched: ProductLite[] = (g.products || []).filter((p: ProductLite) => p.title?.toLowerCase().includes(term));
if (matched.length === 0) continue;
const totalSold = matched.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
const numProducts = matched.length;
const avgTotalSold = numProducts > 0 ? totalSold / numProducts : 0;
const minSold = matched.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
const maxSold = matched.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
filteredGroups.push({
category: g.category,
categoryPath: g.categoryPath,
totalSold,
numProducts,
avgTotalSold,
minSold: Number.isFinite(minSold) ? minSold : 0,
maxSold,
products: matched,
});
allMatchedProducts.push(...matched);
if (term) {
products = products.filter((p) => p.title.toLowerCase().includes(term));
}
if (allMatchedProducts.length > 0) {
const totalSoldAll = allMatchedProducts.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
const avgTotalSoldAll = totalSoldAll / allMatchedProducts.length;
const minSoldAll = allMatchedProducts.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
const maxSoldAll = allMatchedProducts.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
filteredGroups.unshift({
category: `Matches: "${search}"`,
categoryPath: "",
totalSold: totalSoldAll,
numProducts: allMatchedProducts.length,
avgTotalSold: avgTotalSoldAll,
minSold: Number.isFinite(minSoldAll) ? minSoldAll : 0,
maxSold: maxSoldAll,
products: allMatchedProducts,
});
}
return products;
}, [rawData, selectedArtist, search, groupMode]);
return filteredGroups;
}, [forecastData, search]);
const lineGroups = useMemo(
() => groupByLine(filteredProducts),
[filteredProducts]
);
const table = useReactTable({
data: displayData || [],
columns,
const categoryGroups = useMemo(
() => groupByCategory(filteredProducts),
[filteredProducts]
);
const designerGroups = useMemo(
() => groupByDesigner(filteredProducts),
[filteredProducts]
);
const crossLineAverages = useMemo(
() => computeCrossLineAverages(lineGroups),
[lineGroups]
);
// ─── Tables ─────────────────────────────────────────────────────────────
const lineTable = useReactTable({
data: lineGroups,
columns: lineColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: setSorting,
onSortingChange: setLineSorting,
getRowCanExpand: () => true,
state: {
sorting,
},
state: { sorting: lineSorting },
});
const catTable = useReactTable({
data: categoryGroups,
columns: categoryColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: setCatSorting,
getRowCanExpand: () => true,
state: { sorting: catSorting },
});
const designerTable = useReactTable({
data: designerGroups,
columns: designerColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: setDesignerSorting,
getRowCanExpand: () => true,
state: { sorting: designerSorting },
});
// ─── QuickOrderBuilder data (always category-based) ─────────────────────
const qobCategories = useMemo(
() =>
categoryGroups.map((c) => ({
category: c.category,
categoryPath: c.categoryPath,
avgTotalSold: c.avgLifetimeSales,
minSold: c.minSales,
maxSold: c.maxSales,
})),
[categoryGroups]
);
// ─── Summary stats ─────────────────────────────────────────────────────
const totalProducts = filteredProducts.length;
const totalLines = lineGroups.filter((l) => l.line !== "(No Line)").length;
const totalDesigners = designerGroups.filter((d) => d.artist !== "(Unknown Designer)").length;
return (
<div className="container mx-auto py-10 space-y-6">
<Card>
<CardHeader>
<CardTitle>Historical Sales</CardTitle>
<div className="flex items-center justify-between">
<CardTitle>Historical Sales for New Line Ordering</CardTitle>
{rawData && !dataLoading && (
<div className="text-sm text-muted-foreground">
{totalProducts} products across {totalLines} lines
{totalDesigners > 1 ? `, ${totalDesigners} designers` : ''}
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-6 items-center">
{/* ─── Filter bar ─────────────────────────────────────────── */}
<div className="flex gap-3 mb-4 items-center flex-wrap">
<div className="w-[200px]">
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
<SelectTrigger disabled={brandsLoading}>
@@ -204,12 +262,70 @@ export default function Forecasting() {
</SelectContent>
</Select>
</div>
<DateRangePickerQuick
value={dateRange}
onChange={handleDateRangeChange}
/>
{(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? (
<div className="w-[400px] relative">
{/* Artist filter (hidden in designer mode — the view itself compares designers) */}
{rawData?.artists && rawData.artists.length > 0 && groupMode !== "designer" && (
<div className="w-[200px]">
<Select value={selectedArtist} onValueChange={setSelectedArtist}>
<SelectTrigger>
<SelectValue placeholder="All designers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Designers</SelectItem>
{rawData.artists.map((artist) => (
<SelectItem key={artist} value={artist}>
{artist}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Grouping toggle */}
<TooltipProvider delayDuration={0}>
<ToggleGroup
type="single"
value={groupMode}
onValueChange={(v) => { if (v) setGroupMode(v as GroupMode); }}
variant="outline"
size="sm"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem value="line" aria-label="Group by line">
<Layers className="h-4 w-4 mr-1" /> By Line
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Group products by collection/line see how each release performed</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem value="category" aria-label="Group by category">
<FolderTree className="h-4 w-4 mr-1" /> By Category
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Group products by type (Paper, Embellishments, etc.) across all lines</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem value="designer" aria-label="Group by designer">
<Palette className="h-4 w-4 mr-1" /> By Designer
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Compare designers see line + category breakdowns per artist</TooltipContent>
</Tooltip>
</ToggleGroup>
</TooltipProvider>
{/* Search */}
{(totalProducts > 0 || search.trim().length > 0) && (
<div className="w-[280px] relative ml-auto">
<Input
placeholder="Filter by product title"
value={search}
@@ -227,83 +343,243 @@ export default function Forecasting() {
</button>
)}
</div>
) : null}
)}
</div>
{forecastLoading ? (
{/* ─── Cross-line averages (line mode only) ───────────────── */}
{groupMode === "line" && crossLineAverages.length > 0 && (
<div className="mb-4 rounded-md border bg-muted/40 p-3">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
Cross-Line Averages by Product Type
</span>
<span className="text-xs text-muted-foreground">
What an "average" line looks like for this brand
</span>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="py-1.5">Category</TableHead>
<TableHead className="py-1.5 text-right"># Lines</TableHead>
<TableHead className="py-1.5 text-right">Avg Products</TableHead>
<TableHead className="py-1.5 text-right">Avg Lifetime Sales</TableHead>
<TableHead className="py-1.5 text-right">Median</TableHead>
<TableHead className="py-1.5 text-right">Avg First 30d</TableHead>
<TableHead className="py-1.5 text-right">Best Line Avg</TableHead>
<TableHead className="py-1.5 text-right">Worst Line Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{crossLineAverages.map((avg) => (
<TableRow key={avg.category} className="text-xs">
<TableCell className="py-1.5 font-medium">{avg.category}</TableCell>
<TableCell className="py-1.5 text-right">{avg.lineCount}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(avg.avgProductCount, 1)}</TableCell>
<TableCell className="py-1.5 text-right font-medium">{fmt(avg.avgLifetimeSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(avg.medianLifetimeSales, 0)}</TableCell>
<TableCell className="py-1.5 text-right">{fmt(avg.avgFirst30dSales, 1)}</TableCell>
<TableCell className="py-1.5 text-right text-green-600">{fmt(avg.maxAvgSales, 0)}</TableCell>
<TableCell className="py-1.5 text-right text-red-600">{fmt(avg.minAvgSales, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* ─── Main data table ────────────────────────────────────── */}
{dataLoading ? (
<div className="h-24 flex items-center justify-center">
Loading sales data...
</div>
) : forecastData && (
) : rawData && (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<ForecastItem>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<ForecastItem, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<ForecastItem>) => (
<Fragment key={row.id}>
<TableRow
data-state={row.getIsSelected() && "selected"}
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length} className="p-0">
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{groupMode === "line" ? (
<LineViewTable table={lineTable} />
) : groupMode === "designer" ? (
<DesignerViewTable table={designerTable} />
) : (
<CategoryViewTable table={catTable} />
)}
</div>
)}
</CardContent>
</Card>
{/* Quick Order Builder */}
<QuickOrderBuilder
brand={selectedBrand}
categories={(displayData || []).map((c: any) => ({
category: c.category,
categoryPath: c.categoryPath,
avgTotalSold: c.avgTotalSold,
minSold: c.minSold,
maxSold: c.maxSold,
}))}
/>
{/* Quick Order Builder (unchanged interface) */}
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
</div>
);
}
}
// ─── Line view table component ──────────────────────────────────────────────
function LineViewTable({
table,
}: {
table: ReturnType<typeof useReactTable<LineGroup>>;
}) {
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<LineGroup>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<LineGroup, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<LineGroup>) => (
<Fragment key={row.id}>
<TableRow
data-state={row.getIsSelected() && "selected"}
className={row.original.line === "(No Line)" ? "opacity-60" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={lineColumns.length} className="p-0">
<LineSubComponent row={row} />
</TableCell>
</TableRow>
)}
</Fragment>
))
) : (
<TableRow>
<TableCell colSpan={lineColumns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
// ─── Category view table component ──────────────────────────────────────────
function CategoryViewTable({
table,
}: {
table: ReturnType<typeof useReactTable<CategoryGroup>>;
}) {
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<CategoryGroup>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<CategoryGroup, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<CategoryGroup>) => (
<Fragment key={row.id}>
<TableRow data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={categoryColumns.length} className="p-0">
<CategorySubComponent row={row} />
</TableCell>
</TableRow>
)}
</Fragment>
))
) : (
<TableRow>
<TableCell colSpan={categoryColumns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
// ─── Designer view table component ──────────────────────────────────────────
function DesignerViewTable({
table,
}: {
table: ReturnType<typeof useReactTable<DesignerGroup>>;
}) {
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<DesignerGroup>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<DesignerGroup, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<DesignerGroup>) => (
<Fragment key={row.id}>
<TableRow
data-state={row.getIsSelected() && "selected"}
className={row.original.artist === "(Unknown Designer)" ? "opacity-60" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={designerColumns.length} className="p-0">
<DesignerSubComponent row={row} />
</TableCell>
</TableRow>
)}
</Fragment>
))
) : (
<TableRow>
<TableCell colSpan={designerColumns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}