Enhance sticky columns in import, enhance forecasting page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user