Enhance sticky columns in import, enhance forecasting page

This commit is contained in:
2026-04-02 16:20:24 -04:00
parent e43abdafd0
commit 4b2b3d5a9f
6 changed files with 1809 additions and 633 deletions
+142
View File
@@ -163,6 +163,148 @@ router.get('/forecast', async (req, res) => {
}
});
// Enhanced forecast endpoint: returns flat product list with line/artist/category/metrics
// for client-side grouping by line or category
router.get('/forecast-v2', async (req, res) => {
try {
const pool = req.app.locals.pool;
const brand = (req.query.brand || '').toString();
const startDateStr = req.query.startDate;
const endDateStr = req.query.endDate;
if (!brand) {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
const endDate = endDateStr ? new Date(endDateStr) : new Date();
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
const sql = `
WITH params AS (
SELECT $1::date AS start_date, $2::date AS end_date, $3::text AS brand
),
category_path AS (
WITH RECURSIVE cp AS (
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
FROM categories c WHERE c.parent_id IS NULL
UNION ALL
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
FROM categories c JOIN cp ON c.parent_id = cp.cat_id
)
SELECT * FROM cp
),
product_first_received AS (
SELECT
p.pid,
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
FROM products p
LEFT JOIN receivings r ON r.pid = p.pid
GROUP BY p.pid, p.first_received
),
recent_products AS (
SELECT p.pid
FROM products p
JOIN product_first_received fr ON fr.pid = p.pid
JOIN params pr ON 1=1
WHERE p.visible = true
AND COALESCE(p.brand,'Unbranded') = pr.brand
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
),
product_pick_category AS (
(
SELECT DISTINCT ON (pc.pid)
pc.pid,
c.name AS category_name,
COALESCE(cp.path, c.name) AS path
FROM product_categories pc
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
WHERE pc.pid IN (SELECT pid FROM recent_products)
AND (cp.path IS NULL OR (
cp.path NOT ILIKE '%Black Friday%'
AND cp.path NOT ILIKE '%Deals%'
))
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
)
UNION ALL
(
SELECT rp.pid, 'Uncategorized'::text, 'Uncategorized'::text
FROM recent_products rp
WHERE NOT EXISTS (
SELECT 1
FROM product_categories pc
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
WHERE pc.pid = rp.pid
AND (cp.path IS NULL OR (
cp.path NOT ILIKE '%Black Friday%'
AND cp.path NOT ILIKE '%Deals%'
))
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
)
)
)
SELECT
p.pid, p.title, p.sku,
COALESCE(p.line, '') AS line,
COALESCE(p.artist, '') AS artist,
ppc.category_name,
ppc.path AS category_path,
COALESCE(pm.lifetime_sales, 0) AS lifetime_sales,
COALESCE(pm.first_30_days_sales, 0) AS first_30d_sales,
COALESCE(pm.first_60_days_sales, 0) AS first_60d_sales,
COALESCE(pm.first_90_days_sales, 0) AS first_90d_sales,
COALESCE(pm.current_stock, 0) AS current_stock,
COALESCE(pm.date_first_received, pfr.first_received_date)::date AS date_first_received,
COALESCE(p.stock_quantity, 0) AS stock_quantity,
COALESCE(p.price, 0) AS price
FROM recent_products rp
JOIN products p ON p.pid = rp.pid
JOIN product_pick_category ppc ON ppc.pid = p.pid
LEFT JOIN product_metrics pm ON pm.pid = p.pid
LEFT JOIN product_first_received pfr ON pfr.pid = p.pid
ORDER BY COALESCE(p.line, ''), ppc.category_name, pm.lifetime_sales DESC NULLS LAST
`;
const { rows } = await pool.query(sql, [startISO, endISO, brand]);
// Collect distinct artists
const artistSet = new Set();
const products = rows.map(r => {
if (r.artist) artistSet.add(r.artist);
return {
pid: String(r.pid),
title: r.title,
sku: r.sku,
line: r.line || '',
artist: r.artist || '',
category: r.category_name,
categoryPath: r.category_path,
lifetimeSales: Number(r.lifetime_sales) || 0,
first30dSales: Number(r.first_30d_sales) || 0,
first60dSales: Number(r.first_60d_sales) || 0,
first90dSales: Number(r.first_90d_sales) || 0,
currentStock: Number(r.current_stock) || 0,
dateFirstReceived: r.date_first_received || null,
price: Number(r.price) || 0,
};
});
res.json({
products,
artists: [...artistSet].sort(),
});
} catch (error) {
console.error('Error fetching forecast-v2 data:', error);
res.status(500).json({ error: 'Failed to fetch forecast data' });
}
});
// ─── Inventory Intelligence Endpoints ────────────────────────────────────────
// Inventory KPI summary cards
+8 -10
View File
@@ -134,11 +134,10 @@ router.post('/', async (req, res) => {
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
// Check for unique constraint violation (PostgreSQL error code 23505)
if (error?.code === '23505') {
return res.status(409).json({
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
});
}
res.status(500).json({
@@ -232,11 +231,10 @@ router.put('/:id', async (req, res) => {
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
// Check for unique constraint violation (PostgreSQL error code 23505)
if (error?.code === '23505') {
return res.status(409).json({
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
});
}
res.status(500).json({