From 4b2b3d5a9f05e1ba4271f54f32264e67a48aaf69 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 2 Apr 2026 16:20:24 -0400 Subject: [PATCH] Enhance sticky columns in import, enhance forecasting page --- inventory-server/src/routes/analytics.js | 142 +++ inventory-server/src/routes/templates.js | 18 +- .../src/components/forecasting/columns.tsx | 914 +++++++++++++++--- .../components/ValidationTable.tsx | 751 ++++++++------ .../src/components/templates/TemplateForm.tsx | 7 +- inventory/src/pages/Forecasting.tsx | 610 ++++++++---- 6 files changed, 1809 insertions(+), 633 deletions(-) diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index b233a2c..f3e09d7 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -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 diff --git a/inventory-server/src/routes/templates.js b/inventory-server/src/routes/templates.js index 999f960..d38b9ae 100644 --- a/inventory-server/src/routes/templates.js +++ b/inventory-server/src/routes/templates.js @@ -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({ diff --git a/inventory/src/components/forecasting/columns.tsx b/inventory/src/components/forecasting/columns.tsx index 288ec2c..e8cfa16 100644 --- a/inventory/src/components/forecasting/columns.tsx +++ b/inventory/src/components/forecasting/columns.tsx @@ -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[] = [ +// ─── 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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[] = [ { id: "expander", header: () => null, - cell: ({ row }) => { - return row.getCanExpand() ? ( - - ) : null; + ) : null, + }, + { + accessorKey: "line", + header: "Line / Collection", + cell: ({ row }) => ( +
+
{row.original.line}
+ {row.original.artist && ( +
{row.original.artist}
+ )} +
+ ), + }, + { + id: "received", + header: "Received", + cell: ({ row }) => { + const d = row.original.dateFirstReceived; + if (!d) return ; + return {new Date(d).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}; }, }, + { + accessorKey: "productCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.productCount), + }, + { + accessorKey: "avgLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1), + }, + { + accessorKey: "medianLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0), + }, + { + accessorKey: "avgFirst30dSales", + header: ({ column }) => ( + + + + + + Average sales in the first 30 days after product was received + + + ), + cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1), + }, + { + accessorKey: "minSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.minSales), + }, + { + accessorKey: "maxSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.maxSales), + }, + { + accessorKey: "totalSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.totalSales), + }, +]; + +// ─── Category view columns (enhanced) ─────────────────────────────────────── + +export const categoryColumns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, { accessorKey: "category", header: "Category", cell: ({ row }) => (
{row.original.category}
- {row.original.categoryPath && ( -
- {row.original.categoryPath} -
+ {row.original.categoryPath && row.original.categoryPath !== row.original.category && ( +
{row.original.categoryPath}
)}
), }, { - accessorKey: "avgTotalSold", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.getValue("avgTotalSold") as number; - return value?.toFixed(2) || "0.00"; - }, + accessorKey: "productCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.productCount), }, { - accessorKey: "minSold", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.getValue("minSold") as number; - return value?.toLocaleString() || "0"; - }, + accessorKey: "avgLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1), }, { - accessorKey: "maxSold", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.getValue("maxSold") as number; - return value?.toLocaleString() || "0"; - }, + accessorKey: "medianLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0), }, { - accessorKey: "totalSold", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.getValue("totalSold") as number; - return value?.toLocaleString() || "0"; - }, + accessorKey: "avgFirst30dSales", + header: ({ column }) => ( + + + + + + Average sales in the first 30 days after product was received + + + ), + cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1), }, { - accessorKey: "numProducts", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.getValue("numProducts") as number; - return value?.toLocaleString() || "0"; - }, + accessorKey: "minSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.minSales), + }, + { + accessorKey: "maxSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.maxSales), + }, + { + accessorKey: "totalSales", + header: ({ column }) => ( + + ), + 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 ( - - - - - Product - Sold - - - - {products.map((product: Product) => ( - - - - {product.title} - -
{product.sku}
-
- {product.total_sold?.toLocaleString?.() ?? product.total_sold} -
- ))} -
-
-
+
+ {/* Category breakdown summary */} +
+

+ Category breakdown — {categories.length} categories, {products.length} products +

+
+ + + + Category + # Products + Avg Lifetime + Median + First 30d + Min + Max + + + + {categories.map((cat) => ( + + {cat.category} + {cat.productCount} + {fmt(cat.avgLifetimeSales, 1)} + {fmt(cat.medianLifetimeSales)} + {fmt(cat.avgFirst30dSales, 1)} + {fmt(cat.minSales)} + {fmt(cat.maxSales)} + + ))} + +
+
+
+ + {/* Full product list */} +
+

All products in this line

+ + + + + Product + Category + Lifetime + First 30d + Stock + + + + {products + .sort((a, b) => b.lifetimeSales - a.lifetimeSales) + .map((p) => ( + + + + {p.title} + + {p.sku} + + {p.category} + {fmt(p.lifetimeSales)} + {fmt(p.first30dSales)} + + + {fmt(p.currentStock)} + + + + ))} + +
+
+
+
); -}; +} + +// ─── Sub-component for Category view: product list with richer detail ──────── + +export function CategorySubComponent({ row }: { row: { original: CategoryGroup } }) { + const { products } = row.original; + + return ( +
+

+ {products.length} products +

+ + + + + Product + Line + Lifetime + First 30d + First 90d + Stock + + + + {products + .sort((a, b) => b.lifetimeSales - a.lifetimeSales) + .map((p) => ( + + + + {p.title} + + {p.sku} + + {p.line || '—'} + {fmt(p.lifetimeSales)} + {fmt(p.first30dSales)} + {fmt(p.first90dSales)} + + + {fmt(p.currentStock)} + + + + ))} + +
+
+
+ ); +} + +// ─── Designer view columns ────────────────────────────────────────────────── + +export const designerColumns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, + { + accessorKey: "artist", + header: "Designer", + cell: ({ row }) => ( +
+
{row.original.artist}
+
+ {row.original.lineCount} line{row.original.lineCount !== 1 ? 's' : ''} +
+
+ ), + }, + { + accessorKey: "productCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.productCount), + }, + { + accessorKey: "avgLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1), + }, + { + accessorKey: "medianLifetimeSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0), + }, + { + accessorKey: "avgFirst30dSales", + header: ({ column }) => ( + + + + + + Average sales in the first 30 days after product was received + + + ), + cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1), + }, + { + accessorKey: "minSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.minSales), + }, + { + accessorKey: "maxSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.maxSales), + }, + { + accessorKey: "totalSales", + header: ({ column }) => ( + + ), + cell: ({ row }) => fmt(row.original.totalSales), + }, +]; + +// ─── Sub-component for Designer view ──────────────────────────────────────── + +export function DesignerSubComponent({ row }: { row: { original: DesignerGroup } }) { + const { lines, categories } = row.original; + + return ( +
+ {/* Per-line performance */} +
+

+ Line performance — {lines.length} lines +

+
+ + + + Line + Received + # Products + Avg Lifetime + Median + First 30d + Min + Max + + + + {lines.map((l) => ( + + {l.line} + + {l.dateFirstReceived + ? new Date(l.dateFirstReceived).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) + : '—'} + + {l.productCount} + {fmt(l.avgLifetimeSales, 1)} + {fmt(l.medianLifetimeSales)} + {fmt(l.avgFirst30dSales, 1)} + {fmt(l.minSales)} + {fmt(l.maxSales)} + + ))} + +
+
+
+ + {/* Category averages across all their lines */} +
+

+ Category averages across all {row.original.artist} lines +

+
+ + + + Category + # Products + Avg Lifetime + Median + First 30d + Min + Max + + + + {categories.map((cat) => ( + + {cat.category} + {cat.productCount} + {fmt(cat.avgLifetimeSales, 1)} + {fmt(cat.medianLifetimeSales)} + {fmt(cat.avgFirst30dSales, 1)} + {fmt(cat.minSales)} + {fmt(cat.maxSales)} + + ))} + +
+
+
+
+ ); +} + +// ─── 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; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 7aa13cd..a7593e2 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -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[]; fields: Field[]; 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; + /** Per-column stick direction: only columns that are actually stuck appear in this map */ + stickyStates: Map; + /** 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 (
{ 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 (
{ if (!isPopoverOpen) setIsHovered(false); @@ -1798,111 +1820,109 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead {isRequired && ( * )} - {(isHovered || isPopoverOpen) && hasFillableCells && ( - isMsrp ? ( - // MSRP: Show popover with multiplier options - + {/* Button group: pin button always visible when pinned, action buttons only on hover */} +
+ {(isHovered || isPopoverOpen) && hasFillableCells && ( + isMsrp ? ( + + + + + + + + + +

{tooltipText}

+
+
+
+ +
+

+ Calculate MSRP from Cost +

+
+

Multiplier

+
+ {MSRP_MULTIPLIERS.map((m) => ( + + ))} +
+
+ {selectedMultiplier > 2.0 && ( +
+ setShouldRoundToNine(checked === true)} + /> + +
+ )} + {selectedMultiplier === 2.0 && ( +

+ Auto-adjusts ±1¢ for .99 pricing +

+ )} + +
+
+
+ ) : ( - - - +

{tooltipText}

- -
-

- Calculate MSRP from Cost -

-
-

Multiplier

-
- {MSRP_MULTIPLIERS.map((m) => ( - - ))} -
-
- {selectedMultiplier > 2.0 && ( -
- setShouldRoundToNine(checked === true)} - /> - -
- )} - {selectedMultiplier === 2.0 && ( -

- Auto-adjusts ±1¢ for .99 pricing -

- )} - -
-
- - ) : ( - // Cost Each: Simple click behavior - - - - - - -

{tooltipText}

-
-
-
- ) - )} + ) + )} + {pinButton} +
); }); @@ -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 (
{ if (!isPopoverOpen) setIsHovered(false); @@ -2018,54 +2040,56 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo {isRequired && ( * )} - {(isHovered || isPopoverOpen) && hasConvertibleCells && ( - - - - - - + + + +

Convert units for entire column

+
+
+
+ +
+

+ Convert {isWeightField ? 'Weight' : 'Dimensions'} +

+ {conversions.map((conversion) => ( + - - - -

Convert units for entire column

-
- - - -
-

- Convert {isWeightField ? 'Weight' : 'Dimensions'} -

- {conversions.map((conversion) => ( - - ))} -
-
- - )} + {conversion.label} + + ))} +
+
+
+ )} + {pinButton} +
); }); @@ -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 = { @@ -2091,7 +2117,7 @@ const DEFAULT_VALUE_CONFIG: Record { +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 (
setIsHovered(false)} > @@ -2147,33 +2173,36 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV {isRequired && ( * )} - {isHovered && hasEmptyCells && ( - - - - - - -

Fill empty cells with "{config.displayName}"

-
-
-
- )} + {/* Button group: pin button always visible when pinned, action buttons only on hover */} +
+ {isHovered && hasEmptyCells && ( + + + + + + +

Fill empty cells with "{config.displayName}"

+
+
+
+ )} + {pinButton} +
); }); @@ -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 ( -
- {label} - {isRequired && ( - * - )} - - - - - - -

{isSticky ? 'Unpin column' : 'Pin column'}

-
-
-
-
+ + + + + + +

{isPinned ? 'Unpin column' : 'Pin column'}

+
+
+
); }); -NameColumnHeader.displayName = 'NameColumnHeader'; +PinButton.displayName = 'PinButton'; /** * Main table component @@ -2250,57 +2273,145 @@ export const ValidationTable = () => { const tableContainerRef = useRef(null); const headerRef = useRef(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>(() => 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(); + 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>(() => new Map()); + const stickyStatesRef = useRef>(new Map()); + // Shadow keys: the outermost stuck column on each side + const [shadowLeftKey, setShadowLeftKey] = useState(null); + const [shadowRightKey, setShadowRightKey] = useState(null); + const shadowLeftRef = useRef(null); + const shadowRightRef = useRef(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(); + + 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[]>(() => { // 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 = ( + togglePinColumn(field.key)} + /> + ); // Determine which header component to render const renderHeader = () => { - if (isNameColumn) { - return ( - - ); - } if (isPriceColumn) { return ( ); } @@ -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 ( -
+
{field.label} {isRequired && ( * )} +
+ {pinBtn} +
); }; @@ -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 */} - {/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */} + {/* Fixed header - syncs horizontal scroll bidirectionally with body */}
{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 (
{ // 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} /> ); })} diff --git a/inventory/src/components/templates/TemplateForm.tsx b/inventory/src/components/templates/TemplateForm.tsx index f15a254..eab5d24 100644 --- a/inventory/src/components/templates/TemplateForm.tsx +++ b/inventory/src/components/templates/TemplateForm.tsx @@ -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 { diff --git a/inventory/src/pages/Forecasting.tsx b/inventory/src/pages/Forecasting.tsx index 2355a43..0968dcb 100644 --- a/inventory/src/pages/Forecasting.tsx +++ b/inventory/src/pages/Forecasting.tsx @@ -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(""); const [dateRange, setDateRange] = useState({ - from: addDays(addMonths(new Date(), -1), 1), + from: addDays(addMonths(new Date(), -6), 1), to: new Date(), }); - const [sorting, setSorting] = useState([]); + const [groupMode, setGroupMode] = useState("line"); + const [selectedArtist, setSelectedArtist] = useState("all"); + const [lineSorting, setLineSorting] = useState([]); + const [catSorting, setCatSorting] = useState([]); + const [designerSorting, setDesignerSorting] = useState([]); const [search, setSearch] = useState(""); - 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 (
- Historical Sales +
+ Historical Sales for New Line Ordering + {rawData && !dataLoading && ( +
+ {totalProducts} products across {totalLines} lines + {totalDesigners > 1 ? `, ${totalDesigners} designers` : ''} +
+ )} +
-
+ {/* ─── Filter bar ─────────────────────────────────────────── */} +
+ - {(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? ( -
+ + {/* Artist filter (hidden in designer mode — the view itself compares designers) */} + {rawData?.artists && rawData.artists.length > 0 && groupMode !== "designer" && ( +
+ +
+ )} + + {/* Grouping toggle */} + + { if (v) setGroupMode(v as GroupMode); }} + variant="outline" + size="sm" + > + + + + By Line + + + Group products by collection/line — see how each release performed + + + + + By Category + + + Group products by type (Paper, Embellishments, etc.) across all lines + + + + + By Designer + + + Compare designers — see line + category breakdowns per artist + + + + + {/* Search */} + {(totalProducts > 0 || search.trim().length > 0) && ( +
)}
- ) : null} + )}
- - {forecastLoading ? ( + {/* ─── Cross-line averages (line mode only) ───────────────── */} + {groupMode === "line" && crossLineAverages.length > 0 && ( +
+
+ + + Cross-Line Averages by Product Type + + + — What an "average" line looks like for this brand + +
+
+ + + + Category + # Lines + Avg Products + Avg Lifetime Sales + Median + Avg First 30d + Best Line Avg + Worst Line Avg + + + + {crossLineAverages.map((avg) => ( + + {avg.category} + {avg.lineCount} + {fmt(avg.avgProductCount, 1)} + {fmt(avg.avgLifetimeSales, 1)} + {fmt(avg.medianLifetimeSales, 0)} + {fmt(avg.avgFirst30dSales, 1)} + {fmt(avg.maxAvgSales, 0)} + {fmt(avg.minAvgSales, 0)} + + ))} + +
+
+
+ )} + + {/* ─── Main data table ────────────────────────────────────── */} + {dataLoading ? (
Loading sales data...
- ) : forecastData && ( + ) : rawData && (
- - - {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( - - {headerGroup.headers.map((header: Header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row: Row) => ( - - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - {row.getIsExpanded() && ( - - - {renderSubComponent({ row })} - - - )} - - )) - ) : ( - - - No results. - - - )} - -
+ {groupMode === "line" ? ( + + ) : groupMode === "designer" ? ( + + ) : ( + + )}
)} - {/* Quick Order Builder */} - ({ - category: c.category, - categoryPath: c.categoryPath, - avgTotalSold: c.avgTotalSold, - minSold: c.minSold, - maxSold: c.maxSold, - }))} - /> + + {/* Quick Order Builder (unchanged interface) */} +
); -} +} + +// ─── Line view table component ────────────────────────────────────────────── + +function LineViewTable({ + table, +}: { + table: ReturnType>; +}) { + return ( + + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header: Header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: Row) => ( + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && ( + + + + + + )} + + )) + ) : ( + + + No results. + + + )} + +
+ ); +} + +// ─── Category view table component ────────────────────────────────────────── + +function CategoryViewTable({ + table, +}: { + table: ReturnType>; +}) { + return ( + + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header: Header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: Row) => ( + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && ( + + + + + + )} + + )) + ) : ( + + + No results. + + + )} + +
+ ); +} + +// ─── Designer view table component ────────────────────────────────────────── + +function DesignerViewTable({ + table, +}: { + table: ReturnType>; +}) { + return ( + + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header: Header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: Row) => ( + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && ( + + + + + + )} + + )) + ) : ( + + + No results. + + + )} + +
+ ); +}