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 Intelligence Endpoints ────────────────────────────────────────
|
||||||
|
|
||||||
// Inventory KPI summary cards
|
// Inventory KPI summary cards
|
||||||
|
|||||||
@@ -134,11 +134,10 @@ router.post('/', async (req, res) => {
|
|||||||
res.status(201).json(result.rows[0]);
|
res.status(201).json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating template:', error);
|
console.error('Error creating template:', error);
|
||||||
// Check for unique constraint violation
|
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
if (error?.code === '23505') {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Template already exists for this company and product type',
|
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||||
details: error.message
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -232,11 +231,10 @@ router.put('/:id', async (req, res) => {
|
|||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating template:', error);
|
console.error('Error updating template:', error);
|
||||||
// Check for unique constraint violation
|
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
if (error?.code === '23505') {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Template already exists for this company and product type',
|
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||||
details: error.message
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|||||||
@@ -3,40 +3,398 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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;
|
pid: string;
|
||||||
sku: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
total_sold: number;
|
sku: string;
|
||||||
}
|
line: string;
|
||||||
|
artist: string;
|
||||||
export interface ForecastItem {
|
|
||||||
category: string;
|
category: string;
|
||||||
categoryPath: string;
|
categoryPath: string;
|
||||||
totalSold: number;
|
lifetimeSales: number;
|
||||||
numProducts: number;
|
first30dSales: number;
|
||||||
avgTotalSold: number;
|
first60dSales: number;
|
||||||
minSold: number;
|
first90dSales: number;
|
||||||
maxSold: number;
|
currentStock: number;
|
||||||
products?: Product[];
|
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",
|
id: "expander",
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) =>
|
||||||
return row.getCanExpand() ? (
|
row.getCanExpand() ? (
|
||||||
<Button
|
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
|
||||||
variant="ghost"
|
|
||||||
onClick={() => row.toggleExpanded()}
|
|
||||||
className="p-0 h-auto"
|
|
||||||
>
|
|
||||||
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</Button>
|
</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",
|
accessorKey: "category",
|
||||||
@@ -44,141 +402,415 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{row.original.category}</div>
|
<div className="font-medium">{row.original.category}</div>
|
||||||
{row.original.categoryPath && (
|
{row.original.categoryPath && row.original.categoryPath !== row.original.category && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground">{row.original.categoryPath}</div>
|
||||||
{row.original.categoryPath}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "avgTotalSold",
|
accessorKey: "productCount",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => (
|
||||||
return (
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||||
<Button
|
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Avg Total Sold
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
),
|
||||||
},
|
cell: ({ row }) => fmt(row.original.productCount),
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue("avgTotalSold") as number;
|
|
||||||
return value?.toFixed(2) || "0.00";
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "minSold",
|
accessorKey: "avgLifetimeSales",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => (
|
||||||
return (
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||||
<Button
|
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Min Sold
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
),
|
||||||
},
|
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue("minSold") as number;
|
|
||||||
return value?.toLocaleString() || "0";
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "maxSold",
|
accessorKey: "medianLifetimeSales",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => (
|
||||||
return (
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||||
<Button
|
Median <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Max Sold
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
),
|
||||||
},
|
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue("maxSold") as number;
|
|
||||||
return value?.toLocaleString() || "0";
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "totalSold",
|
accessorKey: "avgFirst30dSales",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => (
|
||||||
return (
|
<TooltipProvider delayDuration={0}>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||||
>
|
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||||
Total Sold
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
</TooltipTrigger>
|
||||||
},
|
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
|
||||||
cell: ({ row }) => {
|
</Tooltip>
|
||||||
const value = row.getValue("totalSold") as number;
|
</TooltipProvider>
|
||||||
return value?.toLocaleString() || "0";
|
),
|
||||||
},
|
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "numProducts",
|
accessorKey: "minSales",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => (
|
||||||
return (
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||||
<Button
|
Min <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
# Products
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
),
|
||||||
|
cell: ({ row }) => fmt(row.original.minSales),
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
{
|
||||||
const value = row.getValue("numProducts") as number;
|
accessorKey: "maxSales",
|
||||||
return value?.toLocaleString() || "0";
|
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 }) => {
|
// ─── Sub-component for Line view: category breakdown + product list ─────────
|
||||||
const products = row.original.products || [];
|
|
||||||
|
export function LineSubComponent({ row }: { row: { original: LineGroup } }) {
|
||||||
|
const { categories, products } = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[400px] w-full rounded-md border p-4">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="text-xs">
|
||||||
<TableHead>Product</TableHead>
|
<TableHead className="py-1.5">Category</TableHead>
|
||||||
<TableHead className="text-right">Sold</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product: Product) => (
|
{categories.map((cat) => (
|
||||||
<TableRow key={product.pid}>
|
<TableRow key={cat.category} className="text-xs">
|
||||||
<TableCell>
|
<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
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
href={`https://backend.acherryontop.com/product/${p.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline"
|
className="hover:underline block truncate"
|
||||||
>
|
>
|
||||||
{product.title}
|
{p.title}
|
||||||
</a>
|
</a>
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
<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>
|
</TableCell>
|
||||||
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</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;
|
||||||
|
|||||||
+265
-140
@@ -1014,7 +1014,9 @@ CellWrapper.displayName = 'CellWrapper';
|
|||||||
* Template column width
|
* Template column width
|
||||||
*/
|
*/
|
||||||
const TEMPLATE_COLUMN_WIDTH = 200;
|
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
|
* TemplateCell Component
|
||||||
@@ -1273,6 +1275,15 @@ TemplateCell.displayName = 'TemplateCell';
|
|||||||
* for a given row. It passes all data down to CellWrapper as props.
|
* 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).
|
* 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 {
|
interface VirtualRowProps {
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
@@ -1280,10 +1291,14 @@ interface VirtualRowProps {
|
|||||||
columns: ColumnDef<RowData>[];
|
columns: ColumnDef<RowData>[];
|
||||||
fields: Field<string>[];
|
fields: Field<string>[];
|
||||||
totalRowCount: number;
|
totalRowCount: number;
|
||||||
/** Whether the name column sticky behavior is enabled */
|
/** Map of field key → sticky layout for all currently pinned columns */
|
||||||
nameColumnSticky: boolean;
|
pinnedColumnLayout: Map<string, PinnedColumnEntry>;
|
||||||
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
|
/** Per-column stick direction: only columns that are actually stuck appear in this map */
|
||||||
stickyDirection: 'left' | 'right' | null;
|
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(({
|
const VirtualRow = memo(({
|
||||||
@@ -1293,8 +1308,10 @@ const VirtualRow = memo(({
|
|||||||
columns,
|
columns,
|
||||||
fields,
|
fields,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
nameColumnSticky,
|
pinnedColumnLayout,
|
||||||
stickyDirection,
|
stickyStates,
|
||||||
|
shadowLeftKey,
|
||||||
|
shadowRightKey,
|
||||||
}: VirtualRowProps) => {
|
}: VirtualRowProps) => {
|
||||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||||
const rowData = useValidationStore(
|
const rowData = useValidationStore(
|
||||||
@@ -1478,10 +1495,14 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
const isNameColumn = field.key === 'name';
|
const isNameColumn = field.key === 'name';
|
||||||
|
|
||||||
// Determine sticky behavior for name column
|
// Determine sticky behavior per column (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
|
||||||
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
const pinnedEntry = pinnedColumnLayout.get(field.key);
|
||||||
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
const columnStickDir = pinnedEntry ? stickyStates.get(field.key) : undefined;
|
||||||
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
const shouldBeSticky = !!columnStickDir;
|
||||||
|
const stickyLeft = columnStickDir === 'left';
|
||||||
|
const stickyRight = columnStickDir === 'right';
|
||||||
|
// Shadow on the outermost actually-stuck column per side
|
||||||
|
const isShadowColumn = field.key === shadowLeftKey || field.key === shadowRightKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1492,16 +1513,13 @@ const VirtualRow = memo(({
|
|||||||
// Use box-shadow for right border - renders more consistently with transforms
|
// Use box-shadow for right border - renders more consistently with transforms
|
||||||
// last:shadow-none removes the shadow from the last cell
|
// last:shadow-none removes the shadow from the last cell
|
||||||
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
// Name column needs overflow-visible for the floating AI suggestion badge
|
// overflow-visible needed for: name column (AI suggestion badge) and shadow columns (drop shadow)
|
||||||
// Description handles AI suggestions inside its popover, so no overflow needed
|
(isNameColumn || isShadowColumn) ? "overflow-visible" : "overflow-hidden",
|
||||||
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
// Pinned column sticky behavior - only when enabled and scrolled appropriately
|
||||||
// Name column sticky behavior - only when enabled and scrolled appropriately
|
shouldBeSticky && "lg:sticky",
|
||||||
shouldBeSticky && "lg:sticky lg:z-10",
|
// Drop shadow only on the outermost actually-stuck column
|
||||||
// Add left border when sticky-right since content scrolls behind from the left
|
isShadowColumn && 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))]",
|
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)]",
|
||||||
// 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)]",
|
|
||||||
// Solid background when sticky to overlay content
|
// Solid background when sticky to overlay content
|
||||||
// Use explicit [background:] syntax for consistent specificity
|
// Use explicit [background:] syntax for consistent specificity
|
||||||
// Selection (blue) takes priority over errors (red)
|
// Selection (blue) takes priority over errors (red)
|
||||||
@@ -1519,9 +1537,11 @@ const VirtualRow = memo(({
|
|||||||
width: columnWidth,
|
width: columnWidth,
|
||||||
minWidth: columnWidth,
|
minWidth: columnWidth,
|
||||||
flexShrink: 0,
|
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
|
// Position sticky left or right based on scroll direction
|
||||||
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
...(stickyLeft && { left: pinnedEntry!.left }),
|
||||||
...(stickyRight && { right: 0 }),
|
...(stickyRight && { right: pinnedEntry!.right }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CellWrapper
|
<CellWrapper
|
||||||
@@ -1652,6 +1672,8 @@ interface PriceColumnHeaderProps {
|
|||||||
fieldKey: 'msrp' | 'cost_each';
|
fieldKey: 'msrp' | 'cost_each';
|
||||||
label: string;
|
label: string;
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
|
pinButton?: React.ReactNode;
|
||||||
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MSRP_MULTIPLIERS = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
|
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;
|
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 [isHovered, setIsHovered] = useState(false);
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const [hasFillableCells, setHasFillableCells] = useState(false);
|
const [hasFillableCells, setHasFillableCells] = useState(false);
|
||||||
@@ -1788,7 +1810,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (!isPopoverOpen) setIsHovered(false);
|
if (!isPopoverOpen) setIsHovered(false);
|
||||||
@@ -1798,9 +1820,10 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
{(isHovered || isPopoverOpen) && hasFillableCells && (
|
||||||
isMsrp ? (
|
isMsrp ? (
|
||||||
// MSRP: Show popover with multiplier options
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -1810,11 +1833,9 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
|
||||||
'flex items-center gap-0.5',
|
'flex items-center gap-0.5',
|
||||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
'rounded border border-input bg-background p-1 shadow-sm',
|
||||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
'transition-opacity'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Calculator className="h-3 w-3" />
|
<Calculator className="h-3 w-3" />
|
||||||
@@ -1875,7 +1896,6 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
// Cost Each: Simple click behavior
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1886,11 +1906,9 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
handleCalculateCostEach();
|
handleCalculateCostEach();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
|
||||||
'flex items-center gap-0.5',
|
'flex items-center gap-0.5',
|
||||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
'rounded border border-input bg-background p-1 shadow-sm',
|
||||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
'transition-opacity'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Calculator className="h-3 w-3" />
|
<Calculator className="h-3 w-3" />
|
||||||
@@ -1903,6 +1921,8 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{pinButton}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1923,6 +1943,8 @@ interface UnitConversionColumnHeaderProps {
|
|||||||
fieldKey: 'weight' | 'length' | 'width' | 'height';
|
fieldKey: 'weight' | 'length' | 'width' | 'height';
|
||||||
label: string;
|
label: string;
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
|
pinButton?: React.ReactNode;
|
||||||
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversionOption = {
|
type ConversionOption = {
|
||||||
@@ -1942,7 +1964,7 @@ const DIMENSION_CONVERSIONS: ConversionOption[] = [
|
|||||||
{ label: 'Millimeters → Inches', factor: 0.0393701, roundTo: 2 },
|
{ 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 [isHovered, setIsHovered] = useState(false);
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const [hasConvertibleCells, setHasConvertibleCells] = useState(false);
|
const [hasConvertibleCells, setHasConvertibleCells] = useState(false);
|
||||||
@@ -2008,7 +2030,7 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (!isPopoverOpen) setIsHovered(false);
|
if (!isPopoverOpen) setIsHovered(false);
|
||||||
@@ -2018,6 +2040,8 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -2030,11 +2054,9 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
|
||||||
'flex items-center gap-0.5',
|
'flex items-center gap-0.5',
|
||||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
'rounded border border-input bg-background p-1 shadow-sm',
|
||||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
'transition-opacity'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Scale className="h-3 w-3" />
|
<Scale className="h-3 w-3" />
|
||||||
@@ -2066,6 +2088,8 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
{pinButton}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2084,6 +2108,8 @@ interface DefaultValueColumnHeaderProps {
|
|||||||
fieldKey: 'tax_cat' | 'ship_restrictions';
|
fieldKey: 'tax_cat' | 'ship_restrictions';
|
||||||
label: string;
|
label: string;
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
|
pinButton?: React.ReactNode;
|
||||||
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
|
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' },
|
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 [isHovered, setIsHovered] = useState(false);
|
||||||
const [hasEmptyCells, setHasEmptyCells] = useState(false);
|
const [hasEmptyCells, setHasEmptyCells] = useState(false);
|
||||||
|
|
||||||
@@ -2139,7 +2165,7 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
@@ -2147,6 +2173,8 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
|||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
{isHovered && hasEmptyCells && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -2158,11 +2186,10 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
|||||||
handleSetDefault();
|
handleSetDefault();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
|
||||||
'flex items-center gap-0.5',
|
'flex items-center gap-0.5',
|
||||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
'rounded border border-input bg-background p-1 shadow-sm',
|
||||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
'transition-opacity whitespace-nowrap'
|
'whitespace-nowrap'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-3 w-3" />
|
<Wand2 className="h-3 w-3" />
|
||||||
@@ -2174,6 +2201,8 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
{pinButton}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2181,25 +2210,17 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
|||||||
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
|
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NameColumnHeader Component
|
* PinButton Component - renders a pin/unpin toggle for any column header.
|
||||||
*
|
* Hidden by default, visible on column header hover (via parent `group` class).
|
||||||
* Renders the Name column header with a sticky toggle button.
|
* Always visible when the column is pinned.
|
||||||
* Pin icon toggles whether the name column sticks to edges when scrolling.
|
|
||||||
*/
|
*/
|
||||||
interface NameColumnHeaderProps {
|
interface PinButtonProps {
|
||||||
label: string;
|
isPinned: boolean;
|
||||||
isRequired: boolean;
|
onToggle: () => void;
|
||||||
isSticky: boolean;
|
|
||||||
onToggleSticky: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
|
const PinButton = memo(({ isPinned, onToggle }: PinButtonProps) => {
|
||||||
return (
|
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>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -2207,29 +2228,31 @@ const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }:
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSticky();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto flex items-center justify-center w-6 h-6 rounded',
|
// Matches existing action button style (Calculator, Scale, Wand2)
|
||||||
'transition-colors',
|
// No absolute positioning — parent container handles placement
|
||||||
isSticky
|
'flex items-center justify-center',
|
||||||
? 'text-primary bg-primary/10 hover:bg-primary/20'
|
'rounded border border-input bg-background p-1 shadow-sm',
|
||||||
: 'text-muted-foreground hover:bg-muted'
|
'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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
|
{isPinned ? <Pin className="h-3 w-3" /> : <PinOff className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
|
<p>{isPinned ? 'Unpin column' : 'Pin column'}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
NameColumnHeader.displayName = 'NameColumnHeader';
|
PinButton.displayName = 'PinButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main table component
|
* Main table component
|
||||||
@@ -2250,57 +2273,145 @@ export const ValidationTable = () => {
|
|||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Calculate name column's natural left position (before it becomes sticky)
|
// Set of currently pinned field keys — defaults to 'name' pinned (same as before)
|
||||||
// Selection (40) + Template (200) + all field columns before 'name'
|
const [pinnedColumns, setPinnedColumns] = useState<Set<string>>(() => new Set(['name']));
|
||||||
const nameColumnLeftOffset = useMemo(() => {
|
|
||||||
let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
|
|
||||||
for (const field of fields) {
|
// Toggle pin for a column — enforces MAX_PINNED_COLUMNS cap
|
||||||
if (field.key === 'name') break;
|
const togglePinColumn = useCallback((fieldKey: string) => {
|
||||||
offset += field.width || 150;
|
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;
|
||||||
}
|
}
|
||||||
return offset;
|
next.add(fieldKey);
|
||||||
}, [fields]);
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
|
// Compute layout info for each pinned column: cumulative left/right offsets, natural position, width, z-index
|
||||||
const [nameColumnSticky, setNameColumnSticky] = useState(true);
|
// Pinned columns maintain their natural field order (no reordering)
|
||||||
|
const pinnedColumnLayout = useMemo(() => {
|
||||||
|
const layout = new Map<string, PinnedColumnEntry>();
|
||||||
|
if (pinnedColumns.size === 0) return layout;
|
||||||
|
|
||||||
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
|
// Collect pinned fields in field order with their natural left positions
|
||||||
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
|
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) {
|
||||||
|
const w = field.width || 150;
|
||||||
|
if (pinnedColumns.has(field.key)) {
|
||||||
|
pinnedInOrder.push({ key: field.key, width: w, naturalLeft: runningOffset });
|
||||||
|
}
|
||||||
|
runningOffset += w;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate name column width
|
// Compute cumulative right offsets (from the right edge inward)
|
||||||
const nameColumnWidth = useMemo(() => {
|
const rightOffsets: number[] = new Array(pinnedInOrder.length).fill(0);
|
||||||
const nameField = fields.find(f => f.key === 'name');
|
let cumulativeRight = 0;
|
||||||
return nameField?.width || 400;
|
for (let i = pinnedInOrder.length - 1; i >= 0; i--) {
|
||||||
}, [fields]);
|
rightOffsets[i] = cumulativeRight;
|
||||||
|
cumulativeRight += pinnedInOrder[i].width;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync header scroll with body scroll + track sticky direction
|
// 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]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (tableContainerRef.current && headerRef.current) {
|
if (tableContainerRef.current && headerRef.current) {
|
||||||
const scrollLeft = tableContainerRef.current.scrollLeft;
|
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
const viewportWidth = tableContainerRef.current.clientWidth;
|
const viewportWidth = tableContainerRef.current.clientWidth;
|
||||||
|
if (headerRef.current.scrollLeft !== scrollLeft) {
|
||||||
headerRef.current.scrollLeft = scrollLeft;
|
headerRef.current.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
updateStickyState(scrollLeft, viewportWidth);
|
||||||
|
}
|
||||||
|
}, [updateStickyState]);
|
||||||
|
|
||||||
// Calculate name column's position relative to viewport
|
// Header scroll → sync body (handles scroll-wheel over header area)
|
||||||
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
|
const handleHeaderScroll = useCallback(() => {
|
||||||
const nameRightEdge = namePositionInViewport + nameColumnWidth;
|
if (headerRef.current && tableContainerRef.current) {
|
||||||
|
const scrollLeft = headerRef.current.scrollLeft;
|
||||||
// Determine sticky direction for name column
|
if (tableContainerRef.current.scrollLeft !== scrollLeft) {
|
||||||
if (nameColumnSticky) {
|
tableContainerRef.current.scrollLeft = scrollLeft;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
|
}, []);
|
||||||
|
|
||||||
// Compute filtered indices AND row IDs in a single pass
|
// Compute filtered indices AND row IDs in a single pass
|
||||||
// This avoids calling getState() during render for each row
|
// This avoids calling getState() during render for each row
|
||||||
@@ -2343,12 +2454,7 @@ export const ValidationTable = () => {
|
|||||||
return { filteredIndices: indices, rowIdMap: idMap };
|
return { filteredIndices: indices, rowIdMap: idMap };
|
||||||
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
||||||
|
|
||||||
// Toggle for sticky name column
|
// Build columns - ONLY depends on fields and pinnedColumns, NOT selection state
|
||||||
const toggleNameColumnSticky = useCallback(() => {
|
|
||||||
setNameColumnSticky(prev => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Build columns - ONLY depends on fields, NOT selection state
|
|
||||||
// Selection state is handled by isolated HeaderCheckbox component
|
// Selection state is handled by isolated HeaderCheckbox component
|
||||||
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
||||||
// Selection column - uses isolated HeaderCheckbox to prevent cascading re-renders
|
// 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 isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||||
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
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 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
|
// Determine which header component to render
|
||||||
const renderHeader = () => {
|
const renderHeader = () => {
|
||||||
if (isNameColumn) {
|
|
||||||
return (
|
|
||||||
<NameColumnHeader
|
|
||||||
label={field.label}
|
|
||||||
isRequired={isRequired}
|
|
||||||
isSticky={nameColumnSticky}
|
|
||||||
onToggleSticky={toggleNameColumnSticky}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isPriceColumn) {
|
if (isPriceColumn) {
|
||||||
return (
|
return (
|
||||||
<PriceColumnHeader
|
<PriceColumnHeader
|
||||||
fieldKey={field.key as 'msrp' | 'cost_each'}
|
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
|
pinButton={pinBtn}
|
||||||
|
isPinned={isPinned}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2400,6 +2506,8 @@ export const ValidationTable = () => {
|
|||||||
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
|
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
|
pinButton={pinBtn}
|
||||||
|
isPinned={isPinned}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2409,15 +2517,25 @@ export const ValidationTable = () => {
|
|||||||
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
|
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
|
pinButton={pinBtn}
|
||||||
|
isPinned={isPinned}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Plain header — pin button in its own absolutely-positioned container
|
||||||
return (
|
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>
|
<span className="truncate">{field.label}</span>
|
||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2430,7 +2548,7 @@ export const ValidationTable = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [selectionColumn, templateColumn, ...dataColumns];
|
return [selectionColumn, templateColumn, ...dataColumns];
|
||||||
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
|
}, [fields, pinnedColumns, togglePinColumn]);
|
||||||
|
|
||||||
// Calculate total table width for horizontal scrolling
|
// Calculate total table width for horizontal scrolling
|
||||||
const totalTableWidth = useMemo(() => {
|
const totalTableWidth = useMemo(() => {
|
||||||
@@ -2454,22 +2572,25 @@ export const ValidationTable = () => {
|
|||||||
{/* Copy-down banner - shows when copy-down mode is active */}
|
{/* Copy-down banner - shows when copy-down mode is active */}
|
||||||
<CopyDownBanner />
|
<CopyDownBanner />
|
||||||
|
|
||||||
{/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */}
|
{/* Fixed header - syncs horizontal scroll bidirectionally with body */}
|
||||||
<div
|
<div
|
||||||
ref={headerRef}
|
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 }}
|
style={{ height: HEADER_HEIGHT }}
|
||||||
|
onScroll={handleHeaderScroll}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex h-full"
|
className="flex h-full"
|
||||||
style={{ minWidth: totalTableWidth }}
|
style={{ minWidth: totalTableWidth }}
|
||||||
>
|
>
|
||||||
{columns.map((column, index) => {
|
{columns.map((column, index) => {
|
||||||
const isNameColumn = column.id === 'name';
|
// Per-column sticky state (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
|
||||||
// Determine sticky behavior for header name column
|
const pinnedEntry = column.id ? pinnedColumnLayout.get(column.id) : undefined;
|
||||||
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
const columnStickDir = pinnedEntry && column.id ? stickyStates.get(column.id) : undefined;
|
||||||
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
const shouldBeSticky = !!columnStickDir;
|
||||||
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
const stickyLeft = columnStickDir === 'left';
|
||||||
|
const stickyRight = columnStickDir === 'right';
|
||||||
|
const isShadowColumn = column.id === shadowLeftKey || column.id === shadowRightKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -2479,18 +2600,20 @@ export const ValidationTable = () => {
|
|||||||
// Use box-shadow for right border - renders more consistently
|
// Use box-shadow for right border - renders more consistently
|
||||||
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
// Sticky header - only when enabled and scrolled appropriately
|
// 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))]",
|
shouldBeSticky && "lg:sticky 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)
|
// Drop shadow only on the outermost actually-stuck column per side
|
||||||
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
isShadowColumn && 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)]",
|
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={{
|
style={{
|
||||||
width: column.size || 150,
|
width: column.size || 150,
|
||||||
minWidth: column.size || 150,
|
minWidth: column.size || 150,
|
||||||
flexShrink: 0,
|
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
|
// Position sticky left or right based on scroll direction
|
||||||
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
...(stickyLeft && { left: pinnedEntry!.left }),
|
||||||
...(stickyRight && { right: 0 }),
|
...(stickyRight && { right: pinnedEntry!.right }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof column.header === 'function'
|
{typeof column.header === 'function'
|
||||||
@@ -2527,8 +2650,10 @@ export const ValidationTable = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
totalRowCount={rowCount}
|
totalRowCount={rowCount}
|
||||||
nameColumnSticky={nameColumnSticky}
|
pinnedColumnLayout={pinnedColumnLayout}
|
||||||
stickyDirection={stickyDirection}
|
stickyStates={stickyStates}
|
||||||
|
shadowLeftKey={shadowLeftKey}
|
||||||
|
shadowRightKey={shadowRightKey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -241,10 +241,13 @@ export function TemplateForm({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error saving template:', error);
|
console.error('Error saving template:', error);
|
||||||
console.error('Error response:', error.response?.data);
|
console.error('Error response:', error.response?.data);
|
||||||
|
const serverMessage = error.response?.data?.error;
|
||||||
toast.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 {
|
} finally {
|
||||||
|
|||||||
+407
-131
@@ -14,33 +14,59 @@ import {
|
|||||||
Header,
|
Header,
|
||||||
HeaderGroup,
|
HeaderGroup,
|
||||||
} from "@tanstack/react-table";
|
} 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 { DateRange } from "react-day-picker";
|
||||||
import { addDays, addMonths } from "date-fns";
|
import { addDays, addMonths } from "date-fns";
|
||||||
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||||
import { Input } from "@/components/ui/input";
|
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";
|
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() {
|
export default function Forecasting() {
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
from: addDays(addMonths(new Date(), -1), 1),
|
from: addDays(addMonths(new Date(), -6), 1),
|
||||||
to: new Date(),
|
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 [search, setSearch] = useState<string>("");
|
||||||
const FILTERS_KEY = "forecastingFilters";
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Restore saved brand and date range on first mount
|
// Restore saved filters on first mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FILTERS_KEY);
|
const raw = localStorage.getItem(FILTERS_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const saved = JSON.parse(raw);
|
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) {
|
if (saved.from && saved.to) {
|
||||||
const from = new Date(saved.from);
|
const from = new Date(saved.from);
|
||||||
const to = new Date(saved.to);
|
const to = new Date(saved.to);
|
||||||
@@ -48,148 +74,180 @@ export default function Forecasting() {
|
|||||||
setDateRange({ from, to });
|
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(() => {
|
setTimeout(() => {
|
||||||
try { queryClient.invalidateQueries({ queryKey: ["forecast"] }); } catch {}
|
try {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["forecast-v2"] });
|
||||||
|
} catch {}
|
||||||
}, 0);
|
}, 0);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist brand and date range
|
// Persist filters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
FILTERS_KEY,
|
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 {}
|
} catch {}
|
||||||
}, [selectedBrand, dateRange]);
|
}, [selectedBrand, dateRange, groupMode, selectedArtist]);
|
||||||
|
|
||||||
|
|
||||||
const handleDateRangeChange = (range: DateRange | undefined) => {
|
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||||
if (range) {
|
if (range) setDateRange(range);
|
||||||
setDateRange(range);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Data fetching ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { data: brands = [], isLoading: brandsLoading } = useQuery({
|
const { data: brands = [], isLoading: brandsLoading } = useQuery({
|
||||||
queryKey: ["brands"],
|
queryKey: ["brands"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch("/api/products/brands");
|
const response = await fetch("/api/products/brands");
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error("Failed to fetch brands");
|
||||||
throw new Error("Failed to fetch brands");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: forecastData, isLoading: forecastLoading } = useQuery({
|
const { data: rawData, isLoading: dataLoading } = useQuery<{
|
||||||
queryKey: ["forecast", selectedBrand, dateRange],
|
products: ProductDetail[];
|
||||||
|
artists: string[];
|
||||||
|
}>({
|
||||||
|
queryKey: ["forecast-v2", selectedBrand, dateRange],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
brand: selectedBrand,
|
brand: selectedBrand,
|
||||||
startDate: dateRange.from?.toISOString() || "",
|
startDate: dateRange.from?.toISOString() || "",
|
||||||
endDate: dateRange.to?.toISOString() || "",
|
endDate: dateRange.to?.toISOString() || "",
|
||||||
});
|
});
|
||||||
const response = await fetch(`/api/analytics/forecast?${params}`);
|
const response = await fetch(`/api/analytics/forecast-v2?${params}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error("Failed to fetch forecast data");
|
||||||
throw new Error("Failed to fetch forecast data");
|
return response.json();
|
||||||
}
|
|
||||||
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
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
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(() => {
|
// Artist filter (skip when in designer mode — show all designers)
|
||||||
if (!forecastData) return [] as ForecastItem[];
|
if (selectedArtist !== "all" && groupMode !== "designer") {
|
||||||
|
products = products.filter((p) => p.artist === selectedArtist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title search
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
if (!term) return forecastData;
|
if (term) {
|
||||||
|
products = products.filter((p) => p.title.toLowerCase().includes(term));
|
||||||
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 (allMatchedProducts.length > 0) {
|
return products;
|
||||||
const totalSoldAll = allMatchedProducts.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
}, [rawData, selectedArtist, search, groupMode]);
|
||||||
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 filteredGroups;
|
const lineGroups = useMemo(
|
||||||
}, [forecastData, search]);
|
() => groupByLine(filteredProducts),
|
||||||
|
[filteredProducts]
|
||||||
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const categoryGroups = useMemo(
|
||||||
data: displayData || [],
|
() => groupByCategory(filteredProducts),
|
||||||
columns,
|
[filteredProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const designerGroups = useMemo(
|
||||||
|
() => groupByDesigner(filteredProducts),
|
||||||
|
[filteredProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const crossLineAverages = useMemo(
|
||||||
|
() => computeCrossLineAverages(lineGroups),
|
||||||
|
[lineGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Tables ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lineTable = useReactTable({
|
||||||
|
data: lineGroups,
|
||||||
|
columns: lineColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setLineSorting,
|
||||||
getRowCanExpand: () => true,
|
getRowCanExpand: () => true,
|
||||||
state: {
|
state: { sorting: lineSorting },
|
||||||
sorting,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-10 space-y-6">
|
<div className="container mx-auto py-10 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<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]">
|
<div className="w-[200px]">
|
||||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||||
<SelectTrigger disabled={brandsLoading}>
|
<SelectTrigger disabled={brandsLoading}>
|
||||||
@@ -204,12 +262,70 @@ export default function Forecasting() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateRangePickerQuick
|
<DateRangePickerQuick
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={handleDateRangeChange}
|
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
|
<Input
|
||||||
placeholder="Filter by product title"
|
placeholder="Filter by product title"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -227,28 +343,96 @@ export default function Forecasting() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{forecastLoading ? (
|
{/* ─── Main data table ────────────────────────────────────── */}
|
||||||
|
{dataLoading ? (
|
||||||
<div className="h-24 flex items-center justify-center">
|
<div className="h-24 flex items-center justify-center">
|
||||||
Loading sales data...
|
Loading sales data...
|
||||||
</div>
|
</div>
|
||||||
) : forecastData && (
|
) : rawData && (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
|
{groupMode === "line" ? (
|
||||||
|
<LineViewTable table={lineTable} />
|
||||||
|
) : groupMode === "designer" ? (
|
||||||
|
<DesignerViewTable table={designerTable} />
|
||||||
|
) : (
|
||||||
|
<CategoryViewTable table={catTable} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<ForecastItem>) => (
|
{table.getHeaderGroups().map((headerGroup: HeaderGroup<LineGroup>) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header: Header<ForecastItem, unknown>) => (
|
{headerGroup.headers.map((header: Header<LineGroup, unknown>) => (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -256,11 +440,11 @@ export default function Forecasting() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row: Row<ForecastItem>) => (
|
table.getRowModel().rows.map((row: Row<LineGroup>) => (
|
||||||
<Fragment key={row.id}>
|
<Fragment key={row.id}>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
className={row.original.line === "(No Line)" ? "opacity-60" : ""}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
@@ -270,8 +454,8 @@ export default function Forecasting() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{row.getIsExpanded() && (
|
{row.getIsExpanded() && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="p-0">
|
<TableCell colSpan={lineColumns.length} className="p-0">
|
||||||
{renderSubComponent({ row })}
|
<LineSubComponent row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -279,31 +463,123 @@ export default function Forecasting() {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell colSpan={lineColumns.length} className="h-24 text-center">
|
||||||
colSpan={columns.length}
|
No results.
|
||||||
className="h-24 text-center"
|
</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.
|
No results.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</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,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user