8 Commits

64 changed files with 7255 additions and 2386 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ npm run setup # Create required directories (logs, uploads)
- PostgreSQL with connection pooling (pg library) - PostgreSQL with connection pooling (pg library)
- Pool initialized in `utils/db.js` via `initPool()` - Pool initialized in `utils/db.js` via `initPool()`
- Pool attached to `app.locals.pool` for route access - Pool attached to `app.locals.pool` for route access
- Environment variables loaded from `/var/www/html/inventory/.env` (production path) - Environment variables loaded from `/var/www/inventory/.env` (production path)
**API Routes:** All prefixed with `/api/` **API Routes:** All prefixed with `/api/`
- `/api/products` - Product CRUD operations - `/api/products` - Product CRUD operations
@@ -164,7 +164,7 @@ Run tests for individual components or features:
## Important Notes ## Important Notes
- Environment variables must be configured in `/var/www/html/inventory/.env` for production - Environment variables must be configured in `/var/www/inventory/.env` for production
- The frontend expects the backend at `/api` (proxied in dev, served together in production) - The frontend expects the backend at `/api` (proxied in dev, served together in production)
- PM2 is used for production process management - PM2 is used for production process management
- Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var) - Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var)
+1 -1
View File
@@ -13,7 +13,7 @@ Not all of the information in this database is relevant as it's a direct export
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create. Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
The folder you see as inventory-server is actually a direct mount of the /var/www/html/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself. The folder you see as inventory-server is actually a direct mount of the /var/www/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat. The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
+5
View File
@@ -124,6 +124,11 @@ app.get('/me', async (req, res) => {
const user = userResult.rows[0]; const user = userResult.rows[0];
// Check if user is active
if (!user.is_active) {
return res.status(403).json({ error: 'Account is inactive' });
}
// Get user permissions // Get user permissions
let permissions = []; let permissions = [];
if (!user.is_admin) { if (!user.is_admin) {
View File
View File
@@ -0,0 +1,322 @@
// Customer lookup for the phone app (acot-phone-server).
//
// All queries hit the MySQL `sg` database via the shared SSH-tunneled pool in
// db/connection.js. The stats/orders logic mirrors the freescout
// ACOTCustomerData module so both apps display the same numbers for a given
// customer — the difference is that we key by phone, not email.
//
// NOTE: `users.phone` is not yet indexed in production. Admin will add
// `idx_phone (phone)` — queries here assume that exists for acceptable latency.
const express = require('express');
const router = express.Router();
const { getDbConnection, getCachedQuery } = require('../db/connection');
const { requirePhoneApiKey } = require('../utils/phoneAuth');
// Order status labels mirror ACOTCustomerDataServiceProvider.php.
const ORDER_STATUS_LABEL = {
0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined',
20: 'Placed', 22: 'Placed (Incomplete)', 40: 'Awaiting Payment',
45: 'Payment Pending', 50: 'Awaiting Products', 55: 'Shipping Later',
56: 'Shipping Together', 60: 'Ready', 61: 'Flagged', 62: 'Fix Before Pick',
65: 'Manual Picking', 67: 'Remote Send', 70: 'In PT', 80: 'Picked',
90: 'Awaiting Shipment', 91: 'Remote Wait', 92: 'Awaiting Pickup',
93: 'Fix Before Ship', 95: 'Shipped (Confirmed)', 100: 'Shipped',
};
const ORDER_STATUS_SHORT = {
0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined',
20: 'Placed', 22: 'Plcd Incomp', 40: 'Await Payment', 45: 'Pymt Pending',
50: 'Await Products', 55: 'Ship Later', 56: 'Ship Togethr', 60: 'Ready',
61: 'Flagged', 62: 'Fix Bfr Pick', 65: 'Manual Pick', 67: 'Remote Send',
70: 'In PT', 80: 'Picked', 90: 'Await Ship', 91: 'Remote Wait',
92: 'Await Pickup', 93: 'Fix Bfr Ship', 95: 'Shpd Confirm', 100: 'Shipped',
};
function statusLabel(s) { return ORDER_STATUS_LABEL[s] ?? `Unknown (${s})`; }
function statusShort(s) { return ORDER_STATUS_SHORT[s] ?? `Unknown (${s})`; }
// SIP trunks and historical CRM imports all disagree on phone format. Rather
// than normalize everything upstream, we search across the most common
// variations for US/Canada numbers. Falls through to the raw input for
// international numbers we can't safely reformat.
function phoneVariations(input) {
const raw = String(input || '').trim();
if (!raw) return [];
const digits = raw.replace(/\D/g, '');
const out = new Set([raw, digits]);
if (digits.length === 10) {
out.add(`+1${digits}`);
out.add(`1${digits}`);
} else if (digits.length === 11 && digits.startsWith('1')) {
out.add(`+${digits}`);
out.add(digits.slice(1)); // 10-digit form
out.add(`+1${digits.slice(1)}`);
}
return Array.from(out).filter(Boolean);
}
function trackingLink(method, tracking) {
if (!tracking) return '';
if (typeof method === 'string') {
if (method.startsWith('usps_') || method === 'fedex_smartpost') {
return `https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=${tracking}`;
}
if (method.startsWith('fedex_')) {
return `https://www.fedex.com/fedextrack/?trknbr=${tracking}`;
}
}
return '';
}
// Matches ACOTCustomerDataServiceProvider::imageUrl — sbing.com/i/products/<dir1>/<dir2>/<pid>-t-<iid>.jpg
function imageUrl(pid, iid = 1) {
const padded = String(pid).padStart(10, '0');
const dir1 = padded.slice(0, 4);
const dir2 = padded.slice(4, 7);
return `https://sbing.com/i/products/${dir1}/${dir2}/${pid}-t-${iid}.jpg`;
}
router.use(requirePhoneApiKey);
// ── GET /by-phone ──────────────────────────────────────────────────────────
// Returns top-line customer info for the incoming-call overlay.
router.get('/by-phone', async (req, res) => {
const phone = String(req.query.phone || '').trim();
if (!phone) return res.status(400).json({ success: false, error: 'phone required' });
const variations = phoneVariations(phone);
if (variations.length === 0) return res.json({ success: true, customer: null });
try {
const data = await getCachedQuery(
`customer-by-phone:${variations.join('|')}`,
'default',
async () => {
const { connection, release } = await getDbConnection();
try {
const placeholders = variations.map(() => '?').join(',');
// Tie-break by highest LTV per user instructions: subquery computes LTV
// for every matching user, then we pick the biggest.
const [users] = await connection.execute(
`SELECT u.cid, u.uid, u.firstname, u.lastname, u.email, u.phone, u.points,
COALESCE((
SELECT SUM(summary_total)
FROM _order
WHERE order_cid = u.cid AND order_status >= 50
), 0) AS lifetime_value,
COALESCE((
SELECT COUNT(*)
FROM _order
WHERE order_cid = u.cid AND order_status >= 20
), 0) AS num_orders,
(
SELECT AVG(summary_total)
FROM _order
WHERE order_cid = u.cid AND order_status >= 20
) AS avg_order
FROM users u
WHERE u.phone IN (${placeholders})
ORDER BY lifetime_value DESC
LIMIT 1`,
variations
);
return users[0] ?? null;
} finally {
release();
}
}
);
if (!data) return res.json({ success: true, customer: null });
res.json({
success: true,
customer: {
cid: Number(data.cid),
uid: data.uid,
firstName: data.firstname || null,
lastName: data.lastname || null,
email: data.email || null,
phone: data.phone,
points: Number(data.points) || 0,
lifetimeValue: Number(data.lifetime_value) || 0,
orderCount: Number(data.num_orders) || 0,
avgOrderValue: data.avg_order != null ? Number(data.avg_order) : 0,
},
});
} catch (err) {
console.error('customers/by-phone failed:', err);
res.status(500).json({ success: false, error: 'query_failed' });
}
});
// ── GET /search ────────────────────────────────────────────────────────────
// Name search for the dialer. Accepts a free-text query; splits on whitespace.
// - 1 token: LIKE against firstname OR lastname (prefix).
// - 2+ tokens: firstname LIKE A% AND lastname LIKE B% (order-sensitive on purpose).
router.get('/search', async (req, res) => {
const q = String(req.query.q || '').trim();
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 25);
if (q.length < 2) return res.json({ success: true, results: [] });
try {
const data = await getCachedQuery(
`customer-search:${q}:${limit}`,
'default',
async () => {
const { connection, release } = await getDbConnection();
try {
const tokens = q.split(/\s+/).filter(Boolean);
let sql;
let params;
if (tokens.length === 1) {
const pattern = `${tokens[0]}%`;
sql = `SELECT cid, firstname, lastname, email, phone
FROM users
WHERE (firstname LIKE ? OR lastname LIKE ?)
AND phone <> ''
ORDER BY lastname, firstname
LIMIT ?`;
params = [pattern, pattern, limit];
} else {
const firstPat = `${tokens[0]}%`;
const lastPat = `${tokens.slice(1).join(' ')}%`;
sql = `SELECT cid, firstname, lastname, email, phone
FROM users
WHERE firstname LIKE ? AND lastname LIKE ?
AND phone <> ''
ORDER BY lastname, firstname
LIMIT ?`;
params = [firstPat, lastPat, limit];
}
const [rows] = await connection.execute(sql, params);
return rows;
} finally {
release();
}
}
);
res.json({
success: true,
results: data.map((r) => ({
cid: Number(r.cid),
firstName: r.firstname || null,
lastName: r.lastname || null,
email: r.email || null,
phone: r.phone,
})),
});
} catch (err) {
console.error('customers/search failed:', err);
res.status(500).json({ success: false, error: 'query_failed' });
}
});
// ── GET /:cid/orders ───────────────────────────────────────────────────────
// Recent orders for the active-call screen — mirrors the freescout sidebar.
router.get('/:cid/orders', async (req, res) => {
const cid = Number(req.params.cid);
if (!Number.isFinite(cid) || cid <= 0) {
return res.status(400).json({ success: false, error: 'bad_cid' });
}
try {
const data = await getCachedQuery(
`customer-orders:${cid}`,
'orders',
async () => {
const { connection, release } = await getDbConnection();
try {
// MySQL-safe equivalent of the Laravel query in the freescout module.
// Active = placed OR shipped within the last 3 months.
const [ordersRaw] = await connection.execute(
`SELECT order_id, order_status, order_type, summary_total,
date_placed, ship_method_type, ship_method_tracking,
CASE
WHEN (order_status BETWEEN 20 AND 92
OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH))
THEN 1 ELSE 0
END AS _is_active
FROM _order
WHERE order_cid = ?
AND (order_status >= 20
OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH))
ORDER BY _is_active DESC, date_placed DESC`,
[cid]
);
const active = ordersRaw.filter((o) => o._is_active === 1);
const inactive = ordersRaw.filter((o) => o._is_active === 0);
const orders = active.concat(inactive.slice(0, Math.max(0, 10 - active.length)));
if (orders.length === 0) return [];
const orderIds = orders.map((o) => o.order_id);
const idPlaceholders = orderIds.map(() => '?').join(',');
const [items] = await connection.execute(
`SELECT order_id, prod_pid, prod_itemnumber, prod_description, prod_price, qty_ordered
FROM order_items
WHERE order_id IN (${idPlaceholders})`,
orderIds
);
// Main-image lookup: per-pid highest \`order\` at type=3 (matches the
// freescout module's raw SQL).
const pids = [...new Set(items.map((i) => Number(i.prod_pid)).filter(Boolean))];
const mainImagesByPid = new Map();
if (pids.length > 0) {
const pidList = pids.join(',');
const [imgRows] = await connection.execute(
`SELECT pi.pid, pi.iid
FROM product_images pi
INNER JOIN (
SELECT pid, MAX(\`order\`) AS max_order
FROM product_images
WHERE pid IN (${pidList}) AND type = 3
GROUP BY pid
) pm ON pi.pid = pm.pid AND pi.\`order\` = pm.max_order AND pi.type = 3`
);
for (const r of imgRows) mainImagesByPid.set(Number(r.pid), Number(r.iid));
}
const itemsByOrder = new Map();
for (const it of items) {
const oid = Number(it.order_id);
if (!itemsByOrder.has(oid)) itemsByOrder.set(oid, []);
const iid = mainImagesByPid.get(Number(it.prod_pid)) ?? 1;
itemsByOrder.get(oid).push({
pid: Number(it.prod_pid),
sku: it.prod_itemnumber || null,
name: it.prod_description || null,
price: Number(it.prod_price) || 0,
quantity: Number(it.qty_ordered) || 0,
imageUrl: imageUrl(it.prod_pid, iid),
});
}
return orders.map((o) => ({
orderId: Number(o.order_id),
datePlaced: o.date_placed,
total: Number(o.summary_total) || 0,
status: Number(o.order_status),
statusLabel: statusLabel(Number(o.order_status)),
statusShort: statusShort(Number(o.order_status)),
trackingNumber: o.ship_method_tracking || '',
trackingUrl: trackingLink(o.ship_method_type, o.ship_method_tracking),
items: itemsByOrder.get(Number(o.order_id)) || [],
}));
} finally {
release();
}
}
);
res.json({ success: true, orders: data });
} catch (err) {
console.error('customers/:cid/orders failed:', err);
res.status(500).json({ success: false, error: 'query_failed' });
}
});
module.exports = router;
@@ -4,57 +4,54 @@ const { getDbConnection } = require('../db/connection');
const router = express.Router(); const router = express.Router();
// Bucket boundaries by summary_subtotal (post-item-sale, pre-order-promo).
// The final entry is open-ended: all orders >= the last bound land there.
const RANGE_BOUNDS = [ const RANGE_BOUNDS = [
10, 20, 30, 40, 50, 60, 70, 80, 90, 10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
300, 400, 500, 1000, 1500, 2000 300, 400, 500, 1000, 1500
]; ];
const FINAL_BUCKET_KEY = 'PLUS'; const FINAL_BUCKET_KEY = '99999';
function buildRangeDefinitions() { function buildRangeDefinitions() {
const ranges = []; const ranges = [];
let previous = 0; let previous = 0;
for (const bound of RANGE_BOUNDS) { for (const bound of RANGE_BOUNDS) {
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
const key = bound.toString().padStart(5, '0'); const key = bound.toString().padStart(5, '0');
ranges.push({ ranges.push({
min: previous, min: previous,
max: bound, max: bound,
label, label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
key, key,
sort: bound
}); });
previous = bound; previous = bound;
} }
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket const lastBound = RANGE_BOUNDS[RANGE_BOUNDS.length - 1];
ranges.push({
min: lastBound,
max: null,
label: `$${lastBound.toLocaleString()}+`,
key: FINAL_BUCKET_KEY,
});
return ranges; return ranges;
} }
const RANGE_DEFINITIONS = buildRangeDefinitions(); const RANGE_DEFINITIONS = buildRangeDefinitions();
const BUCKET_CASE = (() => { function bucketKeyFor(subtotal) {
const parts = []; for (const range of RANGE_DEFINITIONS) {
for (let i = 0; i < RANGE_BOUNDS.length; i++) { if (range.max == null) return range.key;
const bound = RANGE_BOUNDS[i]; if (subtotal <= range.max) return range.key;
const key = bound.toString().padStart(5, '0');
if (i === RANGE_BOUNDS.length - 1) {
// For the last bucket (2000), include all orders >= 1500 (previous bound)
parts.push(`ELSE '${key}'`);
} else {
parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`);
}
} }
return `CASE\n ${parts.join('\n ')}\n END`; return FINAL_BUCKET_KEY;
})(); }
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1 const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
const DEFAULTS = { const DEFAULTS = {
merchantFeePercent: 2.9, merchantFeePercent: 2.9,
fixedCostPerOrder: 1.5, fixedCostPerOrder: 1.25,
pointsPerDollar: 0,
pointsRedemptionRate: 0, // Will be calculated from actual data
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
}; };
@@ -73,13 +70,6 @@ function formatDateForSql(dt) {
return dt.toFormat('yyyy-LL-dd HH:mm:ss'); return dt.toFormat('yyyy-LL-dd HH:mm:ss');
} }
function getMidpoint(range) {
if (range.max == null) {
return range.min + 200; // Rough estimate for 2000+
}
return (range.min + range.max) / 2;
}
router.get('/promos', async (req, res) => { router.get('/promos', async (req, res) => {
let connection; let connection;
try { try {
@@ -156,6 +146,188 @@ router.get('/promos', async (req, res) => {
} }
}); });
function emptyBucketAccumulator(range) {
return {
key: range.key,
label: range.label,
min: range.min,
max: range.max,
orderCount: 0,
sumOrderValue: 0,
sumProductDiscountAmount: 0,
sumPromoProductDiscount: 0,
sumCustomerItemCost: 0,
sumShippingChargeBase: 0,
sumShippingAfterAuto: 0,
sumShipPromoDiscount: 0,
sumShippingSurcharge: 0,
sumOrderSurcharge: 0,
sumCustomerShipCost: 0,
sumActualShippingCost: 0,
sumTotalRevenue: 0,
sumProductCogs: 0,
sumMerchantFees: 0,
sumPointsCost: 0,
sumFixedCosts: 0,
sumTotalCosts: 0,
sumProfit: 0,
};
}
function simulateOrder(order, config, derived) {
const orderValue = Number(order.summary_subtotal) || 0;
const retail = Number(order.summary_subtotal_retail) || orderValue;
const productDiscountAmount = Number(order.summary_discount_subtotal) || 0;
const pointsRedeemedDollars = Number(order.points_redeemed) || 0;
// summary_discount_subtotal is a kitchen-sink rollup that includes points
// redemptions (type 20). pointsCost already accrues for points awarded, so
// the points portion of historical discount must be excluded here to avoid
// double-counting it on orders that redeemed points.
const historicalProductDiscountExPoints = Math.max(0, productDiscountAmount - pointsRedeemedDollars);
const shippingChargeBase =
(Number(order.summary_shipping) || 0) + (Number(order.summary_shipping_rush) || 0);
const actualShippingCost = Number(order.ship_method_cost) || 0;
const cogs = Number(order.total_cogs) || 0;
let promoProductDiscount = 0;
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = orderValue * (config.productPromo.value / 100);
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
const targetRate = config.productPromo.value / 100;
const targetCustomerPrice = retail * (1 - targetRate);
promoProductDiscount = Math.max(0, orderValue - targetCustomerPrice);
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = config.productPromo.value;
} else if (config.productPromo.type === 'none' && config.applyHistoricalProductPromo) {
promoProductDiscount = historicalProductDiscountExPoints;
}
promoProductDiscount = Math.max(0, Math.min(promoProductDiscount, orderValue));
let shippingAfterAuto = shippingChargeBase;
for (const tier of config.shippingTiers) {
if (orderValue >= tier.threshold) {
if (tier.mode === 'percentage') {
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
} else if (tier.mode === 'flat') {
shippingAfterAuto = tier.value;
}
}
}
let shipPromoDiscount = 0;
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
if (config.shippingPromo.type === 'percentage') {
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
} else if (config.shippingPromo.type === 'fixed') {
shipPromoDiscount = config.shippingPromo.value;
}
if (config.shippingPromo.maxDiscount > 0) {
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
}
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
}
let shippingSurcharge = 0;
let orderSurcharge = 0;
for (const surcharge of config.surcharges) {
const meetsMin = orderValue >= surcharge.threshold;
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
if (meetsMin && meetsMax) {
if (surcharge.target === 'shipping') shippingSurcharge += surcharge.amount;
else if (surcharge.target === 'order') orderSurcharge += surcharge.amount;
}
}
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
const totalRevenue = customerItemCost + customerShipCost;
const productCogs = config.cogsCalculationMode === 'average'
? orderValue * derived.overallCogsPercentage
: cogs;
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
const pointsCost = orderValue * derived.pointsPerDollar * derived.redemptionRate * derived.pointDollarValue;
const fixedCosts = config.fixedCostPerOrder;
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
const profit = totalRevenue - totalCosts;
return {
orderValue,
productDiscountAmount,
promoProductDiscount,
customerItemCost,
shippingChargeBase,
shippingAfterAuto,
shipPromoDiscount,
shippingSurcharge,
orderSurcharge,
customerShipCost,
actualShippingCost,
totalRevenue,
productCogs,
merchantFees,
pointsCost,
fixedCosts,
totalCosts,
profit,
};
}
function accumulate(bucket, sim) {
bucket.orderCount += 1;
bucket.sumOrderValue += sim.orderValue;
bucket.sumProductDiscountAmount += sim.productDiscountAmount;
bucket.sumPromoProductDiscount += sim.promoProductDiscount;
bucket.sumCustomerItemCost += sim.customerItemCost;
bucket.sumShippingChargeBase += sim.shippingChargeBase;
bucket.sumShippingAfterAuto += sim.shippingAfterAuto;
bucket.sumShipPromoDiscount += sim.shipPromoDiscount;
bucket.sumShippingSurcharge += sim.shippingSurcharge;
bucket.sumOrderSurcharge += sim.orderSurcharge;
bucket.sumCustomerShipCost += sim.customerShipCost;
bucket.sumActualShippingCost += sim.actualShippingCost;
bucket.sumTotalRevenue += sim.totalRevenue;
bucket.sumProductCogs += sim.productCogs;
bucket.sumMerchantFees += sim.merchantFees;
bucket.sumPointsCost += sim.pointsCost;
bucket.sumFixedCosts += sim.fixedCosts;
bucket.sumTotalCosts += sim.totalCosts;
bucket.sumProfit += sim.profit;
}
function finalizeBucket(b, totalOrders) {
const n = b.orderCount;
const avg = (sum) => (n > 0 ? sum / n : 0);
return {
key: b.key,
label: b.label,
min: b.min,
max: b.max,
orderCount: n,
weight: totalOrders > 0 ? n / totalOrders : 0,
orderValue: avg(b.sumOrderValue),
productDiscountAmount: avg(b.sumProductDiscountAmount),
promoProductDiscount: avg(b.sumPromoProductDiscount),
customerItemCost: avg(b.sumCustomerItemCost),
shippingChargeBase: avg(b.sumShippingChargeBase),
shippingAfterAuto: avg(b.sumShippingAfterAuto),
shipPromoDiscount: avg(b.sumShipPromoDiscount),
shippingSurcharge: avg(b.sumShippingSurcharge),
orderSurcharge: avg(b.sumOrderSurcharge),
customerShipCost: avg(b.sumCustomerShipCost),
actualShippingCost: avg(b.sumActualShippingCost),
totalRevenue: avg(b.sumTotalRevenue),
productCogs: avg(b.sumProductCogs),
merchantFees: avg(b.sumMerchantFees),
pointsCost: avg(b.sumPointsCost),
fixedCosts: avg(b.sumFixedCosts),
totalCosts: avg(b.sumTotalCosts),
profit: avg(b.sumProfit),
profitPercent: b.sumTotalRevenue > 0 ? b.sumProfit / b.sumTotalRevenue : 0,
};
}
router.post('/simulate', async (req, res) => { router.post('/simulate', async (req, res) => {
const { const {
dateRange = {}, dateRange = {},
@@ -167,6 +339,7 @@ router.post('/simulate', async (req, res) => {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode = 'actual', cogsCalculationMode = 'actual',
applyHistoricalProductPromo = false,
pointsConfig = {} pointsConfig = {}
} = req.body || {}; } = req.body || {};
@@ -176,20 +349,15 @@ router.post('/simulate', async (req, res) => {
const endDt = parseDate(dateRange.end, endDefault).endOf('day'); const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US'; const shipCountry = filters.shipCountry || 'US';
const rawPromoFilters = [ const promoIds = Array.from(
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
];
const promoCodes = Array.from(
new Set( new Set(
rawPromoFilters [
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
]
.map((value) => { .map((value) => {
if (typeof value === 'string') { if (typeof value === 'string') return value.trim();
return value.trim(); if (typeof value === 'number') return String(value);
}
if (typeof value === 'number') {
return String(value);
}
return ''; return '';
}) })
.filter((value) => value.length > 0) .filter((value) => value.length > 0)
@@ -199,6 +367,8 @@ router.post('/simulate', async (req, res) => {
const config = { const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent, merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder, fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
productPromo: { productPromo: {
type: productPromo.type || 'none', type: productPromo.type || 'none',
value: Number(productPromo.value || 0), value: Number(productPromo.value || 0),
@@ -248,300 +418,131 @@ router.post('/simulate', async (req, res) => {
connection = dbConn.connection; connection = dbConn.connection;
release = dbConn.release; release = dbConn.release;
const filteredOrdersParams = [ const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
shipCountry, let promoExistsClause = '';
formatDateForSql(startDt), if (promoIds.length > 0) {
formatDateForSql(endDt) const placeholders = promoIds.map(() => '?').join(',');
]; promoExistsClause = `
const promoJoin = promoCodes.length > 0 AND EXISTS (
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10' SELECT 1 FROM order_discounts od
: ''; WHERE od.order_id = o.order_id
AND od.discount_active = 1
let promoFilterClause = ''; AND od.discount_type = 10
if (promoCodes.length > 0) { AND od.discount_code IN (${placeholders})
const placeholders = promoCodes.map(() => '?').join(','); )
promoFilterClause = `AND od.discount_code IN (${placeholders})`; `;
filteredOrdersParams.push(...promoCodes); params.push(...promoIds);
} }
const filteredOrdersQuery = ` const ordersQuery = `
SELECT SELECT
o.order_id, o.order_id,
o.order_cid,
o.summary_subtotal, o.summary_subtotal,
o.summary_discount_subtotal, COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
o.summary_shipping, COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
o.ship_method_rate, COALESCE(o.summary_shipping, 0) AS summary_shipping,
o.ship_method_cost, COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
o.summary_points, COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
${BUCKET_CASE} AS bucket_key COALESCE(o.summary_points, 0) AS summary_points,
COALESCE(c.total_cogs, 0) AS total_cogs,
COALESCE(p.points_redeemed, 0) AS points_redeemed
FROM _order o FROM _order o
${promoJoin}
WHERE o.summary_shipping > 0
AND o.summary_total > 0
AND o.order_status NOT IN (15)
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
${promoFilterClause}
`;
const bucketParams = [
...filteredOrdersParams,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const bucketQuery = `
SELECT
f.bucket_key,
COUNT(*) AS order_count,
SUM(f.summary_subtotal) AS subtotal_sum,
SUM(f.summary_discount_subtotal) AS product_discount_sum,
SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum,
SUM(f.ship_method_rate) AS ship_rate_sum,
SUM(f.ship_method_cost) AS ship_cost_sum,
SUM(f.summary_points) AS points_awarded_sum,
SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum,
SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum,
AVG(f.summary_subtotal) AS avg_subtotal,
AVG(f.summary_discount_subtotal) AS avg_product_discount,
AVG(f.ship_method_rate) AS avg_ship_rate,
AVG(f.ship_method_cost) AS avg_ship_cost,
AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs
FROM (
${filteredOrdersQuery}
) AS f
LEFT JOIN ( LEFT JOIN (
SELECT order_id, SUM(cogs_amount) AS total_cogs SELECT order_id, SUM(cogs_amount) AS total_cogs
FROM report_sales_data FROM report_sales_data
WHERE action IN (1,2,3) WHERE action IN (1,2,3)
AND date_change BETWEEN ? AND ?
GROUP BY order_id GROUP BY order_id
) AS c ON c.order_id = f.order_id ) c ON c.order_id = o.order_id
LEFT JOIN ( LEFT JOIN (
SELECT order_id, SUM(discount_amount) AS points_redeemed SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
FROM order_discounts FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1 WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id GROUP BY order_id
) AS p ON p.order_id = f.order_id ) p ON p.order_id = o.order_id
GROUP BY f.bucket_key WHERE o.summary_total > 0
AND o.order_status >= 20
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
${promoExistsClause}
`; `;
const [rows] = await connection.execute(bucketQuery, bucketParams); const [orders] = await connection.execute(ordersQuery, params);
const totals = {
orders: 0,
subtotal: 0,
productDiscount: 0,
regularSubtotal: 0,
shipRate: 0,
shipCost: 0,
cogs: 0,
pointsAwarded: 0,
pointsRedeemed: 0
};
const rowMap = new Map();
for (const row of rows) {
const key = row.bucket_key || FINAL_BUCKET_KEY;
const parsed = {
orderCount: Number(row.order_count || 0),
subtotalSum: Number(row.subtotal_sum || 0),
productDiscountSum: Number(row.product_discount_sum || 0),
regularSubtotalSum: Number(row.regular_subtotal_sum || 0),
shipRateSum: Number(row.ship_rate_sum || 0),
shipCostSum: Number(row.ship_cost_sum || 0),
pointsAwardedSum: Number(row.points_awarded_sum || 0),
pointsRedeemedSum: Number(row.points_redeemed_sum || 0),
cogsSum: Number(row.cogs_sum || 0),
avgSubtotal: Number(row.avg_subtotal || 0),
avgProductDiscount: Number(row.avg_product_discount || 0),
avgShipRate: Number(row.avg_ship_rate || 0),
avgShipCost: Number(row.avg_ship_cost || 0),
avgCogs: Number(row.avg_cogs || 0)
};
rowMap.set(key, parsed);
totals.orders += parsed.orderCount;
totals.subtotal += parsed.subtotalSum;
totals.productDiscount += parsed.productDiscountSum;
totals.regularSubtotal += parsed.regularSubtotalSum;
totals.shipRate += parsed.shipRateSum;
totals.shipCost += parsed.shipCostSum;
totals.cogs += parsed.cogsSum;
totals.pointsAwarded += parsed.pointsAwardedSum;
totals.pointsRedeemed += parsed.pointsRedeemedSum;
}
const productDiscountRate = totals.regularSubtotal > 0
? totals.productDiscount / totals.regularSubtotal
: 0;
const pointsPerDollar = config.points.pointsPerDollar != null
? config.points.pointsPerDollar
: totals.subtotal > 0
? totals.pointsAwarded / totals.subtotal
: 0;
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
// Calculate redemption rate using dollars redeemed from the matched order set
let calculatedRedemptionRate = 0;
if (config.points.redemptionRate != null) {
calculatedRedemptionRate = config.points.redemptionRate;
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
if (totalRedeemedPoints > 0) {
calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
}
}
const redemptionRate = calculatedRedemptionRate;
// Calculate overall average COGS percentage for 'average' mode
let overallCogsPercentage = 0;
if (cogsCalculationMode === 'average' && totals.subtotal > 0) {
overallCogsPercentage = totals.cogs / totals.subtotal;
}
const bucketResults = [];
let weightedProfitAmount = 0;
let weightedProfitPercent = 0;
for (const range of RANGE_DEFINITIONS) {
const data = rowMap.get(range.key) || {
orderCount: 0,
avgSubtotal: 0,
avgShipRate: 0,
avgShipCost: 0,
avgCogs: 0
};
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
const shippingChargeBase = data.avgShipCost > 0 ? data.avgShipCost : 0;
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
// Calculate COGS based on the selected mode
let productCogs;
if (cogsCalculationMode === 'average') {
// Use overall average COGS percentage applied to this bucket's order value
productCogs = orderValue * overallCogsPercentage;
} else {
// Use actual COGS data from this bucket (existing behavior)
productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
}
const productDiscountAmount = orderValue * productDiscountRate;
const effectiveRegularPrice = productDiscountRate < 0.99
? orderValue / (1 - productDiscountRate)
: orderValue;
let promoProductDiscount = 0;
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue);
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
const targetRate = config.productPromo.value / 100;
const additionalRate = Math.max(0, targetRate - productDiscountRate);
promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice);
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, config.productPromo.value);
}
let shippingAfterAuto = shippingChargeBase;
for (const tier of config.shippingTiers) {
if (orderValue >= tier.threshold) {
if (tier.mode === 'percentage') {
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
} else if (tier.mode === 'flat') {
shippingAfterAuto = tier.value;
}
}
}
let shipPromoDiscount = 0;
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
if (config.shippingPromo.type === 'percentage') {
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
} else if (config.shippingPromo.type === 'fixed') {
shipPromoDiscount = config.shippingPromo.value;
}
if (config.shippingPromo.maxDiscount > 0) {
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
}
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
}
// Calculate surcharges
let shippingSurcharge = 0;
let orderSurcharge = 0;
for (const surcharge of config.surcharges) {
const meetsMin = orderValue >= surcharge.threshold;
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
if (meetsMin && meetsMax) {
if (surcharge.target === 'shipping') {
shippingSurcharge += surcharge.amount;
} else if (surcharge.target === 'order') {
orderSurcharge += surcharge.amount;
}
}
}
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
const totalRevenue = customerItemCost + customerShipCost;
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue;
const fixedCosts = config.fixedCostPerOrder;
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
const profit = totalRevenue - totalCosts;
const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0;
const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0;
weightedProfitAmount += profit * weight;
weightedProfitPercent += profitPercent * weight;
bucketResults.push({
key: range.key,
label: range.label,
min: range.min,
max: range.max,
orderCount: data.orderCount || 0,
weight,
orderValue,
productDiscountAmount,
promoProductDiscount,
customerItemCost,
shippingChargeBase,
shippingAfterAuto,
shipPromoDiscount,
shippingSurcharge,
orderSurcharge,
customerShipCost,
actualShippingCost,
totalRevenue,
productCogs,
merchantFees,
pointsCost,
fixedCosts,
totalCosts,
profit,
profitPercent
});
}
if (release) { if (release) {
release(); release();
release = null;
} }
let totalSubtotal = 0;
let totalProductDiscount = 0;
let totalCogs = 0;
let totalPointsAwarded = 0;
let totalPointsRedeemedDollars = 0;
for (const o of orders) {
totalSubtotal += Number(o.summary_subtotal) || 0;
totalProductDiscount += Number(o.summary_discount_subtotal) || 0;
totalCogs += Number(o.total_cogs) || 0;
totalPointsAwarded += Number(o.summary_points) || 0;
totalPointsRedeemedDollars += Number(o.points_redeemed) || 0;
}
const productDiscountRate = totalSubtotal > 0 ? totalProductDiscount / totalSubtotal : 0;
const overallCogsPercentage = totalSubtotal > 0 ? totalCogs / totalSubtotal : 0;
const pointsPerDollar = config.points.pointsPerDollar != null
? config.points.pointsPerDollar
: (totalSubtotal > 0 ? totalPointsAwarded / totalSubtotal : 0);
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
let redemptionRate;
if (config.points.redemptionRate != null) {
redemptionRate = config.points.redemptionRate;
} else if (totalPointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totalPointsRedeemedDollars / pointDollarValue;
redemptionRate = Math.min(1, totalRedeemedPoints / totalPointsAwarded);
} else {
redemptionRate = 0;
}
const derived = {
overallCogsPercentage,
pointsPerDollar,
redemptionRate,
pointDollarValue,
};
const buckets = new Map();
for (const range of RANGE_DEFINITIONS) {
buckets.set(range.key, emptyBucketAccumulator(range));
}
let grandTotalProfit = 0;
let grandTotalRevenue = 0;
for (const order of orders) {
const sim = simulateOrder(order, config, derived);
const bucketKey = bucketKeyFor(sim.orderValue);
const bucket = buckets.get(bucketKey);
accumulate(bucket, sim);
grandTotalProfit += sim.profit;
grandTotalRevenue += sim.totalRevenue;
}
const totalOrders = orders.length;
const bucketResults = RANGE_DEFINITIONS.map((range) =>
finalizeBucket(buckets.get(range.key), totalOrders)
);
const weightedProfitAmount = totalOrders > 0 ? grandTotalProfit / totalOrders : 0;
const weightedProfitPercent = grandTotalRevenue > 0 ? grandTotalProfit / grandTotalRevenue : 0;
res.json({ res.json({
dateRange: { dateRange: {
start: startDt.toISO(), start: startDt.toISO(),
end: endDt.toISO() end: endDt.toISO()
}, },
totals: { totals: {
orders: totals.orders, orders: totalOrders,
subtotal: totals.subtotal, subtotal: totalSubtotal,
productDiscountRate, productDiscountRate,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,
@@ -52,6 +52,7 @@ app.use('/api/acot/discounts', require('./routes/discounts'));
app.use('/api/acot/employee-metrics', require('./routes/employee-metrics')); app.use('/api/acot/employee-metrics', require('./routes/employee-metrics'));
app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics')); app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics'));
app.use('/api/acot/operations-metrics', require('./routes/operations-metrics')); app.use('/api/acot/operations-metrics', require('./routes/operations-metrics'));
app.use('/api/acot/customers', require('./routes/customers'));
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
@@ -0,0 +1,28 @@
// Shared-secret auth for customer-lookup endpoints that expose PII.
// The acot-phone-server sends `x-acot-api-key` on every request; we compare
// against ACOT_PHONE_API_KEY from the environment using timing-safe comparison.
const crypto = require('crypto');
function requirePhoneApiKey(req, res, next) {
const expected = process.env.ACOT_PHONE_API_KEY;
if (!expected) {
console.error('ACOT_PHONE_API_KEY not configured; rejecting all requests');
return res.status(503).json({ success: false, error: 'auth_not_configured' });
}
const provided = req.get('x-acot-api-key') || '';
const expectedBuf = Buffer.from(expected);
const providedBuf = Buffer.from(provided);
if (
providedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
) {
return res.status(401).json({ success: false, error: 'unauthorized' });
}
next();
}
module.exports = { requirePhoneApiKey };
@@ -25,7 +25,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load klaviyo .env for API key // Load klaviyo .env for API key
dotenv.config({ path: path.resolve(__dirname, '../.env') }); dotenv.config({ path: path.resolve(__dirname, '../.env') });
// Also load the main inventory-server .env for DB credentials // Also load the main inventory-server .env for DB credentials
const mainEnvPath = '/var/www/html/inventory/.env'; const mainEnvPath = '/var/www/inventory/.env';
if (fs.existsSync(mainEnvPath)) { if (fs.existsSync(mainEnvPath)) {
dotenv.config({ path: mainEnvPath }); dotenv.config({ path: mainEnvPath });
} }
@@ -32,7 +32,7 @@ const envPaths = [
path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env) path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env)
path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env) path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env)
path.resolve(__dirname, '.env'), // Same directory path.resolve(__dirname, '.env'), // Same directory
'/var/www/html/inventory/.env' // Server absolute path '/var/www/inventory/.env' // Server absolute path
]; ];
let envLoaded = false; let envLoaded = false;
@@ -11,7 +11,7 @@
* *
* Environment: * Environment:
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from * Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
* /var/www/html/inventory/.env (or current process env). * /var/www/inventory/.env (or current process env).
*/ */
const { spawn } = require('child_process'); const { spawn } = require('child_process');
@@ -20,7 +20,7 @@ const fs = require('fs');
// Load .env file if it exists (production path) // Load .env file if it exists (production path)
const envPaths = [ const envPaths = [
'/var/www/html/inventory/.env', '/var/www/inventory/.env',
path.join(__dirname, '../../.env'), path.join(__dirname, '../../.env'),
]; ];
+183 -58
View File
@@ -7,10 +7,12 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const fsp = fs.promises; const fsp = fs.promises;
const sharp = require('sharp'); const sharp = require('sharp');
const axios = require('axios');
const net = require('net');
// Create uploads directory if it doesn't exist // Create uploads directory if it doesn't exist
const uploadsDir = path.join('/var/www/html/inventory/uploads/products'); const uploadsDir = path.join('/var/www/inventory/uploads/products');
const reusableUploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); const reusableUploadsDir = path.join('/var/www/inventory/uploads/reusable');
fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
fs.mkdirSync(reusableUploadsDir, { recursive: true }); fs.mkdirSync(reusableUploadsDir, { recursive: true });
@@ -152,6 +154,78 @@ cleanupImagesOnStartup();
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2)); const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
const getExtensionForImage = (mimetype, sourceName = '') => {
const sourceExt = path.extname(sourceName).toLowerCase();
if (sourceExt) return sourceExt;
switch (mimetype) {
case 'image/jpeg': return '.jpg';
case 'image/png': return '.png';
case 'image/gif': return '.gif';
case 'image/webp': return '.webp';
case 'image/tiff': return '.tif';
default: return '.jpg';
}
};
const createUploadFilename = (prefix, extension) => {
const safePrefix = String(prefix || 'product').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80) || 'product';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
return `${safePrefix}-${uniqueSuffix}${extension}`;
};
const buildUploadedImageResponse = (reqFile, processingResult, filePath) => {
reqFile.size = processingResult.finalSize;
const effectivePath = processingResult.newFilePath || filePath;
if (processingResult.newFilePath) {
reqFile.filename = path.basename(processingResult.newFilePath);
reqFile.mimetype = 'image/jpeg';
}
const baseUrl = 'https://tools.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/products/${reqFile.filename}`;
scheduleImageDeletion(reqFile.filename, effectivePath);
return {
success: true,
imageUrl,
fileName: reqFile.filename,
mimetype: reqFile.mimetype,
fullPath: effectivePath,
notices: processingResult.notices,
warnings: processingResult.warnings,
metadata: processingResult.metadata,
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
};
};
const isBlockedImageSourceHost = (hostname) => {
const host = String(hostname || '').toLowerCase();
if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
return true;
}
const ipVersion = net.isIP(host);
if (ipVersion === 4) {
const [first, second] = host.split('.').map(Number);
return (
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (ipVersion === 6) {
return host === '::1' || host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80');
}
return false;
};
const processUploadedImage = async (filePath, mimetype) => { const processUploadedImage = async (filePath, mimetype) => {
const notices = []; const notices = [];
const legacyWarnings = []; const legacyWarnings = [];
@@ -211,13 +285,19 @@ const processUploadedImage = async (filePath, mimetype) => {
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size }; return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
} }
// TIFF: convert to JPEG (don't store TIFF files) // Convert unsupported product image storage formats to JPEG.
let convertedFromTiff = false; let convertedToJpegFrom = null;
if (format === 'tiff') { if (format === 'tiff' || format === 'webp') {
convertedFromTiff = true; convertedToJpegFrom = format;
format = 'jpeg'; format = 'jpeg';
const message = 'Converted from TIFF to JPEG.'; const sourceLabel = convertedToJpegFrom === 'tiff' ? 'TIFF' : 'WebP';
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' }); const message = `Converted from ${sourceLabel} to JPEG.`;
notices.push({
message,
level: 'info',
code: `converted_from_${convertedToJpegFrom}`,
source: 'server'
});
legacyWarnings.push(message); legacyWarnings.push(message);
} }
@@ -353,7 +433,7 @@ const processUploadedImage = async (filePath, mimetype) => {
}); });
legacyWarnings.push(message); legacyWarnings.push(message);
} }
} else if (shouldConvertToRgb || convertedFromTiff) { } else if (shouldConvertToRgb || convertedToJpegFrom) {
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality }); const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
mutated = true; mutated = true;
finalBuffer = data; finalBuffer = data;
@@ -373,10 +453,10 @@ const processUploadedImage = async (filePath, mimetype) => {
metadata.optimizedSize = metadata.size; metadata.optimizedSize = metadata.size;
} }
// Rename TIFF files to .jpg after conversion // Rename converted source files to .jpg after conversion
let newFilePath = null; let newFilePath = null;
if (convertedFromTiff) { if (convertedToJpegFrom) {
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg'); newFilePath = filePath.replace(/\.(tiff?|webp)$/i, '.jpg');
if (newFilePath !== filePath) { if (newFilePath !== filePath) {
await fsp.rename(filePath, newFilePath); await fsp.rename(filePath, newFilePath);
} }
@@ -426,34 +506,19 @@ const storage = multer.diskStorage({
cb(null, uploadsDir); cb(null, uploadsDir);
}, },
filename: function (req, file, cb) { filename: function (req, file, cb) {
// Create unique filename with original extension const fileExt = getExtensionForImage(file.mimetype, file.originalname);
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const fileName = createUploadFilename(req.body.upc || 'product', fileExt);
// Make sure we preserve the original file extension
let fileExt = path.extname(file.originalname).toLowerCase();
// Ensure there is a proper extension based on mimetype if none exists
if (!fileExt) {
switch (file.mimetype) {
case 'image/jpeg': fileExt = '.jpg'; break;
case 'image/png': fileExt = '.png'; break;
case 'image/gif': fileExt = '.gif'; break;
case 'image/webp': fileExt = '.webp'; break;
case 'image/tiff': fileExt = '.tif'; break;
default: fileExt = '.jpg'; // Default to jpg
}
}
const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`;
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`); console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
cb(null, fileName); cb(null, fileName);
} }
}); });
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB fileSize: MAX_UPLOAD_BYTES,
}, },
fileFilter: function (req, file, cb) { fileFilter: function (req, file, cb) {
// Accept only image files // Accept only image files
@@ -652,34 +717,10 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
// Process the image (resize/compress/color-space) before responding // Process the image (resize/compress/color-space) before responding
const processingResult = await processUploadedImage(filePath, req.file.mimetype); const processingResult = await processUploadedImage(filePath, req.file.mimetype);
req.file.size = processingResult.finalSize; const responsePayload = buildUploadedImageResponse(req.file, processingResult, filePath);
// If TIFF was converted to JPG, update filename to match the renamed file
const effectivePath = processingResult.newFilePath || filePath;
if (processingResult.newFilePath) {
req.file.filename = path.basename(processingResult.newFilePath);
}
// Create URL for the uploaded file - using an absolute URL with domain
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
const baseUrl = 'https://tools.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
// Schedule this image for deletion in 24 hours
scheduleImageDeletion(req.file.filename, effectivePath);
// Return success response with image URL // Return success response with image URL
res.status(200).json({ res.status(200).json(responsePayload);
success: true,
imageUrl,
fileName: req.file.filename,
mimetype: req.file.mimetype,
fullPath: filePath,
notices: processingResult.notices,
warnings: processingResult.warnings,
metadata: processingResult.metadata,
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
});
} catch (error) { } catch (error) {
console.error('Error uploading image:', error); console.error('Error uploading image:', error);
@@ -697,6 +738,89 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
} }
}); });
router.post('/upload-image-url', async (req, res) => {
let filePath = null;
try {
const { imageUrl, upc, supplier_no } = req.body || {};
if (!imageUrl || typeof imageUrl !== 'string') {
return res.status(400).json({ error: 'imageUrl is required' });
}
let parsedUrl;
try {
parsedUrl = new URL(imageUrl.trim());
} catch {
return res.status(400).json({ error: 'Invalid image URL' });
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return res.status(400).json({ error: 'Image URL must use http or https' });
}
if (isBlockedImageSourceHost(parsedUrl.hostname)) {
return res.status(400).json({ error: 'Image URL host is not allowed' });
}
const response = await axios.get(parsedUrl.toString(), {
responseType: 'arraybuffer',
timeout: 15000,
maxContentLength: 15 * 1024 * 1024,
maxBodyLength: 15 * 1024 * 1024,
headers: {
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': 'inventory-image-import/1.0'
}
});
const contentType = String(response.headers['content-type'] || '').split(';')[0].toLowerCase();
if (!contentType.startsWith('image/')) {
return res.status(400).json({ error: 'URL did not return an image' });
}
const imageBuffer = Buffer.from(response.data);
if (!imageBuffer.length) {
return res.status(400).json({ error: 'Downloaded image was empty' });
}
const fileExt = getExtensionForImage(contentType, parsedUrl.pathname);
const filename = createUploadFilename(upc || supplier_no || 'product-url', fileExt);
filePath = path.join(uploadsDir, filename);
await fsp.writeFile(filePath, imageBuffer);
const reqFile = {
filename,
originalname: path.basename(parsedUrl.pathname) || filename,
mimetype: contentType,
size: imageBuffer.length,
path: filePath
};
console.log('Image URL downloaded:', {
filename: reqFile.filename,
originalUrl: parsedUrl.toString(),
mimetype: reqFile.mimetype,
size: reqFile.size,
path: reqFile.path
});
const processingResult = await processUploadedImage(filePath, reqFile.mimetype);
const responsePayload = buildUploadedImageResponse(reqFile, processingResult, filePath);
res.status(200).json(responsePayload);
} catch (error) {
console.error('Error uploading image from URL:', error);
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch (cleanupError) {
console.error('Failed to remove URL-downloaded file after processing error:', cleanupError);
}
}
res.status(500).json({ error: error.message || 'Failed to upload image from URL' });
}
});
// Image deletion endpoint // Image deletion endpoint
router.delete('/delete-image', (req, res) => { router.delete('/delete-image', (req, res) => {
try { try {
@@ -1237,6 +1361,7 @@ router.get('/search-products', async (req, res) => {
ELSE 6 ELSE 6
END END
`} `}
${isPidSearch ? '' : 'LIMIT 100'}
`; `;
// Prepare query parameters based on search type // Prepare query parameters based on search type
+146
View File
@@ -504,6 +504,152 @@ router.get('/search', async (req, res) => {
} }
}); });
// Batch lookup of product display data by pid list (used by Create PO page)
// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500.
// Returns rows in the same order as the deduped input pids; missing pids are silently dropped.
router.get('/batch', async (req, res) => {
const pool = req.app.locals.pool;
const raw = req.query.pids;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'pids query parameter is required' });
}
const pids = Array.from(new Set(
raw.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => Number.isInteger(n) && n > 0)
)).slice(0, 500);
if (pids.length === 0) {
return res.status(400).json({ error: 'No valid pids provided' });
}
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.image_175 AS image_url,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.notions_inv_count,
pm.current_stock,
p.baskets,
pm.on_order_qty,
p.total_sold,
pm.current_cost_price,
pm.date_last_sold,
pm.date_first_received,
p.moq
FROM products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
WHERE p.pid = ANY($1::bigint[])
`, [pids]);
// products.pid is BIGINT, which the pg driver returns as a STRING by
// default (to preserve precision for values > 2^53). Coerce to Number
// so the JSON response has numeric pids and Map lookups work.
const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) }));
const byPid = new Map(normalized.map(r => [r.pid, r]));
// Preserve the requested order so the frontend can append rows in input order
const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean);
res.json(ordered);
} catch (error) {
console.error('Error fetching batch products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Bulk resolve a list of identifiers (UPC / SKU / supplier # / notions # / pid)
// to candidate products in ONE query. Used by the Create PO paste/upload flow.
// Body: { identifiers: string[] }
// Response: { results: Array<{ identifier: string, candidates: Candidate[] }> }
// Results are returned in the same order as the input identifiers, with
// duplicates preserved (so the caller can pair results back to input rows
// positionally).
router.post('/resolve-identifiers', async (req, res) => {
const pool = req.app.locals.pool;
const body = req.body || {};
const raw = Array.isArray(body.identifiers) ? body.identifiers : null;
if (!raw) {
return res.status(400).json({ error: 'identifiers array is required' });
}
// Clean and cap. Cleaned keeps ORIGINAL order (duplicates preserved) so the
// response aligns with the caller's input rows positionally.
const cleaned = raw
.map(s => (typeof s === 'string' ? s.trim() : ''))
.filter(s => s.length > 0)
.slice(0, 1000);
if (cleaned.length === 0) {
return res.json({ results: [] });
}
// Dedupe for the DB lookup, and split numeric-looking values off for a
// separate bigint equality check (so the pid index can be used).
const uniqueTextIds = Array.from(new Set(cleaned));
const numericPids = Array.from(new Set(
uniqueTextIds
.filter(s => /^\d+$/.test(s) && s.length <= 18) // safe for Number()
.map(s => Number(s))
.filter(n => Number.isSafeInteger(n) && n > 0)
));
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.sku,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.brand
FROM products p
WHERE p.sku = ANY($1::text[])
OR p.barcode = ANY($1::text[])
OR p.vendor_reference = ANY($1::text[])
OR p.notions_reference = ANY($1::text[])
OR p.pid = ANY($2::bigint[])
`, [uniqueTextIds, numericPids]);
// Normalize pid to Number once (products.pid is BIGINT → pg returns string)
const products = rows.map(r => ({
pid: Number(r.pid),
title: r.title,
sku: r.sku,
barcode: r.barcode,
vendor_reference: r.vendor_reference,
notions_reference: r.notions_reference,
brand: r.brand,
}));
// Group per-input-identifier. A product counts as a match for an
// identifier if any of its indexable fields equals the identifier string
// (or the pid matches when the identifier is numeric). The comparison is
// done in JS against the fetched products — cheap because the product
// count is bounded by the DB result set.
const results = cleaned.map(identifier => {
const candidates = products.filter(p => (
p.sku === identifier ||
p.barcode === identifier ||
p.vendor_reference === identifier ||
p.notions_reference === identifier ||
String(p.pid) === identifier
));
return { identifier, candidates };
});
res.json({ results });
} catch (error) {
console.error('Error resolving identifiers:', error);
res.status(500).json({ error: 'Failed to resolve identifiers' });
}
});
// Get a single product // Get a single product
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
@@ -0,0 +1,391 @@
const express = require('express');
const router = express.Router();
// Stale PO statuses to exclude from our counting — these are the "still active"
// statuses as defined in scripts/metrics-new/update_product_metrics.sql. POs that
// are canceled or done are excluded separately.
const OPEN_PO_STATUSES = [
'created',
'ordered',
'preordered',
'electronically_sent',
'electronically_ready_send',
'receiving_started'
];
// GET /api/repeat-orders
// Finds products that are repeatedly appearing on small POs to a given supplier,
// indicating the auto-PO pattern where we keep buying MOQ instead of batching.
//
// Design notes on dedup between POs and receivings:
// - We count PO line appearances (not unit totals) as the frequency signal, since
// that is exactly the user action we are trying to detect (repeated small orders).
// - Canceled POs are filtered out so unreceived garbage cannot inflate the count.
// - Receivings are joined for read-only context (units actually arrived) and are
// NOT added to the frequency count, so there is no double-counting between POs
// and their matching receivings.
// - Stock / on-order / replenishment are pulled from product_metrics, which uses
// the canonical FIFO logic in update_product_metrics.sql to avoid double-counting
// units between POs and their receivings.
//
// Query params:
// supplierId (int, default 92 = Notions)
// windowDays (int, default 30, range 1..180)
// minPoCount (int, default 3)
// maxAvgQty (number, default 10 — filters out legitimate large batch orders)
router.get('/', async (req, res) => {
const supplierId = parseInt(req.query.supplierId, 10);
const windowDays = Math.min(180, Math.max(1, parseInt(req.query.windowDays, 10) || 30));
const minPoCount = Math.max(2, parseInt(req.query.minPoCount, 10) || 3);
const maxAvgQtyRaw = parseFloat(req.query.maxAvgQty);
const maxAvgQty = Number.isFinite(maxAvgQtyRaw) && maxAvgQtyRaw > 0 ? maxAvgQtyRaw : 10;
if (!Number.isFinite(supplierId)) {
return res.status(400).json({ error: 'supplierId query param is required' });
}
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(
`
WITH po_window AS (
SELECT
po.pid,
po.po_id,
po.date,
po.ordered,
po.po_cost_price,
po.status
FROM purchase_orders po
WHERE po.supplier_id = $1
AND po.date >= NOW() - ($2::int * INTERVAL '1 day')
AND po.status <> 'canceled'
),
po_agg AS (
SELECT
pid,
COUNT(*)::int AS po_line_count,
COUNT(DISTINCT date::date)::int AS po_days_active,
SUM(ordered)::int AS po_total_units,
AVG(ordered::numeric)::numeric(10,2) AS po_avg_qty,
MIN(ordered)::int AS po_min_qty,
MAX(ordered)::int AS po_max_qty,
MIN(date)::date AS first_po_date,
MAX(date)::date AS last_po_date,
SUM(ordered * po_cost_price)::numeric(14,2) AS po_total_cost,
-- Count of PO lines still in an open status (not yet received / closed)
COUNT(*) FILTER (WHERE status = ANY($5::text[]))::int AS po_open_line_count
FROM po_window
GROUP BY pid
HAVING COUNT(*) >= $3
AND AVG(ordered::numeric) <= $4
),
recv_window AS (
SELECT
pid,
COUNT(*)::int AS recv_line_count,
COUNT(DISTINCT received_date::date)::int AS recv_days_active,
SUM(qty_each)::int AS recv_total_units,
MAX(received_date)::date AS last_recv_date
FROM receivings
WHERE supplier_id = $1
AND received_date >= NOW() - ($2::int * INTERVAL '1 day')
AND status IN ('partial_received', 'full_received', 'paid')
GROUP BY pid
),
forecast_agg AS (
SELECT
pid,
SUM(forecast_units) FILTER (
WHERE forecast_date >= CURRENT_DATE
AND forecast_date < CURRENT_DATE + INTERVAL '30 days'
)::numeric(10,2) AS forecast_30d,
SUM(forecast_units) FILTER (
WHERE forecast_date >= CURRENT_DATE
AND forecast_date < CURRENT_DATE + INTERVAL '60 days'
)::numeric(10,2) AS forecast_60d,
MAX(lifecycle_phase) AS lifecycle_phase
FROM product_forecasts
WHERE pid IN (SELECT pid FROM po_agg)
GROUP BY pid
)
SELECT
pm.pid,
pm.sku,
pm.title,
pm.brand,
pm.vendor,
pm.image_url,
pm.is_visible,
pm.is_replenishable,
pm.moq,
pm.current_stock,
pm.notions_inv_count,
pm.on_order_qty,
pm.sales_30d,
pm.sales_velocity_daily::numeric(10,3) AS velocity,
pm.stock_cover_in_days::numeric(10,1) AS cover_days,
pm.replenishment_units,
pm.to_order_units,
pm.avg_lead_time_days,
pm.lifecycle_phase,
pm.earliest_expected_date,
po.po_line_count,
po.po_days_active,
po.po_total_units,
po.po_avg_qty,
po.po_min_qty,
po.po_max_qty,
po.first_po_date,
po.last_po_date,
po.po_total_cost,
po.po_open_line_count,
COALESCE(r.recv_line_count, 0) AS recv_line_count,
COALESCE(r.recv_days_active, 0) AS recv_days_active,
COALESCE(r.recv_total_units, 0) AS recv_total_units,
r.last_recv_date,
f.forecast_30d,
f.forecast_60d
FROM po_agg po
JOIN product_metrics pm ON pm.pid = po.pid
LEFT JOIN recv_window r ON r.pid = po.pid
LEFT JOIN forecast_agg f ON f.pid = po.pid
ORDER BY po.po_line_count DESC, pm.sales_30d DESC NULLS LAST
LIMIT 500
`,
[supplierId, windowDays, minPoCount, maxAvgQty, OPEN_PO_STATUSES]
);
// Supplier-level summary for the filters in effect — independent of the
// minPoCount / maxAvgQty filters so the user can see the full baseline.
const { rows: summaryRows } = await pool.query(
`
SELECT
COUNT(*)::int AS total_po_lines,
COUNT(DISTINCT po_id)::int AS distinct_po_ids,
COUNT(DISTINCT pid)::int AS distinct_products,
COUNT(DISTINCT date::date)::int AS distinct_days,
SUM(ordered)::int AS total_units,
AVG(ordered::numeric)::numeric(10,2) AS avg_qty_per_line
FROM purchase_orders
WHERE supplier_id = $1
AND date >= NOW() - ($2::int * INTERVAL '1 day')
AND status <> 'canceled'
`,
[supplierId, windowDays]
);
const results = rows.map((row) => {
const poAvg = parseFloat(row.po_avg_qty) || 0;
const velocity = parseFloat(row.velocity) || 0;
const forecast30 = parseFloat(row.forecast_30d) || 0;
const poCount = parseInt(row.po_line_count, 10) || 0;
const poDays = parseInt(row.po_days_active, 10) || 0;
// Repeat score: higher = more consolidation opportunity.
// - poCount boosts the score
// - inversely weighted by avg qty (small buys = bigger problem)
// - boosted by velocity (real demand, not noise)
const repeatScore = poAvg > 0
? Number(((poCount * Math.max(velocity, 0.1)) / poAvg).toFixed(2))
: 0;
return {
pid: row.pid,
sku: row.sku,
title: row.title,
brand: row.brand,
vendor: row.vendor,
imageUrl: row.image_url,
isVisible: row.is_visible,
isReplenishable: row.is_replenishable,
moq: row.moq,
currentStock: row.current_stock,
notionsInvCount: row.notions_inv_count,
onOrderQty: row.on_order_qty,
sales30d: row.sales_30d,
velocity,
coverDays: row.cover_days !== null ? parseFloat(row.cover_days) : null,
replenishmentUnits: row.replenishment_units,
toOrderUnits: row.to_order_units,
avgLeadTimeDays: row.avg_lead_time_days,
lifecyclePhase: row.lifecycle_phase,
earliestExpectedDate: row.earliest_expected_date,
poLineCount: poCount,
poDaysActive: poDays,
poTotalUnits: row.po_total_units,
poAvgQty: poAvg,
poMinQty: row.po_min_qty,
poMaxQty: row.po_max_qty,
firstPoDate: row.first_po_date,
lastPoDate: row.last_po_date,
poTotalCost: parseFloat(row.po_total_cost) || 0,
poOpenLineCount: row.po_open_line_count,
recvLineCount: row.recv_line_count,
recvDaysActive: row.recv_days_active,
recvTotalUnits: row.recv_total_units,
lastRecvDate: row.last_recv_date,
forecast30d: forecast30,
forecast60d: parseFloat(row.forecast_60d) || 0,
repeatScore,
};
});
const summary = summaryRows[0] || {};
res.json({
params: {
supplierId,
windowDays,
minPoCount,
maxAvgQty,
},
supplierSummary: {
totalPoLines: summary.total_po_lines || 0,
distinctPoIds: summary.distinct_po_ids || 0,
distinctProducts: summary.distinct_products || 0,
distinctDays: summary.distinct_days || 0,
totalUnits: summary.total_units || 0,
avgQtyPerLine: parseFloat(summary.avg_qty_per_line) || 0,
},
matchSummary: {
matchCount: results.length,
totalSmallPoLines: results.reduce((sum, r) => sum + r.poLineCount, 0),
totalSmallPoUnits: results.reduce((sum, r) => sum + (r.poTotalUnits || 0), 0),
totalSales30d: results.reduce((sum, r) => sum + (r.sales30d || 0), 0),
totalSuggestedUnits: results.reduce((sum, r) => sum + (r.replenishmentUnits || 0), 0),
},
results,
});
} catch (error) {
console.error('Error fetching repeat orders:', error);
res.status(500).json({ error: 'Failed to fetch repeat orders' });
}
});
// GET /api/repeat-orders/suppliers
// Returns the list of suppliers that have had non-canceled PO activity recently,
// so the UI can offer a supplier dropdown without an expensive scan.
router.get('/suppliers', async (req, res) => {
const windowDays = Math.min(365, Math.max(7, parseInt(req.query.windowDays, 10) || 90));
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(
`
SELECT
supplier_id,
MAX(vendor) AS vendor_name,
COUNT(*)::int AS line_count,
COUNT(DISTINCT po_id)::int AS po_count,
MAX(date)::date AS last_po_date
FROM purchase_orders
WHERE supplier_id IS NOT NULL
AND date >= NOW() - ($1::int * INTERVAL '1 day')
AND status <> 'canceled'
GROUP BY supplier_id
HAVING COUNT(*) >= 5
ORDER BY line_count DESC
`,
[windowDays]
);
res.json({
suppliers: rows.map((r) => ({
supplierId: r.supplier_id,
vendorName: r.vendor_name,
lineCount: r.line_count,
poCount: r.po_count,
lastPoDate: r.last_po_date,
})),
});
} catch (error) {
console.error('Error fetching repeat-order suppliers:', error);
res.status(500).json({ error: 'Failed to fetch suppliers' });
}
});
// GET /api/repeat-orders/:pid/history
// Returns the full PO + receiving history within the window for a single product,
// for drill-down detail when the user clicks a row.
router.get('/:pid/history', async (req, res) => {
const pid = parseInt(req.params.pid, 10);
const supplierId = parseInt(req.query.supplierId, 10);
const windowDays = Math.min(365, Math.max(1, parseInt(req.query.windowDays, 10) || 30));
if (!Number.isFinite(pid) || !Number.isFinite(supplierId)) {
return res.status(400).json({ error: 'pid and supplierId are required' });
}
try {
const pool = req.app.locals.pool;
const { rows: poRows } = await pool.query(
`
SELECT
po_id,
date,
expected_date,
ordered,
po_cost_price,
status,
notes
FROM purchase_orders
WHERE pid = $1
AND supplier_id = $2
AND date >= NOW() - ($3::int * INTERVAL '1 day')
ORDER BY date DESC
`,
[pid, supplierId, windowDays]
);
const { rows: recvRows } = await pool.query(
`
SELECT
receiving_id,
received_date,
qty_each,
cost_each,
status
FROM receivings
WHERE pid = $1
AND supplier_id = $2
AND received_date >= NOW() - ($3::int * INTERVAL '1 day')
ORDER BY received_date DESC
`,
[pid, supplierId, windowDays]
);
res.json({
pid,
supplierId,
purchaseOrders: poRows.map((r) => ({
poId: r.po_id,
date: r.date,
expectedDate: r.expected_date,
ordered: r.ordered,
costPrice: parseFloat(r.po_cost_price) || 0,
status: r.status,
notes: r.notes,
})),
receivings: recvRows.map((r) => ({
receivingId: r.receiving_id,
receivedDate: r.received_date,
qtyEach: r.qty_each,
costEach: parseFloat(r.cost_each) || 0,
status: r.status,
})),
});
} catch (error) {
console.error('Error fetching repeat-order history:', error);
res.status(500).json({ error: 'Failed to fetch product history' });
}
});
module.exports = router;
@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Create reusable uploads directory if it doesn't exist // Create reusable uploads directory if it doesn't exist
const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); const uploadsDir = path.join('/var/www/inventory/uploads/reusable');
fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
// Configure multer for file uploads // Configure multer for file uploads
+270
View File
@@ -0,0 +1,270 @@
const express = require('express');
const router = express.Router();
const MAX_MATCHES = 500;
const DESCRIPTION_SAMPLE_LIMIT = 8;
// GET /api/spec-lookup?company=...&term=...
// Returns aggregated specs across products matching company (brand) and term (title).
router.get('/', async (req, res) => {
const company = typeof req.query.company === 'string' ? req.query.company.trim() : '';
const term = typeof req.query.term === 'string' ? req.query.term.trim() : '';
if (!company && !term) {
return res.status(400).json({ error: 'company or term is required' });
}
try {
const pool = req.app.locals.pool;
const conditions = [];
const params = [];
if (company) {
params.push(`%${company}%`);
conditions.push(`brand ILIKE $${params.length}`);
}
if (term) {
params.push(`%${term}%`);
conditions.push(`title ILIKE $${params.length}`);
}
params.push(MAX_MATCHES);
const limitParam = `$${params.length}`;
const sql = `
SELECT
pid::TEXT AS pid,
title, sku, brand, vendor, artist,
country_of_origin, harmonized_tariff_code,
description, categories,
cost_price, regular_price,
moq, weight, length, width, height,
created_at
FROM products
WHERE ${conditions.join(' AND ')}
ORDER BY created_at DESC NULLS LAST
LIMIT ${limitParam}
`;
const { rows } = await pool.query(sql, params);
// Resolve category cat_ids → names. products.categories is a comma-separated cat_id string.
const catIds = new Set();
for (const r of rows) {
if (!r.categories) continue;
for (const tok of String(r.categories).split(',')) {
const trimmed = tok.trim();
if (trimmed && /^\d+$/.test(trimmed)) catIds.add(trimmed);
}
}
// Map cat_id → {name, type}. Types 10-13 are Section/Category/Subcategory/Sub-Subcategory; 20-21 are Theme/Subtheme.
const catIdToInfo = new Map();
if (catIds.size > 0) {
const { rows: catRows } = await pool.query(
`SELECT cat_id::TEXT AS cat_id, name, type FROM categories WHERE cat_id = ANY($1::bigint[])`,
[Array.from(catIds)],
);
for (const c of catRows) catIdToInfo.set(c.cat_id, { name: c.name, type: Number(c.type) });
}
const products = rows.map(r => ({
pid: Number(r.pid),
title: r.title,
sku: r.sku,
brand: r.brand,
vendor: r.vendor,
artist: r.artist,
country_of_origin: r.country_of_origin,
harmonized_tariff_code: r.harmonized_tariff_code,
description: r.description,
categories: r.categories,
cost_price: toNumberOrNull(r.cost_price),
regular_price: toNumberOrNull(r.regular_price),
moq: toNumberOrNull(r.moq),
weight: toNumberOrNull(r.weight),
length: toNumberOrNull(r.length),
width: toNumberOrNull(r.width),
height: toNumberOrNull(r.height),
created_at: r.created_at,
}));
res.json({
company,
term,
total: products.length,
truncated: products.length === MAX_MATCHES,
products,
aggregates: {
numeric: {
cost_price: numericAggregate(products, 'cost_price'),
regular_price: numericAggregate(products, 'regular_price'),
moq: numericAggregate(products, 'moq'),
weight: numericAggregate(products, 'weight'),
length: numericAggregate(products, 'length'),
width: numericAggregate(products, 'width'),
height: numericAggregate(products, 'height'),
},
categorical: {
artist: categoricalAggregate(products, 'artist'),
country_of_origin: categoricalAggregate(products, 'country_of_origin'),
harmonized_tariff_code: categoricalAggregate(products, 'harmonized_tariff_code'),
},
categories: groupedAggregate(products, catIdToInfo, new Set([10, 11, 12, 13])),
themes: groupedAggregate(products, catIdToInfo, new Set([20, 21])),
description: descriptionAggregate(products),
},
});
} catch (error) {
console.error('Error in spec-lookup:', error);
res.status(500).json({ error: 'Failed to compute spec lookup' });
}
});
function toNumberOrNull(v) {
if (v === null || v === undefined) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
// Aggregate a numeric field. Treats null/0 as unset since 0 is the codebase's "no value" sentinel.
// `products` is assumed to be ordered most-recent-first (created_at DESC) so the head of the
// list is also the recency window we use for trend detection.
function numericAggregate(products, field) {
const values = [];
// Iterate products in order so we know which values came from the most-recent rows.
for (const p of products) {
const v = p[field];
if (typeof v === 'number' && Number.isFinite(v) && v > 0) values.push(v);
}
if (!values.length) {
return { count: 0, sample_size: products.length, distribution: [] };
}
const sorted = [...values].sort((a, b) => a - b);
const sum = values.reduce((s, v) => s + v, 0);
const avg = sum / values.length;
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
const variance = values.reduce((s, v) => s + (v - avg) ** 2, 0) / values.length;
const stddev = Math.sqrt(variance);
const counts = new Map();
for (const v of values) {
const key = roundForKey(v);
counts.set(key, (counts.get(key) || 0) + 1);
}
const distribution = Array.from(counts.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count || a.value - b.value);
const mode = distribution[0]?.value ?? null;
const mode_count = distribution[0]?.count ?? 0;
// Trend detection: scan only the most-recent N values. N adapts to sample size so this
// can never look at more than ~20% of the data when the sample is small.
const recentN = Math.min(20, Math.max(5, Math.floor(values.length / 4)));
const recentValues = values.slice(0, recentN);
let recent_mode = null;
let recent_mode_count = 0;
let trending = false;
if (recentValues.length >= 3) {
const recentCounts = new Map();
for (const v of recentValues) {
const key = roundForKey(v);
recentCounts.set(key, (recentCounts.get(key) || 0) + 1);
}
const recentSorted = Array.from(recentCounts.entries()).sort((a, b) => b[1] - a[1] || a[0] - b[0]);
recent_mode = recentSorted[0][0];
recent_mode_count = recentSorted[0][1];
// Trend = recent mode differs from overall AND dominates the window AND has min absolute support.
const majority = recent_mode_count >= Math.ceil(recentValues.length * 0.6);
const minSupport = recent_mode_count >= 3;
trending = recent_mode !== mode && majority && minSupport;
}
return {
count: values.length,
sample_size: products.length,
avg,
median,
min: sorted[0],
max: sorted[sorted.length - 1],
stddev,
mode,
mode_count,
recent_mode,
recent_mode_count,
recent_window: recentValues.length,
trending,
distribution,
};
}
// Round to 4 decimals so JS-FP noise doesn't fragment the histogram.
function roundForKey(v) {
return Math.round(v * 10000) / 10000;
}
function categoricalAggregate(products, field) {
const counts = new Map();
for (const p of products) {
const v = p[field];
if (v === null || v === undefined) continue;
const key = String(v).trim();
if (!key) continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
return Array.from(counts.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
}
// Aggregate cat_id token counts, including only entries whose category type is in `acceptedTypes`.
function groupedAggregate(products, catIdToInfo, acceptedTypes) {
const counts = new Map();
for (const p of products) {
if (!p.categories) continue;
const tokens = String(p.categories).split(',').map(t => t.trim()).filter(Boolean);
for (const t of tokens) {
const info = catIdToInfo.get(t);
if (!info || !acceptedTypes.has(info.type)) continue;
counts.set(info.name, (counts.get(info.name) || 0) + 1);
}
}
return Array.from(counts.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
}
function descriptionAggregate(products) {
const counts = new Map();
for (const p of products) {
if (!p.description) continue;
const key = String(p.description).trim();
if (!key) continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
const duplicates = Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count);
// Recent unique samples (products are already ordered by created_at DESC).
const seen = new Set();
const samples = [];
for (const p of products) {
const desc = (p.description || '').trim();
if (!desc || seen.has(desc)) continue;
seen.add(desc);
samples.push({ value: desc, title: p.title, pid: p.pid, sku: p.sku });
if (samples.length >= DESCRIPTION_SAMPLE_LIMIT) break;
}
return { duplicates, samples };
}
module.exports = router;
+5 -1
View File
@@ -23,14 +23,16 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup'); const htsLookupRouter = require('./routes/hts-lookup');
const specLookupRouter = require('./routes/spec-lookup');
const importSessionsRouter = require('./routes/import-sessions'); const importSessionsRouter = require('./routes/import-sessions');
const importAuditLogRouter = require('./routes/import-audit-log'); const importAuditLogRouter = require('./routes/import-audit-log');
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log'); const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
const newsletterRouter = require('./routes/newsletter'); const newsletterRouter = require('./routes/newsletter');
const linesAggregateRouter = require('./routes/linesAggregate'); const linesAggregateRouter = require('./routes/linesAggregate');
const repeatOrdersRouter = require('./routes/repeat-orders');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env'; const envPath = '/var/www/inventory/.env';
console.log('Looking for .env file at:', envPath); console.log('Looking for .env file at:', envPath);
console.log('.env file exists:', fs.existsSync(envPath)); console.log('.env file exists:', fs.existsSync(envPath));
@@ -135,11 +137,13 @@ async function startServer() {
app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter); app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/spec-lookup', specLookupRouter);
app.use('/api/import-sessions', importSessionsRouter); app.use('/api/import-sessions', importSessionsRouter);
app.use('/api/import-audit-log', importAuditLogRouter); app.use('/api/import-audit-log', importAuditLogRouter);
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter); app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
app.use('/api/newsletter', newsletterRouter); app.use('/api/newsletter', newsletterRouter);
app.use('/api/lines-aggregate', linesAggregateRouter); app.use('/api/lines-aggregate', linesAggregateRouter);
app.use('/api/repeat-orders', repeatOrdersRouter);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/html/inventory/frontend vite build", "build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/inventory/frontend vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"mount": "../mountremote.command" "mount": "../mountremote.command"
+24 -8
View File
@@ -23,13 +23,15 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
const Forecasting = lazy(() => import('./pages/Forecasting')); const Forecasting = lazy(() => import('./pages/Forecasting'));
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator')); const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
const HtsLookup = lazy(() => import('./pages/HtsLookup')); const HtsLookup = lazy(() => import('./pages/HtsLookup'));
const Vendors = lazy(() => import('./pages/Vendors')); const SpecLookup = lazy(() => import('./pages/SpecLookup'));
const Categories = lazy(() => import('./pages/Categories')); const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands')); const Brands = lazy(() => import('./pages/Brands'));
const ProductLines = lazy(() => import('./pages/ProductLines')); const ProductLines = lazy(() => import('./pages/ProductLines'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders')); const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Newsletter = lazy(() => import('./pages/Newsletter')); const Newsletter = lazy(() => import('./pages/Newsletter'));
const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
// 2. Dashboard app - separate chunk // 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -136,13 +138,6 @@ function App() {
</Suspense> </Suspense>
</Protected> </Protected>
} /> } />
<Route path="/vendors" element={
<Protected page="vendors">
<Suspense fallback={<PageLoading />}>
<Vendors />
</Suspense>
</Protected>
} />
<Route path="/brands" element={ <Route path="/brands" element={
<Protected page="brands"> <Protected page="brands">
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
@@ -185,6 +180,13 @@ function App() {
</Suspense> </Suspense>
</Protected> </Protected>
} /> } />
<Route path="/spec-lookup" element={
<Protected page="spec_lookup">
<Suspense fallback={<PageLoading />}>
<SpecLookup />
</Suspense>
</Protected>
} />
<Route path="/forecasting" element={ <Route path="/forecasting" element={
<Protected page="forecasting"> <Protected page="forecasting">
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
@@ -192,6 +194,20 @@ function App() {
</Suspense> </Suspense>
</Protected> </Protected>
} /> } />
<Route path="/repeat-orders" element={
<Protected page="repeat_orders">
<Suspense fallback={<PageLoading />}>
<RepeatOrders />
</Suspense>
</Protected>
} />
<Route path="/create-purchase-order" element={
<Protected page="create_purchase_orders">
<Suspense fallback={<PageLoading />}>
<CreatePurchaseOrder />
</Suspense>
</Protected>
} />
{/* Always loaded settings */} {/* Always loaded settings */}
<Route path="/settings" element={ <Route path="/settings" element={
@@ -10,11 +10,12 @@ const PAGES = [
{ path: "/overview", permission: "access:overview" }, { path: "/overview", permission: "access:overview" },
{ path: "/products", permission: "access:products" }, { path: "/products", permission: "access:products" },
{ path: "/categories", permission: "access:categories" }, { path: "/categories", permission: "access:categories" },
{ path: "/vendors", permission: "access:vendors" }, { path: "/brands", permission: "access:brands" },
{ path: "/purchase-orders", permission: "access:purchase_orders" }, { path: "/purchase-orders", permission: "access:purchase_orders" },
{ path: "/analytics", permission: "access:analytics" }, { path: "/analytics", permission: "access:analytics" },
{ path: "/discount-simulator", permission: "access:discount_simulator" }, { path: "/discount-simulator", permission: "access:discount_simulator" },
{ path: "/hts-lookup", permission: "access:hts_lookup" }, { path: "/hts-lookup", permission: "access:hts_lookup" },
{ path: "/spec-lookup", permission: "access:spec_lookup" },
{ path: "/forecasting", permission: "access:forecasting" }, { path: "/forecasting", permission: "access:forecasting" },
{ path: "/import", permission: "access:import" }, { path: "/import", permission: "access:import" },
{ path: "/settings", permission: "access:settings" }, { path: "/settings", permission: "access:settings" },
+2 -2
View File
@@ -129,12 +129,12 @@ Admin users automatically have all permissions.
| `access:overview` | Access to Overview page | | `access:overview` | Access to Overview page |
| `access:products` | Access to Products page | | `access:products` | Access to Products page |
| `access:categories` | Access to Categories page | | `access:categories` | Access to Categories page |
| `access:brands` | Access to Brands page | | `access:brands` | Access to Brands & Vendors page |
| `access:vendors` | Access to Vendors page |
| `access:purchase_orders` | Access to Purchase Orders page | | `access:purchase_orders` | Access to Purchase Orders page |
| `access:analytics` | Access to Analytics page | | `access:analytics` | Access to Analytics page |
| `access:discount_simulator` | Access to Discount Simulator page | | `access:discount_simulator` | Access to Discount Simulator page |
| `access:hts_lookup` | Access to HTS Lookup page | | `access:hts_lookup` | Access to HTS Lookup page |
| `access:spec_lookup` | Access to Spec Lookup page |
| `access:forecasting` | Access to Forecasting page | | `access:forecasting` | Access to Forecasting page |
| `access:import` | Access to Import page | | `access:import` | Access to Import page |
| `access:settings` | Access to Settings page | | `access:settings` | Access to Settings page |
@@ -93,19 +93,9 @@ export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice
} }
} }
/** Get the backend field key for submission */ /** Get the product editor API field key for submission */
export function getSubmitFieldKey(field: BulkEditFieldChoice): string { export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
switch (field) { return field;
case "name": return "description"; // backend field is "description" for product name
case "description": return "notes"; // backend uses "notes" for product description
case "hts_code": return "harmonized_tariff_code";
case "msrp": return "sellingprice";
case "cost_each": return "cost_each";
case "tax_cat": return "tax_code";
case "size_cat": return "size_cat";
case "ship_restrictions": return "shipping_restrictions";
default: return field;
}
} }
interface BulkEditRowProps { interface BulkEditRowProps {
@@ -0,0 +1,787 @@
/**
* Modal that lets the user add products to the PO via a single unified
* interface that covers search, paste, and file upload.
*
* The three previously-separate tabs are replaced by one input surface:
* - Type a query → live-search `/api/products/search` and show results
* - Paste multi-line or tab-separated data → onPaste intercepts, parses,
* and switches to a column-mapping preview
* - Click "Upload file" OR drop a .xlsx/.csv onto the dialog → parse and
* show the same column-mapping preview
*
* The "mode" of the interface is derived from state (presence of a parsed
* table) rather than stored — no state machine, no drift.
*
* Paste/upload funnel through the same pipeline: parse → auto-detect
* columns → preview w/ inline remapping → resolve identifiers → (optional
* review dialog for ambiguous matches) → onAdd. Search results resolve
* directly since each row is a known pid.
*/
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Loader2,
Upload,
FileSpreadsheet,
AlertCircle,
Search as SearchIcon,
Check,
X,
} from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import type { SearchProduct } from "@/components/product-editor/types";
import {
parsePastedTable,
parseWorkbookFirstSheet,
autoDetectColumns,
applyMapping,
type ParsedTable,
type DetectedMapping,
type ColumnRole,
} from "./parseSpreadsheet";
import { resolveIdentifiers } from "./resolveIdentifiers";
import type { ResolveResult } from "./types";
import { ReviewMatchesDialog } from "./ReviewMatchesDialog";
import { cn } from "@/lib/utils";
export interface AddProductsResult {
/** Resolved (pid, qty) pairs ready to be hydrated by the parent. */
items: Array<{ pid: number; qty: number }>;
}
interface AddProductsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Pids that are already on the PO; used to dim/disable in search results. */
existingPids: Set<number>;
/** Called when the user has finalized a list of pids to add. */
onAdd: (result: AddProductsResult) => void;
}
interface QuickSearchResult {
pid: number;
title: string;
sku: string;
barcode: string;
brand: string;
line: string;
regular_price: number;
image_175: string | null;
}
const SEARCH_LIMIT = 100;
export function AddProductsDialog({
open,
onOpenChange,
existingPids,
onAdd,
}: AddProductsDialogProps) {
// ----- Search state --------------------------------------------------------
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [searched, setSearched] = useState(false);
// ----- Parse state (from paste/upload) ------------------------------------
const [table, setTable] = useState<ParsedTable | null>(null);
const [mapping, setMapping] = useState<DetectedMapping | null>(null);
const [filename, setFilename] = useState<string | null>(null);
// ----- Review (ambiguous match) state --------------------------------------
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewResult, setReviewResult] = useState<ResolveResult | null>(null);
const [resolving, setResolving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Mode is derived: if we have a parsed table, we're showing the preview;
// otherwise we're in search mode.
const inParseMode = table !== null;
// Computed at the dialog level so the sticky footer's Add button shows an
// accurate count without having to plumb state out of ParsedPreview.
// applyMapping is pure, so ParsedPreview computing the same thing for its
// own summary line / row highlighting can't drift from this value.
const parseImportable = useMemo(() => {
if (!table || !mapping || mapping.identifierIdx < 0) {
return { count: 0, hasIdentifier: false };
}
return {
count: applyMapping(table, mapping).length,
hasIdentifier: true,
};
}, [table, mapping]);
// ----- Reset everything (used by Start over button) -----------------------
const resetAll = useCallback(() => {
setTable(null);
setMapping(null);
setFilename(null);
setQuery("");
setSearchResults([]);
setSearchTotal(0);
setSearched(false);
}, []);
// ----- Close handler that also resets ------------------------------------
const handleClose = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) resetAll();
onOpenChange(nextOpen);
},
[onOpenChange, resetAll]
);
// ----- Search --------------------------------------------------------------
const runSearch = useCallback(async () => {
if (!query.trim()) return;
setIsSearching(true);
setSearched(true);
try {
const res = await axios.get<{ results: QuickSearchResult[]; total: number }>(
"/api/products/search",
{ params: { q: query } }
);
setSearchResults(res.data.results ?? []);
setSearchTotal(res.data.total ?? 0);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [query]);
const handleSelectSearchResult = useCallback(
async (result: QuickSearchResult) => {
// Look up full details to pull MOQ (to default qty sensibly)
try {
const res = await axios.get<SearchProduct[]>("/api/import/search-products", {
params: { pid: result.pid },
});
const full = (res.data ?? [])[0];
const moq = full?.moq && full.moq > 0 ? full.moq : 1;
onAdd({ items: [{ pid: Number(result.pid), qty: moq }] });
} catch {
// Fall back to qty=1 if the detail lookup fails — still adds the product
onAdd({ items: [{ pid: Number(result.pid), qty: 1 }] });
}
handleClose(false);
},
[onAdd, handleClose]
);
const handleLoadAllSearchResults = useCallback(async () => {
const pids = searchResults
.map((r) => Number(r.pid))
.filter((pid) => !existingPids.has(pid));
if (pids.length === 0) return;
try {
const res = await axios.get<SearchProduct[]>("/api/import/search-products", {
params: { pid: pids.join(",") },
});
const items = (res.data ?? []).map((p) => ({
pid: Number(p.pid),
qty: p.moq && p.moq > 0 ? p.moq : 1,
}));
onAdd({ items });
handleClose(false);
} catch {
toast.error("Failed to load products");
}
}, [searchResults, existingPids, onAdd, handleClose]);
// ----- Paste interception --------------------------------------------------
// If the clipboard content looks like tabular data (contains newlines or
// tabs), we hijack the paste and switch to parse mode instead of letting
// the text land in the search input.
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData("text");
if (!text || (!text.includes("\n") && !text.includes("\t"))) {
// Normal paste — let it through
return;
}
e.preventDefault();
const parsed = parsePastedTable(text);
if (!parsed.headers.length || !parsed.rows.length) {
toast.error("No data detected in pasted content");
return;
}
setTable(parsed);
setMapping(autoDetectColumns(parsed.headers, parsed.rows));
setFilename(null);
setQuery("");
}, []);
// ----- File handling -------------------------------------------------------
const handleFile = useCallback(async (file: File) => {
setFilename(file.name);
try {
const buffer = await file.arrayBuffer();
const parsed = parseWorkbookFirstSheet(buffer);
if (!parsed.headers.length || !parsed.rows.length) {
toast.error("No data found in file");
setFilename(null);
return;
}
setTable(parsed);
setMapping(autoDetectColumns(parsed.headers, parsed.rows));
} catch (e) {
console.error(e);
toast.error("Could not parse file");
setFilename(null);
}
}, []);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
// Reset the input so selecting the same file again still fires onChange
e.target.value = "";
},
[handleFile]
);
// Full-dialog dropzone — the user can drop a file anywhere on the modal
const { getRootProps, getInputProps, isDragActive } = useDropzone({
noClick: true,
noKeyboard: true,
maxFiles: 1,
accept: {
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"text/csv": [".csv"],
},
onDropAccepted: ([file]) => {
void handleFile(file);
},
onDropRejected: (rejections) => {
const msg = rejections[0]?.errors[0]?.message || "Invalid file";
toast.error(msg);
},
});
// ----- Parse preview → resolve → review → onAdd pipeline -------------------
const handleResolveAndAdd = useCallback(async () => {
if (!table || !mapping) return;
const rows = applyMapping(table, mapping);
if (rows.length === 0) {
toast.error("No valid rows to import");
return;
}
setResolving(true);
try {
const result = await resolveIdentifiers(rows);
// Fast path: everything matched cleanly → skip the review dialog
if (result.ambiguous.length === 0 && result.unmatched.length === 0) {
onAdd({ items: result.matched.map((m) => ({ pid: m.pid, qty: m.qty })) });
handleClose(false);
return;
}
setReviewResult(result);
setReviewOpen(true);
} catch (e) {
console.error(e);
toast.error("Failed to look up products");
} finally {
setResolving(false);
}
}, [table, mapping, onAdd, handleClose]);
const handleReviewConfirm = useCallback(
(resolved: Array<{ pid: number; qty: number }>) => {
setReviewOpen(false);
setReviewResult(null);
if (resolved.length === 0) {
toast.warning("No rows selected to add");
return;
}
onAdd({ items: resolved });
handleClose(false);
},
[onAdd, handleClose]
);
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0"
{...getRootProps()}
>
{/* Hidden file input driven by the Upload button */}
<input
ref={fileInputRef}
type="file"
accept=".xls,.xlsx,.csv"
className="hidden"
onChange={handleFileInputChange}
/>
{/* react-dropzone's input (for keyboard accessibility) */}
<input {...getInputProps()} />
{/* Drag-over overlay */}
{isDragActive && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-primary/10 backdrop-blur-sm rounded-lg border-2 border-dashed border-primary pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<FileSpreadsheet className="h-10 w-10" />
<p className="font-medium">Drop file to parse</p>
</div>
</div>
)}
<DialogHeader className="p-6 pb-3">
<DialogTitle>Add products</DialogTitle>
</DialogHeader>
{/* Primary input row — always visible */}
<div className="px-6 pb-3">
<div className="flex gap-2">
{/* Wrapper has px-0.5 so the input's focus ring isn't clipped
by the dialog's rounded clipping region on the left edge */}
<div className="flex-1 px-0.5">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
runSearch();
}
}}
onPaste={handlePaste}
placeholder="Search by name, UPC, SKU, supplier # — or paste multi-line data"
disabled={inParseMode}
/>
</div>
<Button onClick={runSearch} disabled={!query.trim() || inParseMode || isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SearchIcon className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={inParseMode}
>
<Upload className="h-4 w-4 mr-2" />
Upload file
</Button>
</div>
</div>
{/* Content area: parse preview OR search results OR empty hint.
`flex-1 overflow-auto min-h-0` lets this region shrink and
scroll independently while the sticky footer below stays put.
isSearching is checked BEFORE the searched branch so that the
moment the user clicks Search (which flips both `searched` and
`isSearching` to true in the same render) we show a loading
state instead of flashing "No results" against the previous
empty results array while the network request is in flight. */}
<div className="flex-1 overflow-auto px-6 pb-3 min-h-0">
{inParseMode && table && mapping ? (
<ParsedPreview
table={table}
mapping={mapping}
filename={filename}
onMappingChange={setMapping}
onReset={resetAll}
/>
) : isSearching ? (
<div className="text-center text-sm text-muted-foreground py-12">
<Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin opacity-60" />
Searching
</div>
) : searched ? (
<SearchResultsTable
results={searchResults}
total={searchTotal}
loadedPids={existingPids}
onSelect={handleSelectSearchResult}
onLoadAll={handleLoadAllSearchResults}
/>
) : (
<div className="text-center text-sm text-muted-foreground py-8">
<SearchIcon className="h-8 w-8 mx-auto mb-3 opacity-30" />
</div>
)}
</div>
{/* Sticky footer: only rendered in parse mode. Because it's a
flex-none sibling of the scrollable content area (not inside
it), it stays fixed at the bottom of the dialog regardless of
how tall the preview table grows. Search mode doesn't need a
footer since adding is click-per-row. */}
{inParseMode && (
<div className="flex-none border-t bg-background px-6 py-3 flex justify-end">
<Button
onClick={handleResolveAndAdd}
disabled={parseImportable.count === 0 || resolving}
>
{resolving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Looking up products
</>
) : (
<>
Add {parseImportable.count}{" "}
{parseImportable.count === 1 ? "product" : "products"}
</>
)}
</Button>
</div>
)}
</DialogContent>
</Dialog>
<ReviewMatchesDialog
open={reviewOpen}
onOpenChange={(o) => {
setReviewOpen(o);
if (!o) setReviewResult(null);
}}
result={reviewResult}
onConfirm={handleReviewConfirm}
/>
</>
);
}
// ============================================================================
// SearchResultsTable — compact results list with click-to-add + load-all
// ============================================================================
function SearchResultsTable({
results,
total,
loadedPids,
onSelect,
onLoadAll,
}: {
results: QuickSearchResult[];
total: number;
loadedPids: Set<number>;
onSelect: (result: QuickSearchResult) => void;
onLoadAll: () => void;
}) {
if (results.length === 0) {
return (
<div className="text-center text-sm text-muted-foreground py-12">
No results. Try a different search term.
</div>
);
}
const isTruncated = total > SEARCH_LIMIT;
const unloadedCount = results.filter((r) => !loadedPids.has(Number(r.pid))).length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{isTruncated
? `Showing ${SEARCH_LIMIT} of ${total} results`
: `${total} ${total === 1 ? "result" : "results"}`}
</span>
{unloadedCount > 1 && (
<Button variant="outline" size="sm" onClick={onLoadAll}>
Add all {unloadedCount}
</Button>
)}
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Item #</TableHead>
<TableHead>UPC</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((r) => {
const isLoaded = loadedPids.has(Number(r.pid));
return (
<TableRow
key={r.pid}
className={cn(
isLoaded
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-muted/50"
)}
onClick={() => !isLoaded && onSelect(r)}
>
<TableCell className="max-w-[320px]">
<div className="flex items-center gap-2">
{isLoaded && (
<Check className="h-3 w-3 text-emerald-600 flex-shrink-0" />
)}
<span className="truncate">{r.title}</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs">{r.sku}</TableCell>
<TableCell className="font-mono text-xs">{r.barcode}</TableCell>
<TableCell>{r.brand}</TableCell>
<TableCell className="text-right">
${Number(r.regular_price).toFixed(2)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
// ============================================================================
// ParsedPreview — column-role mapping + data preview for paste/upload
// ============================================================================
function ParsedPreview({
table,
mapping,
filename,
onMappingChange,
onReset,
}: {
table: ParsedTable;
mapping: DetectedMapping;
filename: string | null;
onMappingChange: (m: DetectedMapping) => void;
onReset: () => void;
}) {
// Precompute the exact set of row indices that will be imported with the
// current mapping. Used both for the aggregate count and for row-level
// "will be dropped" visual feedback. When no qty column is assigned,
// each row gets an implicit qty=1 — so the check only cares about the
// identifier cell being non-empty.
const importableIndices = useMemo(() => {
const set = new Set<number>();
if (mapping.identifierIdx < 0) return set;
const hasQtyCol = mapping.qtyIdx >= 0;
table.rows.forEach((row, idx) => {
const identifier = (row[mapping.identifierIdx] || "").trim();
if (!identifier) return;
if (hasQtyCol) {
const qtyStr = (row[mapping.qtyIdx] || "").trim();
const cleaned = qtyStr.replace(/[^0-9.-]/g, "");
const qty = Math.round(Number(cleaned));
if (!Number.isFinite(qty) || qty <= 0) return;
}
set.add(idx);
});
return set;
}, [table, mapping]);
const importable = importableIndices.size;
const hasIdentifier = mapping.identifierIdx >= 0;
const handleRoleChange = useCallback(
(colIdx: number, role: ColumnRole) => {
const newRoles = [...mapping.roles];
newRoles[colIdx] = role;
// Enforce: at most one identifier and one qty column. Demote any
// previous holder of the role to "ignore".
if (role === "identifier" || role === "qty") {
for (let i = 0; i < newRoles.length; i++) {
if (i !== colIdx && newRoles[i] === role) newRoles[i] = "ignore";
}
}
onMappingChange({
identifierIdx: newRoles.findIndex((r) => r === "identifier"),
qtyIdx: newRoles.findIndex((r) => r === "qty"),
roles: newRoles,
});
},
[mapping, onMappingChange]
);
const previewRows = table.rows.slice(0, 10);
// Single source of truth for per-role styling. The header cell, the
// data cells in that column, and the dropdown trigger all read from
// these helpers so visual drift is impossible.
const columnBg = (role: ColumnRole) => {
if (role === "identifier") return "bg-emerald-50";
if (role === "qty") return "bg-sky-50";
return "";
};
const headerBorder = (role: ColumnRole) => {
if (role === "identifier") return "border-b-2 border-emerald-500";
if (role === "qty") return "border-b-2 border-sky-500";
return "border-b border-border";
};
const triggerStyle = (role: ColumnRole) => {
if (role === "identifier") return "border-emerald-500 text-emerald-900 font-medium";
if (role === "qty") return "border-sky-500 text-sky-900 font-medium";
return "text-muted-foreground";
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-2">
{filename && (
<>
<FileSpreadsheet className="h-3 w-3" />
<span className="font-medium">{filename}</span>
<span>·</span>
</>
)}
<span>
<span className="font-semibold text-foreground">{importable}</span>{" "}
of {table.rows.length} {table.rows.length === 1 ? "row" : "rows"}{" "}
will be imported
</span>
</span>
<Button variant="ghost" size="sm" onClick={onReset}>
<X className="h-3 w-3 mr-1" />
Start over
</Button>
</div>
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
{table.headers.map((h, i) => {
const role = mapping.roles[i] || "ignore";
return (
<th
key={i}
className={cn(
"text-left p-2 align-top min-w-[140px]",
columnBg(role),
headerBorder(role)
)}
>
<div
className="font-mono text-xs truncate mb-1.5"
title={h || `Column ${i + 1}`}
>
{h || `Column ${i + 1}`}
</div>
<Select
value={role}
onValueChange={(v) => handleRoleChange(i, v as ColumnRole)}
>
<SelectTrigger
className={cn("h-7 text-xs w-full", triggerStyle(role))}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="identifier">Identifier</SelectItem>
<SelectItem value="qty">Quantity</SelectItem>
<SelectItem value="ignore">Ignore</SelectItem>
</SelectContent>
</Select>
</th>
);
})}
</tr>
</thead>
<tbody>
{previewRows.map((r, i) => {
const willImport = importableIndices.has(i);
return (
<tr
key={i}
className={cn(
"border-t",
!willImport && "bg-destructive/5"
)}
title={
!willImport
? "This row will be skipped (missing identifier or invalid quantity)"
: undefined
}
>
{r.map((cell, j) => {
const role = mapping.roles[j] || "ignore";
return (
<td
key={j}
// max-w + truncate + whitespace-nowrap keeps every
// row at a single-line height regardless of cell
// content length. The full value is available via
// the title tooltip for rows the user wants to
// inspect (e.g. long product descriptions).
className={cn(
"p-2 max-w-[240px] truncate whitespace-nowrap",
columnBg(role),
role === "identifier" && "font-mono",
role === "ignore" && "text-muted-foreground/70",
!willImport && "line-through decoration-destructive/50"
)}
title={cell || undefined}
>
{cell || <span className="text-muted-foreground"></span>}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{table.rows.length > 10 && (
<div className="text-xs text-muted-foreground p-2 border-t bg-muted/30">
and {table.rows.length - 10} more rows
</div>
)}
</div>
{!hasIdentifier && (
<div className="flex items-start gap-2 text-xs text-amber-700">
<AlertCircle className="h-3 w-3 mt-0.5" />
<span>Pick an identifier column above to continue.</span>
</div>
)}
{hasIdentifier && importable === 0 && (
<div className="flex items-start gap-2 text-xs text-destructive">
<AlertCircle className="h-3 w-3 mt-0.5" />
<span>No rows have a valid identifier.</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,67 @@
/**
* Post-submit success screen.
*
* Shows when the legacy backend has accepted the submission. Supports both
* flows: creating a brand-new PO and adding line items to an existing PO.
* The single primary action is the external link to the legacy admin's PO
* editor; the secondary action resets the page for another submission.
*/
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle2, ExternalLink, Plus } from "lucide-react";
type Mode = "create" | "add";
interface ConfirmationViewProps {
poId: number;
itemCount: number;
mode: Mode;
onCreateAnother: () => void;
}
export function ConfirmationView({
poId,
itemCount,
mode,
onCreateAnother,
}: ConfirmationViewProps) {
const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`;
const heading =
mode === "create" ? "Purchase order created" : "Products added to purchase order";
const itemNoun = itemCount === 1 ? "item" : "items";
const subhead =
mode === "create"
? `PO #${poId} with ${itemCount} ${itemNoun} has been submitted to the backend.`
: `${itemCount} ${itemNoun} added to PO #${poId}.`;
const resetLabel = mode === "create" ? "Create another" : "Add more";
return (
<div className="max-w-2xl mx-auto pt-12">
<Card>
<CardContent className="pt-8 pb-6">
<div className="flex flex-col items-center text-center">
<div className="rounded-full bg-emerald-100 p-3 mb-4">
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-semibold mb-1">{heading}</h2>
<p className="text-muted-foreground mb-6">{subhead}</p>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button asChild size="lg">
<a href={externalUrl} target="_blank" rel="noreferrer">
Open PO #{poId}
<ExternalLink className="h-4 w-4 ml-2" />
</a>
</Button>
<Button variant="outline" size="lg" onClick={onCreateAnother}>
<Plus className="h-4 w-4 mr-2" />
{resetLabel}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,354 @@
/**
* Sortable, checkbox-selectable table of PO line items.
*
* Columns: checkbox · image · title · UPC · supplier#/notions# · shelf ·
* basket · on-order · total sold · cost ea · last sold · first in ·
* [notions inv (conditional)] · MOQ (editable, local) · qty (editable,
* highlighted on MOQ mismatch) · remove
*
* Sorting is local-only (no server round-trip), driven by clickable
* column headers. The Notions column toggles based on the supplier prop.
* MOQ is inline-editable and updates a per-row `moqOverride` field —
* never sent to the backend, never persisted.
*/
import { useMemo, useState, useCallback } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { X as XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils";
import type { PoLineItem } from "./types";
import { NOTIONS_SUPPLIER_ID } from "./constants";
type SortKey =
| "title"
| "barcode"
| "supplier_ref"
| "current_stock"
| "baskets"
| "on_order_qty"
| "total_sold"
| "current_cost_price"
| "date_last_sold"
| "date_first_received"
| "notions_inv_count"
| "moq"
| "qty";
type SortDir = "asc" | "desc";
interface LineItemsTableProps {
items: PoLineItem[];
selectedPids: Set<number>;
supplierId: number | undefined;
onToggleSelect: (pid: number) => void;
onToggleSelectAll: (selectAll: boolean) => void;
onChangeQty: (pid: number, qty: number) => void;
onChangeMoqOverride: (pid: number, moq: number | undefined) => void;
onRemove: (pid: number) => void;
}
/** Effective MOQ for a row, considering the user's local override. */
function effectiveMoq(item: PoLineItem): number | null {
if (item.moqOverride != null) return item.moqOverride;
return item.moq;
}
/** True if the row's qty isn't a multiple of its (effective) MOQ. */
function isQtyMoqMismatch(item: PoLineItem): boolean {
const moq = effectiveMoq(item);
if (!moq || moq <= 0) return false;
if (item.qty <= 0) return false;
return item.qty % moq !== 0;
}
function compareValues(a: unknown, b: unknown, dir: SortDir): number {
const sign = dir === "asc" ? 1 : -1;
// Push nulls/undefineds to the bottom regardless of direction
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (typeof a === "number" && typeof b === "number") {
return (a - b) * sign;
}
// Date strings sort lexicographically as ISO; fall back to string compare
return String(a).localeCompare(String(b)) * sign;
}
export function LineItemsTable({
items,
selectedPids,
supplierId,
onToggleSelect,
onToggleSelectAll,
onChangeQty,
onChangeMoqOverride,
onRemove,
}: LineItemsTableProps) {
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const isNotions = Number(supplierId) === NOTIONS_SUPPLIER_ID;
const sorted = useMemo(() => {
if (!sortKey) return items;
const accessors: Record<SortKey, (i: PoLineItem) => unknown> = {
title: (i) => i.title?.toLowerCase(),
barcode: (i) => i.barcode,
supplier_ref: (i) => (isNotions ? i.notions_reference : i.vendor_reference),
current_stock: (i) => i.current_stock,
baskets: (i) => i.baskets,
on_order_qty: (i) => i.on_order_qty,
total_sold: (i) => i.total_sold,
current_cost_price: (i) => i.current_cost_price,
date_last_sold: (i) => i.date_last_sold,
date_first_received: (i) => i.date_first_received,
notions_inv_count: (i) => i.notions_inv_count,
moq: (i) => effectiveMoq(i),
qty: (i) => i.qty,
};
const accessor = accessors[sortKey];
return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir));
}, [items, sortKey, sortDir, isNotions]);
const handleSort = useCallback(
(key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
},
[sortKey]
);
const allSelected =
items.length > 0 && items.every((i) => selectedPids.has(i.pid));
const someSelected = !allSelected && items.some((i) => selectedPids.has(i.pid));
if (items.length === 0) {
return (
<div className="rounded-md border border-dashed p-12 text-center text-muted-foreground">
<p className="text-sm">No products added yet.</p>
</div>
);
}
const SortableHead = ({
label,
sortBy,
align = "left",
className,
}: {
label: string;
sortBy: SortKey;
align?: "left" | "right" | "center";
className?: string;
}) => {
const isActive = sortKey === sortBy;
return (
<TableHead
className={cn(
align === "right" && "text-right",
align === "center" && "text-center",
className
)}
>
<button
type="button"
onClick={() => handleSort(sortBy)}
className={cn(
"inline-flex items-center gap-1 hover:text-foreground transition-colors",
isActive ? "text-foreground" : "text-muted-foreground",
align === "right" && "flex-row-reverse",
align === "center" && "justify-center w-full"
)}
>
{label}
</button>
</TableHead>
);
};
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(v) => onToggleSelectAll(Boolean(v))}
aria-label="Select all rows"
/>
</TableHead>
<SortableHead label="Qty" sortBy="qty" align="center" />
<TableHead className="w-[72px] text-center">Image</TableHead>
<SortableHead label="Product" sortBy="title" />
<SortableHead label="UPC" sortBy="barcode" align="left" />
<SortableHead
label={isNotions ? "Notions #" : "Supplier #"}
sortBy="supplier_ref"
className="whitespace-nowrap"
/>
<SortableHead label="MOQ" sortBy="moq" align="center" />
<SortableHead label="Shelf" sortBy="current_stock" align="center" />
<SortableHead label="Basket" sortBy="baskets" align="center" />
<SortableHead label="On Order" sortBy="on_order_qty" align="center" />
<SortableHead label="Total Sold" sortBy="total_sold" align="center" />
<SortableHead label="Cost" sortBy="current_cost_price" align="center" />
<SortableHead label="Last Sold" sortBy="date_last_sold" align="center" className="whitespace-nowrap"/>
<SortableHead label="First In" sortBy="date_first_received" align="center" className="whitespace-nowrap"/>
{isNotions && (
<SortableHead
label="Notions Inv."
sortBy="notions_inv_count"
align="center"
/>
)}
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((item) => {
const isSelected = selectedPids.has(item.pid);
const moq = effectiveMoq(item);
const mismatch = isQtyMoqMismatch(item);
return (
<TableRow
key={item.pid}
className={cn(isSelected && "bg-muted/50")}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(item.pid)}
aria-label={`Select ${item.title}`}
/>
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
value={item.qty}
onChange={(e) => {
const n = Math.max(
0,
Math.round(Number(e.target.value.replace(/[^0-9]/g, "")) || 0)
);
onChangeQty(item.pid, n);
}}
className={cn(
"w-14 h-8 text-center",
mismatch &&
"border-amber-500 focus-visible:ring-amber-500 bg-amber-50"
)}
aria-label={`Quantity for ${item.title}`}
title={
mismatch
? `Not a multiple of MOQ (${moq})`
: undefined
}
/>
</TableCell>
<TableCell>
{item.image_url ? (
<img
src={item.image_url}
alt={item.title}
className="h-[60px] w-[60px] object-contain rounded"
loading="lazy"
/>
) : (
<div className="h-[60px] w-[60px] rounded bg-muted" />
)}
</TableCell>
<TableCell className="max-w-[350px] min-w-[200px]">
<div className="font-medium line-clamp-2" title={item.title}>
{item.title}
</div>
</TableCell>
<TableCell className="font-mono text-xs whitespace-nowrap text-center">
{item.barcode || "—"}
</TableCell>
<TableCell className="font-mono text-xs whitespace-nowrap text-center">
{(isNotions ? item.notions_reference : item.vendor_reference) || "—"}
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
value={moq ?? ""}
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, "");
if (v === "") {
onChangeMoqOverride(item.pid, 0);
} else {
const n = Math.max(0, Math.round(Number(v)));
onChangeMoqOverride(item.pid, Number.isFinite(n) ? n : 0);
}
}}
className="w-14 h-8 text-center text-xs"
aria-label={`MOQ for ${item.title}`}
/>
</TableCell>
<TableCell className="text-center">
{item.current_stock != null ? formatNumber(item.current_stock) : "—"}
</TableCell>
<TableCell className="text-center">
{item.baskets != null ? formatNumber(item.baskets) : "—"}
</TableCell>
<TableCell className="text-center">
{item.on_order_qty != null ? formatNumber(item.on_order_qty) : "—"}
</TableCell>
<TableCell className="text-center">
{item.total_sold != null ? formatNumber(item.total_sold) : "—"}
</TableCell>
<TableCell className="text-center whitespace-nowrap">
{item.current_cost_price != null
? formatCurrency(item.current_cost_price)
: "—"}
</TableCell>
<TableCell className="text-xs whitespace-nowrap text-center">
{item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"}
</TableCell>
<TableCell className="text-xs whitespace-nowrap text-center">
{item.date_first_received ? formatDateShort(item.date_first_received) : "—"}
</TableCell>
{isNotions && (
<TableCell className="text-center">
{item.notions_inv_count != null
? formatNumber(item.notions_inv_count)
: "—"}
</TableCell>
)}
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive hover:bg-transparent"
onClick={() => onRemove(item.pid)}
aria-label={`Remove ${item.title}`}
>
<XIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,62 @@
/**
* Floating action bar that appears when one or more PO line items are
* checkbox-selected. Provides bulk Remove + Clear selection.
*
* Pattern adapted from inventory/src/components/product-import/steps/
* ValidationStep/components/FloatingSelectionBar.tsx — but stripped down:
* no zustand store, no template management, no delete confirmation
* dialog (Remove on the PO page is local-only and instantly reversible
* by adding the product again, so a confirm step would be friction).
*/
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { X, Trash2 } from "lucide-react";
interface PoFloatingSelectionBarProps {
selectedCount: number;
onClear: () => void;
onRemove: () => void;
}
export const PoFloatingSelectionBar = memo(function PoFloatingSelectionBar({
selectedCount,
onClear,
onRemove,
}: PoFloatingSelectionBarProps) {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md whitespace-nowrap">
{selectedCount} selected
</div>
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 w-8 p-0"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="h-8 w-px bg-border" />
<Button
variant="destructive"
size="sm"
onClick={onRemove}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
Remove
</Button>
</div>
</div>
);
});
@@ -0,0 +1,251 @@
/**
* Review screen for the paste/upload flow.
*
* Shows the result of `resolveIdentifiers()`:
* - "matched" rows are summarized at the top (no action needed)
* - "ambiguous" rows show a radio-pick of candidates per row
* - "unmatched" rows are listed separately so the user knows what was
* dropped (no inline rescue — user can re-paste a corrected value)
*
* On confirm, the dialog returns the final pid+qty list (matched rows
* plus user-resolved ambiguous rows). Skipped ambiguous rows are
* dropped silently.
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CheckCircle2, AlertCircle, HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ResolveResult } from "./types";
interface ReviewMatchesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
result: ResolveResult | null;
onConfirm: (resolved: Array<{ pid: number; qty: number }>) => void;
}
export function ReviewMatchesDialog({
open,
onOpenChange,
result,
onConfirm,
}: ReviewMatchesDialogProps) {
// Per-ambiguous-row selected pid (keyed by row index in result.ambiguous)
const [picks, setPicks] = useState<Record<number, number | null>>({});
// Reset picks whenever a new result comes in
useEffect(() => {
setPicks({});
}, [result]);
if (!result) return null;
const matchedCount = result.matched.length;
const ambiguousCount = result.ambiguous.length;
const unmatchedCount = result.unmatched.length;
const handleConfirm = () => {
const out: Array<{ pid: number; qty: number }> = result.matched.map((m) => ({
pid: m.pid,
qty: m.qty,
}));
result.ambiguous.forEach((row, idx) => {
const picked = picks[idx];
if (picked) out.push({ pid: picked, qty: row.qty });
});
onConfirm(out);
};
// Skip review entirely if there's nothing ambiguous and nothing unmatched —
// the parent should ideally call onConfirm directly in that case, but we
// also short-circuit here as a safety net.
const reviewNeeded = ambiguousCount > 0 || unmatchedCount > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Review imported rows</DialogTitle>
<DialogDescription>
{reviewNeeded
? "Resolve ambiguous matches before adding products to your purchase order."
: "All rows matched successfully."}
</DialogDescription>
</DialogHeader>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-3 mt-2">
<div className="border rounded-md p-3 bg-emerald-50 border-emerald-200">
<div className="flex items-center gap-2 text-emerald-800">
<CheckCircle2 className="h-4 w-4" />
<span className="text-xs font-medium">Matched</span>
</div>
<div className="text-2xl font-semibold text-emerald-900 mt-1">
{matchedCount}
</div>
</div>
<div
className={cn(
"border rounded-md p-3",
ambiguousCount > 0
? "bg-amber-50 border-amber-200"
: "bg-muted border-border"
)}
>
<div
className={cn(
"flex items-center gap-2",
ambiguousCount > 0 ? "text-amber-800" : "text-muted-foreground"
)}
>
<HelpCircle className="h-4 w-4" />
<span className="text-xs font-medium">Ambiguous</span>
</div>
<div
className={cn(
"text-2xl font-semibold mt-1",
ambiguousCount > 0 ? "text-amber-900" : "text-muted-foreground"
)}
>
{ambiguousCount}
</div>
</div>
<div
className={cn(
"border rounded-md p-3",
unmatchedCount > 0
? "bg-destructive/10 border-destructive/30"
: "bg-muted border-border"
)}
>
<div
className={cn(
"flex items-center gap-2",
unmatchedCount > 0 ? "text-destructive" : "text-muted-foreground"
)}
>
<AlertCircle className="h-4 w-4" />
<span className="text-xs font-medium">Unmatched</span>
</div>
<div
className={cn(
"text-2xl font-semibold mt-1",
unmatchedCount > 0 ? "text-destructive" : "text-muted-foreground"
)}
>
{unmatchedCount}
</div>
</div>
</div>
<ScrollArea className="flex-1 mt-4 -mx-6 px-6">
{ambiguousCount > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Pick a product for each ambiguous row</h3>
{result.ambiguous.map((row, idx) => (
<div
key={`ambig-${idx}-${row.identifier}`}
className="border rounded-md p-3"
>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-xs text-muted-foreground">Pasted value</div>
<div className="font-mono text-sm">{row.identifier}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground">Qty</div>
<div className="text-sm font-semibold">{row.qty}</div>
</div>
</div>
<div className="space-y-1.5">
{row.candidates.map((c) => {
const isPicked = picks[idx] === c.pid;
return (
<button
key={c.pid}
type="button"
onClick={() =>
setPicks((prev) => ({
...prev,
[idx]: isPicked ? null : c.pid,
}))
}
className={cn(
"w-full text-left flex items-center gap-3 p-2 rounded-md border transition-colors",
isPicked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
)}
>
<div
className={cn(
"h-4 w-4 rounded-full border-2 flex-shrink-0",
isPicked
? "border-primary bg-primary"
: "border-muted-foreground/40"
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{c.title}
</div>
<div className="text-xs text-muted-foreground flex gap-3">
<span>PID {c.pid}</span>
{c.sku && <span>SKU {c.sku}</span>}
{c.barcode && <span>UPC {c.barcode}</span>}
{c.brand && <span>{c.brand}</span>}
</div>
</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
{unmatchedCount > 0 && (
<div className="mt-6">
<h3 className="text-sm font-semibold mb-2">
Unmatched (these will be skipped)
</h3>
<div className="border rounded-md divide-y">
{result.unmatched.map((row, idx) => (
<div
key={`unmatched-${idx}-${row.identifier}`}
className="flex items-center justify-between p-2 text-sm"
>
<span className="font-mono">{row.identifier}</span>
<span className="text-muted-foreground text-xs">qty {row.qty}</span>
</div>
))}
</div>
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm}>
Add{" "}
{matchedCount + Object.values(picks).filter((p) => p != null).length}{" "}
products
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,64 @@
/**
* Supplier picker for the Create PO page.
*
* Reuses the existing ComboboxField from product-editor (the canonical
* combobox in this app). Loads the supplier list once via react-query
* using the shared `["field-options"]` queryKey, so the cache is shared
* with any other page that reads the same field options and the
* suppliers list will already be warm if visited elsewhere first.
*/
import { useQuery } from "@tanstack/react-query";
import { ComboboxField } from "@/components/product-editor/ComboboxField";
import { Skeleton } from "@/components/ui/skeleton";
import type { FieldOption } from "@/components/product-editor/types";
interface FieldOptionsResponse {
suppliers?: FieldOption[];
}
export function SupplierSelector({
value,
onChange,
disabled,
}: {
/** The selected supplier ID as a string (matches FieldOption.value type). */
value: string | undefined;
onChange: (supplierId: string | undefined) => void;
disabled?: boolean;
}) {
const { data, isLoading, error } = useQuery({
queryKey: ["field-options"],
queryFn: async (): Promise<FieldOptionsResponse> => {
const res = await fetch("/api/import/field-options");
if (!res.ok) throw new Error("Failed to load suppliers");
return res.json();
},
staleTime: 30 * 60 * 1000, // 30 min — matches the server-side cache TTL
});
const options = data?.suppliers ?? [];
if (isLoading) {
return <Skeleton className="h-9 w-full" />;
}
if (error) {
return (
<div className="text-sm text-destructive">
Failed to load suppliers. Try refreshing the page.
</div>
);
}
return (
<ComboboxField
options={options}
value={value ?? ""}
onChange={(v) => onChange(v || undefined)}
placeholder="Select a supplier…"
searchPlaceholder="Search suppliers…"
disabled={disabled}
/>
);
}
@@ -0,0 +1,13 @@
/**
* The Notions supplier has a fixed ID in the legacy backend, hardcoded in
* several places (see inventory-server/src/routes/products.js, import.js).
* When this supplier is selected, the Create PO page swaps "Supplier #" for
* "Notions #" and surfaces the per-product Notions inventory column.
*/
export const NOTIONS_SUPPLIER_ID = 92;
/** Max pids the backend /api/products/batch endpoint accepts in one call. */
export const BATCH_LOOKUP_MAX_PIDS = 200;
/** Max search-result rows we'll attempt to resolve when bulk-adding by paste/upload. */
export const RESOLVE_LOOKUP_MAX_ROWS = 1000;
@@ -0,0 +1,353 @@
/**
* Spreadsheet parsing helpers for the Create PO page.
*
* The parsing helpers (parsePasted, detectDelimiter, autoMapHeaderNames,
* toIntOrUndefined) were originally adapted from a now-removed
* forecasting/QuickOrderBuilder.tsx prototype, scoped down to the
* 2-column (identifier, qty) PO use case.
*
* Two main entry points:
* - parsePastedTable(text) → headers + rows from a TSV/CSV string
* - parseWorkbookFirstSheet(buf) → headers + rows from an .xlsx/.xls/.csv file
*
* Plus a `autoDetectColumns(headers)` that picks the most likely identifier
* and qty column for the auto-detect path. Both PasteTab and UploadTab
* funnel through these helpers and end at the same RawIdentifierRow[]
* shape consumed by `resolveIdentifiers()`.
*/
import * as XLSX from "xlsx";
import type { RawIdentifierRow } from "./types";
// --- Header synonym lists -----------------------------------------------------
const IDENTIFIER_HEADER_SYNONYMS = [
"upc",
"barcode",
"bar code",
"ean",
"jan",
"sku",
"item",
"item#",
"item number",
"item no",
"item_no",
"supplier #",
"supplier no",
"supplier_no",
"supplier number",
"notions #",
"notions no",
"notions_no",
"notions number",
"product code",
"code",
"id",
"pid",
];
const QTY_HEADER_SYNONYMS = [
"qty",
"quantity",
"order qty",
"order quantity",
"amount",
"count",
"units",
];
function normalizeHeader(h: string): string {
return (h || "").trim().toLowerCase();
}
// --- Delimiter detection ------------------------------------------------------
function detectDelimiter(text: string): string {
// Heuristic: prefer tab, then comma, then semicolon. Sample first 5 lines.
const lines = text.split(/\r?\n/).slice(0, 5);
const counts: Record<string, number> = { "\t": 0, ",": 0, ";": 0 };
for (const line of lines) {
counts["\t"] += (line.match(/\t/g) || []).length;
counts[","] += (line.match(/,/g) || []).length;
counts[";"] += (line.match(/;/g) || []).length;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
}
// --- Pasted text parser -------------------------------------------------------
export interface ParsedTable {
headers: string[];
rows: string[][];
/** True when the first row appears to be a header (contains non-numeric strings). */
hasHeader: boolean;
}
/**
* Parse a pasted TSV/CSV string. Detects delimiter, splits cleanly, pads
* short rows to header width, and trims whitespace per cell. All cells are
* preserved as STRINGS — we never coerce to numbers here, because UPCs with
* leading zeros must survive parsing intact.
*
* Header detection: if the first row's cells are mostly non-numeric strings
* we treat it as a header and pull it out; otherwise we synthesize generic
* "Column 1", "Column 2" headers and treat all rows as data. This makes the
* paste flow forgiving: users can paste data with or without a header row.
*/
export function parsePastedTable(text: string): ParsedTable {
if (!text || !text.trim()) return { headers: [], rows: [], hasHeader: false };
const delimiter = detectDelimiter(text);
const lines = text
.split(/\r?\n/)
.map((l) => l.trimEnd()) // trim trailing whitespace but keep tabs as separators
.filter((l) => l.length > 0);
if (lines.length === 0) return { headers: [], rows: [], hasHeader: false };
const split = (line: string) => line.split(delimiter).map((s) => s.trim());
const firstRow = split(lines[0]);
const restRows = lines.slice(1).map(split);
// Header heuristic: at least one cell in the first row is non-numeric and
// not empty, AND there's more than one row of data (otherwise treat the
// single row as data).
const looksLikeHeader =
firstRow.length >= 2 &&
firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c));
if (looksLikeHeader && restRows.length > 0) {
const headers = firstRow.map((h) => h || "");
const rows = restRows.map((r) => {
while (r.length < headers.length) r.push("");
return r;
});
return { headers, rows, hasHeader: true };
}
// No header — synthesize column names and treat every line as data
const allRows = [firstRow, ...restRows];
const maxCols = Math.max(...allRows.map((r) => r.length));
const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
const rows = allRows.map((r) => {
while (r.length < maxCols) r.push("");
return r;
});
return { headers, rows, hasHeader: false };
}
// --- Workbook (xlsx/xls/csv) parser -------------------------------------------
/**
* Parse the first sheet of a workbook (read from an ArrayBuffer) into the
* same {headers, rows} shape as parsePastedTable. All cell values are
* coerced to STRINGS to keep UPCs intact (raw: false). Empty cells become
* empty strings.
*/
export function parseWorkbookFirstSheet(buffer: ArrayBuffer): ParsedTable {
// raw:false → use formatted text values (preserves leading zeros if cell
// is text-formatted in the source spreadsheet); cellDates:true → date
// cells become Date objects which we then stringify.
const wb = XLSX.read(buffer, {
type: "array",
cellDates: true,
raw: false,
codepage: 65001,
WTF: false,
});
const sheetName = wb.SheetNames[0];
if (!sheetName) return { headers: [], rows: [], hasHeader: false };
const sheet = wb.Sheets[sheetName];
// header:1 returns rows as arrays; defval ensures missing cells become ""
const aoa = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
if (!aoa.length) return { headers: [], rows: [], hasHeader: false };
const stringify = (v: unknown): string => {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v).trim();
};
const allRows = aoa.map((row) => row.map(stringify));
const firstRow = allRows[0];
const restRows = allRows.slice(1).filter((r) => r.some((c) => c.length > 0));
const looksLikeHeader =
firstRow.length >= 2 &&
firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c));
if (looksLikeHeader && restRows.length > 0) {
const headers = firstRow.map((h) => h || "");
const rows = restRows.map((r) => {
while (r.length < headers.length) r.push("");
return r;
});
return { headers, rows, hasHeader: true };
}
const all = restRows.length > 0 ? [firstRow, ...restRows] : [firstRow];
const maxCols = Math.max(...all.map((r) => r.length));
const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
const rows = all.map((r) => {
while (r.length < maxCols) r.push("");
return r;
});
return { headers, rows, hasHeader: false };
}
// --- Auto-detect column roles -------------------------------------------------
export type ColumnRole = "identifier" | "qty" | "ignore";
export interface DetectedMapping {
/** Index of the column to treat as the product identifier (UPC/SKU/etc.) */
identifierIdx: number;
/** Index of the column to treat as the quantity. */
qtyIdx: number;
/** Per-column role assignment, indexed by header position. */
roles: ColumnRole[];
}
/**
* Best-effort guess of which column is the identifier and which is the qty.
*
* Strategy:
* 1. If headers contain known synonyms, use them.
* 2. Otherwise inspect the first few data rows: a column where most cells
* look numeric AND values are small (<10000) is the qty; the other is
* the identifier.
* 3. Fall back to: column 0 = identifier, column 1 = qty.
*/
export function autoDetectColumns(
headers: string[],
rows: string[][]
): DetectedMapping {
const norm = headers.map(normalizeHeader);
const findFirst = (syns: string[]): number => {
for (const s of syns) {
const idx = norm.findIndex((h) => h === s || (h && h.includes(s)));
if (idx >= 0) return idx;
}
return -1;
};
let identifierIdx = findFirst(IDENTIFIER_HEADER_SYNONYMS);
let qtyIdx = findFirst(QTY_HEADER_SYNONYMS);
// If headers didn't tell us, look at the data
if (identifierIdx < 0 || qtyIdx < 0) {
const sample = rows.slice(0, 10);
const numCols = headers.length;
const numericLikeRatios: number[] = [];
const avgValues: number[] = [];
for (let c = 0; c < numCols; c++) {
let numericCount = 0;
let sum = 0;
let parsedCount = 0;
for (const row of sample) {
const cell = (row[c] || "").trim();
if (!cell) continue;
// Treat as "numeric and small" if it parses to an int <= 10000
if (/^\d+$/.test(cell)) {
const n = Number(cell);
if (Number.isFinite(n)) {
numericCount += 1;
if (n <= 10000) {
sum += n;
parsedCount += 1;
}
}
}
}
numericLikeRatios.push(sample.length > 0 ? numericCount / sample.length : 0);
avgValues.push(parsedCount > 0 ? sum / parsedCount : Infinity);
}
if (qtyIdx < 0) {
// Best qty candidate: smallest avg value, prefer columns where >50% rows are numeric
let bestIdx = -1;
let bestAvg = Infinity;
for (let c = 0; c < numCols; c++) {
if (numericLikeRatios[c] >= 0.5 && avgValues[c] < bestAvg) {
bestAvg = avgValues[c];
bestIdx = c;
}
}
qtyIdx = bestIdx;
}
if (identifierIdx < 0) {
// Identifier = first column that isn't qty
for (let c = 0; c < numCols; c++) {
if (c !== qtyIdx) {
identifierIdx = c;
break;
}
}
}
}
// Final fallback: identifier must point somewhere; qty is optional and
// stays unassigned (-1) when there's no other column to use. applyMapping
// defaults each row's qty to 1 when qtyIdx is -1 — so a single-column
// paste of UPCs now imports as "one of each" instead of trying to parse
// the UPC itself as a qty (which previously produced billion-unit rows).
if (identifierIdx < 0) identifierIdx = 0;
if (qtyIdx < 0) {
qtyIdx = headers.length > 1 && identifierIdx !== 1 ? 1 : -1;
}
const roles: ColumnRole[] = headers.map((_, i) => {
if (i === identifierIdx) return "identifier";
if (i === qtyIdx) return "qty";
return "ignore";
});
return { identifierIdx, qtyIdx, roles };
}
// --- Convert mapped rows → RawIdentifierRow[] --------------------------------
/**
* Apply a column mapping to the parsed table to produce raw identifier
* rows. Rows with an empty identifier are dropped.
*
* When `qtyIdx` is -1 (no qty column assigned), each row gets a default
* qty of 1 — this makes single-column pastes like "paste a list of UPCs,
* one per line" work without forcing users to add a qty column. When
* `qtyIdx` is set but a row has an invalid/non-positive qty value, that
* row is dropped.
*/
export function applyMapping(
table: ParsedTable,
mapping: DetectedMapping
): RawIdentifierRow[] {
const out: RawIdentifierRow[] = [];
const hasQtyColumn = mapping.qtyIdx >= 0;
for (const row of table.rows) {
const identifier = (row[mapping.identifierIdx] || "").trim();
if (!identifier) continue;
let qty = 1;
if (hasQtyColumn) {
const qtyStr = (row[mapping.qtyIdx] || "").trim();
// Strip non-numeric chars from qty (commas, spaces, decimals)
const cleaned = qtyStr.replace(/[^0-9.-]/g, "");
const parsed = Math.round(Number(cleaned));
if (!Number.isFinite(parsed) || parsed <= 0) continue;
qty = parsed;
}
out.push({ identifier, qty });
}
return out;
}
@@ -0,0 +1,137 @@
/**
* Identifier-resolution pipeline for the Create PO page.
*
* Takes a list of `RawIdentifierRow` (from paste/upload parsing) and looks
* them up in ONE backend call via `POST /api/products/resolve-identifiers`.
* The endpoint does the SQL work: a single query with indexed equality
* matches across sku, barcode, vendor_reference, notions_reference, and pid,
* returning candidates grouped per input identifier in input order.
*
* Each row resolves to one of:
* - matched → exactly one candidate, ready to add
* - ambiguous → multiple candidates; user must pick one in ReviewMatchesDialog
* - unmatched → zero candidates; surfaced so the user knows it was dropped
*
* Also exports `fetchBatchProducts`, which hydrates a list of pids into
* full PoLineItem rows via `GET /api/products/batch`.
*/
import axios from "axios";
import type {
RawIdentifierRow,
ResolveResult,
SearchCandidate,
PoLineItem,
} from "./types";
import { BATCH_LOOKUP_MAX_PIDS } from "./constants";
interface ResolveApiCandidate {
pid: number | string;
title: string;
sku: string | null;
barcode: string | null;
vendor_reference: string | null;
notions_reference: string | null;
brand: string | null;
}
interface ResolveApiResponse {
results: Array<{
identifier: string;
candidates: ResolveApiCandidate[];
}>;
}
/**
* Coerces a candidate from the API into the frontend's SearchCandidate
* shape. pid may come back as a string if the backend ever regresses to
* un-normalized BIGINT — Number() is defensive.
*/
function toSearchCandidate(c: ResolveApiCandidate): SearchCandidate {
return {
pid: Number(c.pid),
title: c.title,
sku: c.sku,
barcode: c.barcode,
brand: c.brand,
};
}
export async function resolveIdentifiers(
rows: RawIdentifierRow[]
): Promise<ResolveResult> {
if (rows.length === 0) {
return { matched: [], ambiguous: [], unmatched: [] };
}
const identifiers = rows.map((r) => r.identifier);
const res = await axios.post<ResolveApiResponse>(
"/api/products/resolve-identifiers",
{ identifiers }
);
const apiResults = res.data?.results ?? [];
const result: ResolveResult = { matched: [], ambiguous: [], unmatched: [] };
// The backend preserves input order and length, so we can zip by index.
// If the response is shorter than the input (backend truncation or error),
// missing rows are treated as unmatched so the user still sees them.
rows.forEach((row, i) => {
const apiRow = apiResults[i];
const candidates = apiRow?.candidates ?? [];
if (candidates.length === 0) {
result.unmatched.push({ identifier: row.identifier, qty: row.qty });
} else if (candidates.length === 1) {
result.matched.push({
pid: Number(candidates[0].pid),
qty: row.qty,
identifier: row.identifier,
});
} else {
result.ambiguous.push({
identifier: row.identifier,
qty: row.qty,
candidates: candidates.map(toSearchCandidate),
});
}
});
return result;
}
/**
* Fetch full product display data for a list of pids. Chunks the request
* to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the
* caller passes hundreds of pids.
*
* Returns a flat array of PoLineItem with `qty` set to the value passed in
* the `qtyByPid` map (default 1).
*/
export async function fetchBatchProducts(
pids: number[],
qtyByPid: Map<number, number> = new Map()
): Promise<PoLineItem[]> {
if (pids.length === 0) return [];
const uniqPids = Array.from(new Set(pids));
const out: PoLineItem[] = [];
for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) {
const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS);
const res = await axios.get<Omit<PoLineItem, "qty">[]>(
"/api/products/batch",
{ params: { pids: chunk.join(",") } }
);
for (const row of res.data ?? []) {
// Defensive Number() coercion: the backend already returns pid as a
// Number, but if it ever regresses to a BIGINT string the numeric
// Set/Map lookups downstream would silently fail instead of crashing.
const pid = Number(row.pid);
out.push({ ...row, pid, qty: qtyByPid.get(pid) ?? 1 });
}
}
return out;
}
@@ -0,0 +1,76 @@
/**
* Display shape for a single line item on the Create PO page. This is the
* exact response shape returned by GET /api/products/batch (snake_case).
*
* `qty` and `moqOverride` are local-only client state appended to the API
* shape; they are NOT returned by the backend.
*/
export interface PoLineItem {
pid: number;
title: string;
image_url: string | null;
barcode: string | null;
vendor_reference: string | null;
notions_reference: string | null;
notions_inv_count: number | null;
current_stock: number | null;
baskets: number | null;
on_order_qty: number | null;
total_sold: number | null;
current_cost_price: number | null;
date_last_sold: string | null;
date_first_received: string | null;
/** From the products table; may be null/0/inconsistent. The user can override locally. */
moq: number | null;
// --- Local-only client state ---
/** User-entered order quantity. */
qty: number;
/**
* Local override of MOQ for this row. Undefined means "use the canonical
* `moq` value above". This is never sent to the backend; it only affects
* the qty-validation highlight on the row.
*/
moqOverride?: number;
}
/** Wire shape sent to the legacy PHP endpoint. */
export interface PoSubmitItem {
pid: number;
qty: number;
}
/**
* Intermediate shape produced by paste/upload parsing — before identifier
* resolution against the products table.
*/
export interface RawIdentifierRow {
identifier: string;
qty: number;
}
/** A single search result candidate from /api/products/search. */
export interface SearchCandidate {
pid: number;
title: string;
sku: string | null;
barcode: string | null;
brand: string | null;
}
/**
* The result of attempting to resolve a single raw row to a product:
* - matched → exactly one product found
* - ambiguous → multiple candidates; user must pick one
* - unmatched → zero candidates; user can manually fix or drop
*/
export type ResolveOutcome =
| { kind: "matched"; pid: number; qty: number; identifier: string }
| { kind: "ambiguous"; identifier: string; qty: number; candidates: SearchCandidate[] }
| { kind: "unmatched"; identifier: string; qty: number };
export interface ResolveResult {
matched: Array<{ pid: number; qty: number; identifier: string }>;
ambiguous: Array<{ identifier: string; qty: number; candidates: SearchCandidate[] }>;
unmatched: Array<{ identifier: string; qty: number }>;
}
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator"; import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { formatNumber } from "@/utils/productUtils"; import { formatNumber } from "@/utils/productUtils";
import { PlusIcon, X } from "lucide-react"; import { PlusIcon, X } from "lucide-react";
@@ -43,6 +44,8 @@ interface ConfigPanelProps {
onFixedCostChange: (value: number) => void; onFixedCostChange: (value: number) => void;
cogsCalculationMode: CogsCalculationMode; cogsCalculationMode: CogsCalculationMode;
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void; onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
applyHistoricalProductPromo: boolean;
onApplyHistoricalProductPromoChange: (value: boolean) => void;
pointsPerDollar: number; pointsPerDollar: number;
redemptionRate: number; redemptionRate: number;
onRedemptionRateChange: (value: number) => void; onRedemptionRateChange: (value: number) => void;
@@ -58,6 +61,9 @@ interface ConfigPanelProps {
} }
function parseNumber(value: string, fallback = 0) { function parseNumber(value: string, fallback = 0) {
if (value.trim() === '') {
return fallback;
}
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
@@ -113,6 +119,8 @@ export function ConfigPanel({
onFixedCostChange, onFixedCostChange,
cogsCalculationMode, cogsCalculationMode,
onCogsCalculationModeChange, onCogsCalculationModeChange,
applyHistoricalProductPromo,
onApplyHistoricalProductPromoChange,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,
onRedemptionRateChange, onRedemptionRateChange,
@@ -480,6 +488,23 @@ export function ConfigPanel({
</div> </div>
</div> </div>
)} )}
<div className="flex items-start justify-between gap-2 pt-1">
<div className="flex flex-col">
<Label className={labelClass}>Replay historical discount</Label>
<span className="text-[0.65rem] text-muted-foreground leading-snug">
Only when promo type is "No additional promo"
</span>
</div>
<Switch
checked={applyHistoricalProductPromo}
onCheckedChange={(checked) => {
onConfigInputChange();
onApplyHistoricalProductPromoChange(checked);
setTimeout(() => onRunSimulation(), 0);
}}
disabled={productPromo.type !== "none"}
/>
</div>
</div> </div>
</section> </section>
@@ -826,11 +851,11 @@ export function ConfigPanel({
<Input <Input
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="0.01"
value={Math.round(redemptionRate * 100)} value={Number((redemptionRate * 100).toFixed(2))}
onChange={(event) => { onChange={(event) => {
onConfigInputChange(); onConfigInputChange();
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100); onRedemptionRateChange(parseNumber(event.target.value, redemptionRate * 100) / 100);
}} }}
onBlur={handleFieldBlur} onBlur={handleFieldBlur}
/> />
@@ -67,12 +67,11 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
return null; return null;
} }
// Use the same format as the table - show top range value
const labels = buckets.map((bucket) => { const labels = buckets.map((bucket) => {
if (bucket.max == null) { if (bucket.max == null) {
return `$${bucket.min.toLocaleString()}+`; return `$${bucket.min.toLocaleString()}+`;
} }
return `$${bucket.max.toLocaleString()}`; return `$${bucket.min.toLocaleString()}$${bucket.max.toLocaleString()}`;
}); });
const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2))); const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2)));
@@ -109,13 +108,28 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
pointBorderColor: pointColors, pointBorderColor: pointColors,
pointRadius: 6, pointRadius: 6,
pointHoverRadius: 8, pointHoverRadius: 8,
tension: 0.3, tension: 0,
fill: false, fill: false,
}, },
], ],
}; };
}, [buckets]); }, [buckets]);
const yAxisBounds = useMemo(() => {
const populated = buckets.filter((bucket) => bucket.orderCount > 0);
if (populated.length === 0) {
return { min: 0, max: 50 };
}
const values = populated.map((bucket) => bucket.profitPercent * 100);
const rawMin = Math.min(...values, 0);
const rawMax = Math.max(...values, 0);
const span = Math.max(rawMax - rawMin, 5);
const pad = Math.max(span * 0.1, 1);
const min = Math.floor((rawMin - pad) / 5) * 5;
const max = Math.ceil((rawMax + pad) / 5) * 5;
return { min, max: Math.max(max, min + 5) };
}, [buckets]);
const options = useMemo(() => ({ const options = useMemo(() => ({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -157,8 +171,8 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
position: 'left' as const, position: 'left' as const,
min: 0, min: yAxisBounds.min,
max: 50, max: yAxisBounds.max,
ticks: { ticks: {
stepSize: 5, stepSize: 5,
callback: (value: number | string) => `${Number(value).toFixed(0)}`, callback: (value: number | string) => `${Number(value).toFixed(0)}`,
@@ -177,7 +191,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
}, },
}, },
}, },
}), [buckets]); }), [buckets, yAxisBounds]);
if (isLoading && !chartData) { if (isLoading && !chartData) {
return ( return (
@@ -145,10 +145,10 @@ const rowLabels: RowConfig[] = [
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => { const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
if (bucket.max == null) { if (bucket.max == null) {
return `${formatCurrency(bucket.min)}+`; return `${formatCurrency(bucket.min, 0)}+`;
} }
return formatCurrency(bucket.max); return `${formatCurrency(bucket.min, 0)}${formatCurrency(bucket.max, 0)}`;
}; };
export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
@@ -173,7 +173,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<colgroup> <colgroup>
<col style={{ width: '156px' }} /> <col style={{ width: '156px' }} />
{buckets.map((bucket) => ( {buckets.map((bucket) => (
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} /> <col key={bucket.key} style={{ minWidth: '95px', width: '110px' }} />
))} ))}
</colgroup> </colgroup>
<TableHeader> <TableHeader>
@@ -57,6 +57,10 @@ interface SummaryCardProps {
onClearBaseline?: () => void; onClearBaseline?: () => void;
} }
function rangesMatch(a: DiscountSimulationResponse, b: DiscountSimulationResponse): boolean {
return a.dateRange.start === b.dateRange.start && a.dateRange.end === b.dateRange.end;
}
function calculateAnnualizedProfitDiff( function calculateAnnualizedProfitDiff(
current: DiscountSimulationResponse, current: DiscountSimulationResponse,
baseline: DiscountSimulationResponse baseline: DiscountSimulationResponse
@@ -65,6 +69,9 @@ function calculateAnnualizedProfitDiff(
const baselineTotals = baseline.totals; const baselineTotals = baseline.totals;
if (!currentTotals || !baselineTotals) return null; if (!currentTotals || !baselineTotals) return null;
if (!Number.isFinite(currentTotals.weightedProfitAmount) || !Number.isFinite(baselineTotals.weightedProfitAmount)) {
return null;
}
// Calculate days in the current simulation period // Calculate days in the current simulation period
const startDate = new Date(current.dateRange.start); const startDate = new Date(current.dateRange.start);
@@ -118,7 +125,9 @@ export function SummaryCard({
} }
const totals = result?.totals; const totals = result?.totals;
const weightedProfitAmount = totals ? formatCurrency(totals.weightedProfitAmount) : '—'; const weightedProfitAmount = totals && Number.isFinite(totals.weightedProfitAmount)
? formatCurrency(totals.weightedProfitAmount)
: '—';
const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—'; const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—';
// Get color for profit percentage // Get color for profit percentage
@@ -130,8 +139,9 @@ export function SummaryCard({
: "destructive" : "destructive"
: "secondary"; : "secondary";
// Calculate annualized profit difference if baseline exists const baselineScopeMatches = !!(result && baselineResult && rangesMatch(result, baselineResult));
const annualizedDiff = result && baselineResult // Calculate annualized profit difference if baseline exists and scope matches
const annualizedDiff = result && baselineResult && baselineScopeMatches
? calculateAnnualizedProfitDiff(result, baselineResult) ? calculateAnnualizedProfitDiff(result, baselineResult)
: null; : null;
const hasBaseline = !!baselineResult; const hasBaseline = !!baselineResult;
@@ -194,6 +204,27 @@ export function SummaryCard({
</> </>
)} )}
{hasBaseline && !baselineScopeMatches && (
<>
<Separator orientation="vertical" className="h-12" />
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Baseline</p>
<span className="inline-block px-3 py-1.5 rounded bg-secondary text-amber-700 text-sm">
Date range changed clear or restore to compare
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={onClearBaseline}
>
<X className="h-3 w-3 mr-1" />
Clear
</Button>
</>
)}
{/* Save as baseline button */} {/* Save as baseline button */}
{result && !hasBaseline && ( {result && !hasBaseline && (
<> <>
@@ -1,956 +0,0 @@
import { useEffect, useMemo, useRef, useState, useTransition, useCallback, memo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code } from "@/components/ui/code";
import * as XLSX from "xlsx";
import { toast } from "sonner";
import { X as XIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
export interface CategorySummary {
category: string;
categoryPath: string;
avgTotalSold: number;
minSold: number;
maxSold: number;
}
type ParsedRow = {
product: string;
sku?: string;
categoryHint?: string;
moq?: number;
upc?: string;
};
type OrderRow = ParsedRow & {
matchedCategoryPath?: string;
matchedCategoryName?: string;
baseSuggestion?: number; // from category avg
finalQty: number; // adjusted for MOQ
};
type HeaderMap = {
// Stores generated column ids like "col-0" instead of raw header text
product?: string;
sku?: string;
categoryHint?: string;
moq?: string;
upc?: string;
};
const PRODUCT_HEADER_SYNONYMS = [
"product",
"name",
"title",
"description",
"item",
"item name",
"sku description",
"product name",
];
const SKU_HEADER_SYNONYMS = [
"sku",
"item#",
"item number",
"supplier #",
"supplier no",
"supplier_no",
"product code",
];
const CATEGORY_HEADER_SYNONYMS = [
"category",
"categories",
"line",
"collection",
"type",
];
const MOQ_HEADER_SYNONYMS = [
"moq",
"min qty",
"min. order qty",
"min order qty",
"qty per unit",
"unit qty",
"inner pack",
"case pack",
"pack",
];
const UPC_HEADER_SYNONYMS = [
"upc",
"barcode",
"bar code",
"ean",
"jan",
"upc code",
];
function normalizeHeader(h: string) {
return h.trim().toLowerCase();
}
function autoMapHeaderNames(headers: string[]): { product?: string; sku?: string; categoryHint?: string; moq?: string; upc?: string } {
const norm = headers.map((h) => normalizeHeader(h));
const findFirst = (syns: string[]) => {
for (const s of syns) {
const idx = norm.findIndex((h) => h === s || h.includes(s));
if (idx >= 0) return headers[idx];
}
return undefined;
};
return {
product: findFirst(PRODUCT_HEADER_SYNONYMS) || headers[0],
sku: findFirst(SKU_HEADER_SYNONYMS),
categoryHint: findFirst(CATEGORY_HEADER_SYNONYMS),
moq: findFirst(MOQ_HEADER_SYNONYMS),
upc: findFirst(UPC_HEADER_SYNONYMS),
};
}
function detectDelimiter(text: string): string {
// Very simple heuristic: prefer tab, then comma, then semicolon
const lines = text.split(/\r?\n/).slice(0, 5);
const counts = { "\t": 0, ",": 0, ";": 0 } as Record<string, number>;
for (const line of lines) {
counts["\t"] += (line.match(/\t/g) || []).length;
counts[","] += (line.match(/,/g) || []).length;
counts[";"] += (line.match(/;/g) || []).length;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
}
function parsePasted(text: string): { headers: string[]; rows: string[][] } {
const delimiter = detectDelimiter(text);
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(delimiter).map((s) => s.trim());
const rows = lines.slice(1).map((l) => {
const parts = l.split(delimiter).map((s) => s.trim());
// Preserve empty trailing columns by padding to headers length
while (parts.length < headers.length) parts.push("");
return parts;
});
return { headers, rows };
}
function toIntOrUndefined(v: any): number | undefined {
if (v === null || v === undefined) return undefined;
const n = Number(String(v).replace(/[^0-9.-]/g, ""));
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
}
function scoreCategoryMatch(catText: string, name: string, hint?: string): number {
const base = catText.toLowerCase();
const tokens = (name || "")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length >= 3);
let score = 0;
for (const t of tokens) {
if (base.includes(t)) score += 2;
}
if (hint) {
const h = hint.toLowerCase();
if (base.includes(h)) score += 5;
}
return score;
}
function suggestFromCategory(avgTotalSold?: number, scalePct: number = 100): number {
const scaled = (avgTotalSold || 0) * (isFinite(scalePct) ? scalePct : 100) / 100;
const base = Math.max(1, Math.round(scaled));
return base;
}
function applyMOQ(qty: number, moq?: number): number {
if (!moq || moq <= 1) return Math.max(0, qty);
if (qty <= 0) return 0;
const mult = Math.ceil(qty / moq);
return mult * moq;
}
export function QuickOrderBuilder({
categories,
brand,
}: {
categories: CategorySummary[];
brand?: string;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [pasted, setPasted] = useState("");
const [headers, setHeaders] = useState<string[]>([]);
const [rawRows, setRawRows] = useState<string[][]>([]);
const [headerMap, setHeaderMap] = useState<HeaderMap>({});
const [orderRows, setOrderRows] = useState<OrderRow[]>([]);
const [showJson, setShowJson] = useState(false);
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined);
const [scalePct, setScalePct] = useState<number>(100);
const [scaleInput, setScaleInput] = useState<string>("100");
const [showExcludedOnly, setShowExcludedOnly] = useState<boolean>(false);
const [parsed, setParsed] = useState<boolean>(false);
const [showMapping, setShowMapping] = useState<boolean>(false);
const [, startTransition] = useTransition();
const [initialCategories, setInitialCategories] = useState<CategorySummary[] | null>(null);
// Local storage draft persistence
const DRAFT_KEY = "quickOrderBuilderDraft";
const restoringRef = useRef(false);
// Load suppliers from existing endpoint used elsewhere in the app
const { data: fieldOptions } = useQuery({
queryKey: ["field-options"],
queryFn: async () => {
const res = await fetch("/api/import/field-options");
if (!res.ok) throw new Error("Failed to load field options");
return res.json();
},
});
const supplierOptions: { label: string; value: string }[] = fieldOptions?.suppliers || [];
// Default supplier to the brand name if an exact label match exists
useEffect(() => {
if (!supplierOptions?.length) return;
if (selectedSupplierId) return;
if (brand) {
const match = supplierOptions.find((s) => s.label?.toLowerCase?.() === brand.toLowerCase());
if (match) setSelectedSupplierId(String(match.value));
}
}, [supplierOptions, brand, selectedSupplierId]);
// Restore draft on mount
useEffect(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
restoringRef.current = true;
setPasted(draft.pasted ?? "");
setHeaders(Array.isArray(draft.headers) ? draft.headers : []);
setRawRows(Array.isArray(draft.rawRows) ? draft.rawRows : []);
setHeaderMap(draft.headerMap ?? {});
setOrderRows(Array.isArray(draft.orderRows) ? draft.orderRows : []);
setSelectedSupplierId(draft.selectedSupplierId ?? undefined);
const restoredScale = typeof draft.scalePct === 'number' ? draft.scalePct : 100;
setScalePct(restoredScale);
setScaleInput(String(restoredScale));
setParsed(Array.isArray(draft.headers) && draft.headers.length > 0);
setShowMapping(!(Array.isArray(draft.orderRows) && draft.orderRows.length > 0));
if (Array.isArray(draft.categoriesSnapshot)) {
setInitialCategories(draft.categoriesSnapshot);
}
// brand is passed via props; we don't override it here
} catch (e) {
console.warn("Failed to restore draft", e);
} finally {
// Defer toggling off to next tick to allow state batching
setTimeout(() => { restoringRef.current = false; }, 0);
}
}, []);
// Save draft on changes
useEffect(() => {
if (restoringRef.current) return;
const draft = {
pasted,
headers,
rawRows,
headerMap,
orderRows,
selectedSupplierId,
scalePct,
brand,
categoriesSnapshot: categories,
};
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch (e) {
// ignore storage quota errors silently
}
}, [pasted, headers, rawRows, headerMap, orderRows, selectedSupplierId, scalePct, brand]);
// Debounce scale input -> numeric scalePct
useEffect(() => {
const handle = setTimeout(() => {
const v = Math.max(1, Math.min(500, Math.round(Number(scaleInput) || 0)));
setScalePct(v);
}, 500);
return () => clearTimeout(handle);
}, [scaleInput]);
const effectiveCategories = (categories && categories.length > 0) ? categories : (initialCategories || []);
const categoryOptions = useMemo(() => {
const arr = (effectiveCategories || [])
.map((c) => ({
value: c.categoryPath || c.category,
label: c.categoryPath ? `${c.category}${c.categoryPath}` : c.category,
}))
.filter((o) => !!o.value && String(o.value).trim() !== "");
// dedupe by value to avoid duplicate Select values
const dedup = new Map<string, string>();
for (const o of arr) {
if (!dedup.has(o.value)) dedup.set(o.value, o.label);
}
return Array.from(dedup.entries()).map(([value, label]) => ({ value, label }));
}, [effectiveCategories]);
const categoryByKey = useMemo(() => {
const map = new Map<string, CategorySummary>();
for (const c of effectiveCategories || []) {
map.set(c.categoryPath || c.category, c);
}
return map;
}, [effectiveCategories]);
// Build header option list with generated ids so values are never empty and keys are unique
const headerOptions = useMemo(
() => headers.map((h, i) => ({ id: `col-${i}`, index: i, label: h && h.trim() ? h : `Column ${i + 1}` })),
[headers]
);
const idToIndex = useMemo(() => new Map(headerOptions.map((o) => [o.id, o.index])), [headerOptions]);
function headerNameToId(name?: string): string | undefined {
if (!name) return undefined;
const idx = headers.findIndex((h) => h === name);
return idx >= 0 ? `col-${idx}` : undefined;
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
const ext = f.name.split(".").pop()?.toLowerCase();
reader.onload = () => {
try {
let wb: XLSX.WorkBook | null = null;
if (ext === "xlsx" || ext === "xls") {
const data = new Uint8Array(reader.result as ArrayBuffer);
wb = XLSX.read(data, { type: "array" });
} else if (ext === "csv" || ext === "tsv") {
const text = reader.result as string;
wb = XLSX.read(text, { type: "string" });
} else {
// Try naive string read
const text = reader.result as string;
wb = XLSX.read(text, { type: "string" });
}
if (!wb) throw new Error("Unable to parse file");
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
if (!rows.length) throw new Error("Empty file");
const hdrs = (rows[0] as string[]).map((h) => String(h || "").trim());
const body = rows.slice(1).map((r) => (r as any[]).map((v) => String(v ?? "").trim()));
// Build mapping based on detected names -> ids
const mappedNames = autoMapHeaderNames(hdrs);
const mappedIds: HeaderMap = {
product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined),
sku: headerNameToId(mappedNames.sku),
categoryHint: headerNameToId(mappedNames.categoryHint),
moq: headerNameToId(mappedNames.moq),
upc: headerNameToId(mappedNames.upc),
};
setHeaders(hdrs);
setRawRows(body);
setHeaderMap(mappedIds);
setPasted("");
setParsed(true);
setShowMapping(true);
toast.success("File parsed");
} catch (err) {
console.error(err);
toast.error("Could not parse file");
}
};
if (ext === "xlsx" || ext === "xls") {
reader.readAsArrayBuffer(f);
} else {
reader.readAsText(f);
}
}
function handlePasteParse() {
try {
const { headers: hdrs, rows } = parsePasted(pasted);
if (!hdrs.length || !rows.length) {
toast.error("No data detected");
return;
}
const mappedNames = autoMapHeaderNames(hdrs);
const mappedIds: HeaderMap = {
product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined),
sku: headerNameToId(mappedNames.sku),
categoryHint: headerNameToId(mappedNames.categoryHint),
moq: headerNameToId(mappedNames.moq),
upc: headerNameToId(mappedNames.upc),
};
setHeaders(hdrs);
setRawRows(rows);
setHeaderMap(mappedIds);
setParsed(true);
setShowMapping(true);
toast.success("Pasted data parsed");
} catch (e) {
console.error(e);
toast.error("Paste parse failed");
}
}
function buildParsedRows(): ParsedRow[] {
if (!headers.length || !rawRows.length) return [];
const idx = (id?: string) => (id ? idToIndex.get(id) ?? -1 : -1);
const iProduct = idx(headerMap.product);
const iSku = idx(headerMap.sku);
const iCat = idx(headerMap.categoryHint);
const iMoq = idx(headerMap.moq);
const iUpc = idx(headerMap.upc);
const out: ParsedRow[] = [];
for (const r of rawRows) {
const product = String(iProduct >= 0 ? r[iProduct] ?? "" : "").trim();
const upc = iUpc >= 0 ? String(r[iUpc] ?? "") : undefined;
if (!product && !(upc && upc.trim())) continue;
const sku = iSku >= 0 ? String(r[iSku] ?? "") : undefined;
const categoryHint = iCat >= 0 ? String(r[iCat] ?? "") : undefined;
const moq = iMoq >= 0 ? toIntOrUndefined(r[iMoq]) : undefined;
out.push({ product, sku, categoryHint, moq, upc });
}
return out;
}
function matchCategory(row: ParsedRow): { key?: string; name?: string } {
if (!categories?.length) return {};
let bestKey: string | undefined;
let bestName: string | undefined;
let bestScore = -1;
for (const c of categories) {
const key = c.categoryPath || c.category;
const text = `${c.category} ${c.categoryPath || ""}`;
const s = scoreCategoryMatch(text, row.product, row.categoryHint);
if (s > bestScore) {
bestScore = s;
bestKey = key;
bestName = c.category;
}
}
return bestScore > 0 ? { key: bestKey, name: bestName } : {};
}
function buildOrderRows() {
const parsed = buildParsedRows();
if (!parsed.length) {
toast.error("Nothing to process");
return;
}
const next: OrderRow[] = parsed.map((r) => {
const m = matchCategory(r);
const cat = m.key ? categoryByKey.get(m.key) : undefined;
const base = suggestFromCategory(cat?.avgTotalSold, scalePct);
const finalQty = applyMOQ(base, r.moq);
return {
...r,
matchedCategoryPath: m.key,
matchedCategoryName: m.name,
baseSuggestion: base,
finalQty,
};
});
setOrderRows(next);
setShowMapping(false);
}
// Re-apply scaling dynamically to suggested rows
useEffect(() => {
if (!orderRows.length) return;
startTransition(() => {
setOrderRows((rows) =>
rows.map((row) => {
const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined;
if (!cat) return row; // nothing to scale when no category
const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq);
const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct);
const nextAuto = applyMOQ(nextBase, row.moq);
const isAuto = row.finalQty === prevAuto;
return {
...row,
baseSuggestion: nextBase,
finalQty: isAuto ? nextAuto : row.finalQty,
};
})
);
});
}, [scalePct, categoryByKey]);
// After categories load (e.g. after refresh), recompute base suggestions
useEffect(() => {
if (!orderRows.length) return;
startTransition(() => {
setOrderRows((rows) =>
rows.map((row) => {
const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined;
if (!cat) return row;
const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct);
const nextAuto = applyMOQ(nextBase, row.moq);
const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq);
const isAuto = row.finalQty === prevAuto || !row.baseSuggestion; // treat empty base as auto
return {
...row,
baseSuggestion: nextBase,
finalQty: isAuto ? nextAuto : row.finalQty,
};
})
);
});
}, [categoryByKey]);
const changeCategory = useCallback((idx: number, newKey?: string) => {
setOrderRows((rows) => {
const copy = [...rows];
const row = { ...copy[idx] };
row.matchedCategoryPath = newKey;
if (newKey) {
const cat = categoryByKey.get(newKey);
row.matchedCategoryName = cat?.category;
row.baseSuggestion = suggestFromCategory(cat?.avgTotalSold, scalePct);
row.finalQty = applyMOQ(row.baseSuggestion || 0, row.moq);
} else {
row.matchedCategoryName = undefined;
row.baseSuggestion = undefined;
row.finalQty = row.moq ? row.moq : 0;
}
copy[idx] = row;
return copy;
});
}, [categoryByKey, scalePct]);
const changeQty = useCallback((idx: number, value: string) => {
const n = Number(value);
startTransition(() => setOrderRows((rows) => {
const copy = [...rows];
const row = { ...copy[idx] };
const raw = Number.isFinite(n) ? Math.round(n) : 0;
row.finalQty = raw; // do not enforce MOQ on manual edits
copy[idx] = row;
return copy;
}));
}, []);
const removeRow = useCallback((idx: number) => {
setOrderRows((rows) => rows.filter((_, i) => i !== idx));
}, []);
const visibleRows = useMemo(() => (
showExcludedOnly
? orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim()))
: orderRows
), [orderRows, showExcludedOnly]);
const OrderRowsTable = useMemo(() => memo(function OrderRowsTableInner({
rows,
}: { rows: OrderRow[] }) {
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>UPC</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">Avg Sold</TableHead>
<TableHead className="text-right">MOQ</TableHead>
<TableHead className="text-right">Order Qty</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, idx) => {
const cat = r.matchedCategoryPath ? categoryByKey.get(r.matchedCategoryPath) : undefined;
const isExcluded = !(r.finalQty > 0 && r.upc && r.upc.trim());
return (
<TableRow key={`${r.product || r.upc || 'row'}-${idx}`} className={isExcluded ? 'bg-destructive/10' : undefined}>
<TableCell>
<div className="font-medium">{r.product}</div>
</TableCell>
<TableCell className="whitespace-nowrap">{r.sku || ""}</TableCell>
<TableCell className="whitespace-nowrap">{r.upc || ""}</TableCell>
<TableCell className="min-w-[280px]">
<Select
value={r.matchedCategoryPath ?? "__none"}
onValueChange={(v) => changeCategory(idx, v === "__none" ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent className="max-h-[320px]">
<SelectItem value="__none">Unmatched</SelectItem>
{categoryOptions.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">{cat?.avgTotalSold?.toFixed?.(2) ?? "-"}</TableCell>
<TableCell className="text-right">{r.moq ?? "-"}</TableCell>
<TableCell className="text-right">
<Input
className="w-24 text-right"
value={Number.isFinite(r.finalQty) ? r.finalQty : 0}
onChange={(e) => changeQty(idx, e.target.value)}
inputMode="numeric"
/>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => removeRow(idx)} aria-label="Remove row">
<XIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}), [categoryByKey, categoryOptions, changeCategory, changeQty, removeRow]);
const exportJson = useMemo(() => {
const items = orderRows
.filter((r) => (r.finalQty || 0) > 0 && !!(r.upc && r.upc.trim()))
.map((r) => ({ upc: r.upc!, quantity: r.finalQty }));
return {
supplierId: selectedSupplierId ?? null,
generatedAt: new Date().toISOString(),
itemCount: items.length,
items,
};
}, [orderRows, selectedSupplierId]);
const canProcess = headers.length > 0 && rawRows.length > 0;
return (
<Card>
<CardHeader>
<CardTitle>Quick Order Builder</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Supplier + Clear */}
<div className="flex flex-wrap items-end gap-3">
<div className="max-w-sm">
<div className="text-sm font-medium mb-1">Supplier</div>
<Select
value={selectedSupplierId ?? "__none"}
onValueChange={(v) => setSelectedSupplierId(v === "__none" ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select supplier" />
</SelectTrigger>
<SelectContent className="max-h-[320px]">
<SelectItem value="__none">Select supplier</SelectItem>
{supplierOptions.map((s) => (
<SelectItem key={String(s.value)} value={String(s.value)}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="destructive"
onClick={() => {
setPasted("");
setHeaders([]);
setRawRows([]);
setHeaderMap({});
setOrderRows([]);
setShowJson(false);
setSelectedSupplierId(undefined);
setScalePct(100);
setScaleInput("100");
setParsed(false);
setShowMapping(false);
try { localStorage.removeItem(DRAFT_KEY); } catch {}
toast.message("Draft cleared");
}}
>
Clear Draft
</Button>
</div>
{!parsed && (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv,.tsv,.txt"
onChange={handleFileChange}
className="max-w-sm"
/>
<span className="text-muted-foreground text-sm">or paste below</span>
</div>
<Textarea
placeholder="Paste rows (with a header): Product, SKU, Category, MOQ..."
value={pasted}
onChange={(e) => setPasted(e.target.value)}
className="min-h-[120px]"
/>
<div className="flex gap-2">
<Button variant="outline" onClick={handlePasteParse} disabled={!pasted.trim()}>
Parse Pasted Data
</Button>
</div>
</>
)}
{headers.length > 0 && showMapping && (
<div className="space-y-3">
<div className="text-sm font-medium">Map Columns</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<div>
<div className="text-xs text-muted-foreground mb-1">Product (recommended)</div>
<Select
value={headerMap.product}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, product: v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">UPC / Barcode (recommended)</div>
<Select
value={headerMap.upc ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, upc: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">SKU (optional)</div>
<Select
value={headerMap.sku ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, sku: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Category Hint (optional)</div>
<Select
value={headerMap.categoryHint ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, categoryHint: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">MOQ (optional)</div>
<Select
value={headerMap.moq ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, moq: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<Button onClick={buildOrderRows} disabled={!canProcess || (!headerMap.product && !headerMap.upc)}>
Build Suggestions
</Button>
</div>
</div>
)}
{orderRows.length > 0 && (
<div className="space-y-3">
{/* Controls for existing suggestions */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-end gap-3">
<div>
<div className="text-xs text-muted-foreground mb-1">Scale suggestions (%)</div>
<Input
type="number"
className="w-28"
value={scaleInput}
onChange={(e) => setScaleInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 pb-2">
<Checkbox id="excludedOnly" checked={showExcludedOnly} onCheckedChange={(v) => setShowExcludedOnly(!!v)} />
<label htmlFor="excludedOnly" className="text-sm">Show excluded only</label>
</div>
</div>
<div className="pb-2">
<Button variant="outline" size="sm" onClick={() => setShowMapping((v) => !v)}>
{showMapping ? 'Hide Mapping' : 'Edit Mapping'}
</Button>
</div>
</div>
<OrderRowsTable rows={visibleRows} />
{/* Exclusion alert if some rows won't be exported */}
{(() => {
const excluded = orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim()));
if (excluded.length === 0) return null;
const missingUpc = excluded.filter((r) => !r.upc || !r.upc.trim()).length;
const zeroQty = excluded.filter((r) => !(r.finalQty > 0)).length;
return (
<Alert variant="destructive">
<AlertTitle>Some rows will not be included</AlertTitle>
<AlertDescription>
<div className="text-sm">
{excluded.length} row{excluded.length !== 1 ? "s" : ""} excluded from JSON
<ul className="list-disc ml-5">
{missingUpc > 0 && <li>{missingUpc} missing UPC</li>}
{zeroQty > 0 && <li>{zeroQty} with zero quantity</li>}
</ul>
</div>
</AlertDescription>
</Alert>
);
})()}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowJson((s) => !s)}>
{showJson ? "Hide" : "Preview"} JSON
</Button>
<Button
onClick={() => {
setShowJson(true);
navigator.clipboard?.writeText(JSON.stringify(exportJson, null, 2)).then(
() => toast.success("JSON copied"),
() => toast.message("JSON ready (copy failed)")
).finally(() => {
try { localStorage.removeItem(DRAFT_KEY); } catch {}
});
}}
>
Copy JSON
</Button>
</div>
{showJson && (
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
{JSON.stringify(exportJson, null, 2)}
</Code>
)}
</div>
)}
</CardContent>
</Card>
);
}
@@ -809,8 +809,3 @@ export function DesignerSubComponent({ row }: { row: { original: DesignerGroup }
); );
} }
// ─── 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;
+85 -71
View File
@@ -7,7 +7,6 @@ import {
Tags, Tags,
PackagePlus, PackagePlus,
ShoppingBag, ShoppingBag,
Truck,
MessageCircle, MessageCircle,
LayoutDashboard, LayoutDashboard,
Percent, Percent,
@@ -17,6 +16,9 @@ import {
PenLine, PenLine,
Mail, Mail,
Layers, Layers,
Repeat,
ClipboardPlus,
PackageSearch,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react"; import { IconCrystalBall } from "@tabler/icons-react";
import { import {
@@ -79,18 +81,6 @@ const inventoryItems = [
url: "/brands", url: "/brands",
permission: "access:brands" permission: "access:brands"
}, },
{
title: "Product Lines",
icon: Layers,
url: "/product-lines",
permission: "access:product_lines"
},
{
title: "Vendors",
icon: Truck,
url: "/vendors",
permission: "access:vendors"
},
{ {
title: "Purchase Orders", title: "Purchase Orders",
icon: ClipboardList, icon: ClipboardList,
@@ -105,7 +95,61 @@ const inventoryItems = [
} }
]; ];
const buyingItems = [
{
title: "Product Lines",
icon: Layers,
url: "/product-lines",
permission: "access:product_lines"
},
{
title: "Forecasting",
icon: IconCrystalBall,
url: "/forecasting",
permission: "access:forecasting"
},
{
title: "Repeat Orders",
icon: Repeat,
url: "/repeat-orders",
permission: "access:repeat_orders"
},
{
title: "Create PO",
icon: ClipboardPlus,
url: "/create-purchase-order",
permission: "access:create_purchase_orders"
}
];
const productManagementItems = [
{
title: "Create Products",
icon: PackagePlus,
url: "/import",
permission: "access:import"
},
{
title: "Product Editor",
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
},
{
title: "Bulk Edit",
icon: PenLine,
url: "/bulk-edit",
permission: "access:bulk_edit"
}
];
const toolsItems = [ const toolsItems = [
{
title: "Newsletter",
icon: Mail,
url: "/newsletter",
permission: "access:newsletter"
},
{ {
title: "Discount Simulator", title: "Discount Simulator",
icon: Percent, icon: Percent,
@@ -119,41 +163,11 @@ const toolsItems = [
permission: "access:hts_lookup" permission: "access:hts_lookup"
}, },
{ {
title: "Forecasting", title: "Spec Lookup",
icon: IconCrystalBall, icon: PackageSearch,
url: "/forecasting", url: "/spec-lookup",
permission: "access:forecasting" permission: "access:spec_lookup"
}, },
{
title: "Product Editor",
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
},
{
title: "Bulk Edit",
icon: PenLine,
url: "/bulk-edit",
permission: "access:bulk_edit"
},
{
title: "Newsletter",
icon: Mail,
url: "/newsletter",
permission: "access:newsletter"
}
];
const productSetupItems = [
{
title: "Create Products",
icon: PackagePlus,
url: "/import",
permission: "access:import"
}
];
const chatItems = [
{ {
title: "Chat Archive", title: "Chat Archive",
icon: MessageCircle, icon: MessageCircle,
@@ -259,6 +273,30 @@ export function AppSidebar() {
</SidebarGroup> </SidebarGroup>
)} )}
{/* Buying Section */}
{hasAccessToSection(buyingItems) && (
<SidebarGroup>
<SidebarGroupLabel>Buying</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(buyingItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Product Management Section */}
{hasAccessToSection(productManagementItems) && (
<SidebarGroup>
<SidebarGroupLabel>Product Management</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(productManagementItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Tools Section */} {/* Tools Section */}
{hasAccessToSection(toolsItems) && ( {hasAccessToSection(toolsItems) && (
<SidebarGroup> <SidebarGroup>
@@ -271,30 +309,6 @@ export function AppSidebar() {
</SidebarGroup> </SidebarGroup>
)} )}
{/* Product Setup Section */}
{hasAccessToSection(productSetupItems) && (
<SidebarGroup>
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(productSetupItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Chat Section */}
{hasAccessToSection(chatItems) && (
<SidebarGroup>
<SidebarGroupLabel>Chat</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(chatItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Settings Section */} {/* Settings Section */}
<Protected permission="access:settings" fallback={null}> <Protected permission="access:settings" fallback={null}>
<SidebarGroup> <SidebarGroup>
@@ -87,6 +87,7 @@ export function EditableInput({
<button <button
type="button" type="button"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
onFocus={() => setEditing(true)}
className="flex items-center gap-1.5 flex-1 min-w-0" className="flex items-center gap-1.5 flex-1 min-w-0"
> >
{label && ( {label && (
@@ -188,6 +188,24 @@ function SortableImageCell({
let newImageCounter = 0; let newImageCounter = 0;
const normalizeImageUrlInput = (url: string) => {
const trimmed = url.trim();
if (!trimmed) return "";
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed;
}
return `https://${trimmed}`;
};
const isLikelyWebpUrl = (url: string) => {
try {
const parsed = new URL(normalizeImageUrlInput(url));
return parsed.pathname.toLowerCase().endsWith(".webp");
} catch {
return url.toLowerCase().includes(".webp");
}
};
export function ImageManager({ export function ImageManager({
images, images,
setImages, setImages,
@@ -301,12 +319,36 @@ export function ImageManager({
[addNewImage] [addNewImage]
); );
const handleUrlAdd = useCallback(() => { const handleUrlAdd = useCallback(async () => {
const url = urlInput.trim(); const url = urlInput.trim();
if (!url) return; if (!url) return;
addNewImage(url);
setUrlInput(""); if (!isLikelyWebpUrl(url)) {
addNewImage(url);
setUrlInput("");
setAddOpen(false);
return;
}
setIsUploading(true);
setAddOpen(false); setAddOpen(false);
try {
const res = await axios.post("/api/import/upload-image-url", {
imageUrl: normalizeImageUrlInput(url),
});
if (res.data?.imageUrl) {
addNewImage(res.data.imageUrl);
setUrlInput("");
} else {
throw new Error("Upload response did not include an image URL");
}
} catch (error) {
console.error("Failed to convert WebP image URL:", error);
toast.error(`Failed to convert WebP image URL: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setIsUploading(false);
}
}, [urlInput, addNewImage]); }, [urlInput, addNewImage]);
const activeImage = activeId const activeImage = activeId
@@ -344,8 +344,10 @@ export function ProductEditForm({
const originalIds = original.map((img) => img.iid); const originalIds = original.map((img) => img.iid);
const currentIds = current.map((img) => img.iid); const currentIds = current.map((img) => img.iid);
const originalExistingIds = originalIds.filter((id): id is number => typeof id === "number");
const currentExistingIds = currentIds.filter((id): id is number => typeof id === "number");
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[]; const toDelete = originalExistingIds.filter((id) => !currentIds.includes(id));
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number"); const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number"); const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const add: Record<string, string> = {}; const add: Record<string, string> = {};
@@ -357,8 +359,11 @@ export function ProductEditForm({
const order = current.map((img) => img.iid); const order = current.map((img) => img.iid);
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid); const originalHidden = original
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds); .filter((img) => img.hidden)
.map((img) => img.iid)
.filter((id): id is number => typeof id === "number");
const orderChanged = JSON.stringify(currentExistingIds) !== JSON.stringify(originalExistingIds);
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort()); const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
const hasDeleted = toDelete.length > 0; const hasDeleted = toDelete.length > 0;
const hasAdded = Object.keys(add).length > 0; const hasAdded = Object.keys(add).length > 0;
@@ -492,7 +497,22 @@ export function ProductEditForm({
} else { } else {
toast.success("Product updated successfully"); toast.success("Product updated successfully");
originalValuesRef.current = { ...data }; originalValuesRef.current = { ...data };
originalImagesRef.current = [...productImages]; if (imageChanges) {
try {
const res = await axios.get(`/api/import/product-images/${product.pid}`);
originalImagesRef.current = res.data;
setProductImages(res.data);
} catch {
const submittedImages = productImages.map((img) => ({
...img,
isNew: false,
}));
originalImagesRef.current = submittedImages;
setProductImages(submittedImages);
}
} else {
originalImagesRef.current = [...productImages];
}
reset(data); reset(data);
} }
} catch (err) { } catch (err) {
@@ -50,6 +50,7 @@ export function ProductSearch({
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null); const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false); const [resultsOpen, setResultsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [fullByPid, setFullByPid] = useState<Map<number, SearchProduct>>(new Map());
const handleSearch = useCallback(async () => { const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return; if (!searchTerm.trim()) return;
@@ -59,8 +60,29 @@ export function ProductSearch({
const res = await axios.get("/api/products/search", { const res = await axios.get("/api/products/search", {
params: { q: searchTerm }, params: { q: searchTerm },
}); });
setSearchResults(res.data.results); if (res.data.total === 0) {
setTotalCount(res.data.total); const fallback = await axios.get("/api/import/search-products", {
params: { q: searchTerm },
});
const fullProducts = fallback.data as SearchProduct[];
const previews: QuickSearchResult[] = fullProducts.map((p) => ({
pid: Number(p.pid),
title: p.title,
sku: p.sku,
barcode: p.barcode,
brand: p.brand,
line: p.line,
regular_price: p.regular_price,
image_175: null,
}));
setSearchResults(previews);
setTotalCount(fullProducts.length);
setFullByPid(new Map(fullProducts.map((p) => [Number(p.pid), p])));
} else {
setSearchResults(res.data.results);
setTotalCount(res.data.total);
setFullByPid(new Map());
}
setResultsOpen(true); setResultsOpen(true);
} catch { } catch {
toast.error("Search failed"); toast.error("Search failed");
@@ -72,6 +94,12 @@ export function ProductSearch({
const handleSelect = useCallback( const handleSelect = useCallback(
async (product: QuickSearchResult) => { async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return; if (loadedPids.has(Number(product.pid))) return;
const cached = fullByPid.get(Number(product.pid));
if (cached) {
onSelect(cached);
setResultsOpen(false);
return;
}
setIsLoadingProduct(product.pid); setIsLoadingProduct(product.pid);
try { try {
const res = await axios.get("/api/import/search-products", { const res = await axios.get("/api/import/search-products", {
@@ -90,7 +118,7 @@ export function ProductSearch({
setIsLoadingProduct(null); setIsLoadingProduct(null);
} }
}, },
[onSelect, loadedPids] [onSelect, loadedPids, fullByPid]
); );
const handleLoadAll = useCallback(() => { const handleLoadAll = useCallback(() => {
@@ -293,16 +293,6 @@ export const BASE_IMPORT_FIELDS = [
width: 500, width: 500,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
{
label: "Private Notes",
key: "priv_notes",
description: "Internal notes about the product",
fieldType: {
type: "input",
multiline: true
},
width: 300,
},
{ {
label: "Categories", label: "Categories",
key: "categories", key: "categories",
@@ -335,6 +325,16 @@ export const BASE_IMPORT_FIELDS = [
}, },
width: 200, width: 200,
}, },
{
label: "Private Notes",
key: "priv_notes",
description: "Internal notes about the product",
fieldType: {
type: "input",
multiline: true
},
width: 300,
},
] as const; ] as const;
export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"]; export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
@@ -1,8 +1,11 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, Upload } from "lucide-react"; import { Loader2, Upload } from "lucide-react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
interface GenericDropzoneProps { interface GenericDropzoneProps {
processingBulk: boolean; processingBulk: boolean;
unassignedImages: { previewUrl: string; file: File }[]; unassignedImages: { previewUrl: string; file: File }[];
@@ -22,7 +25,17 @@ export const GenericDropzone = ({
accept: { accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
}, },
maxSize: MAX_UPLOAD_BYTES,
onDrop, onDrop,
onDropRejected: (rejections) => {
rejections.forEach((rejection) => {
const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large');
const reason = tooLarge
? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit`
: rejection.errors[0]?.message ?? 'rejected';
toast.error(`${rejection.file.name}: ${reason}`);
});
},
multiple: true multiple: true
}); });
@@ -1,7 +1,10 @@
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
interface ImageDropzoneProps { interface ImageDropzoneProps {
productIndex: number; productIndex: number;
onDrop: (files: File[]) => void; onDrop: (files: File[]) => void;
@@ -12,9 +15,19 @@ export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
accept: { accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
}, },
maxSize: MAX_UPLOAD_BYTES,
onDrop: (acceptedFiles) => { onDrop: (acceptedFiles) => {
onDrop(acceptedFiles); onDrop(acceptedFiles);
}, },
onDropRejected: (rejections) => {
rejections.forEach((rejection) => {
const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large');
const reason = tooLarge
? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit`
: rejection.errors[0]?.message ?? 'rejected';
toast.error(`${rejection.file.name}: ${reason}`);
});
},
}); });
return ( return (
@@ -1,9 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import config from "@/config";
import { Product, ProductImageSortable } from "../types"; import { Product, ProductImageSortable } from "../types";
type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[]; type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
const isLikelyWebpUrl = (url: string) => {
try {
return new URL(url).pathname.toLowerCase().endsWith(".webp");
} catch {
return url.toLowerCase().includes(".webp");
}
};
interface UseUrlImageUploadProps { interface UseUrlImageUploadProps {
data: Product[]; data: Product[];
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>; setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
@@ -46,6 +55,35 @@ export const useUrlImageUpload = ({
return; return;
} }
let imageUrl = validatedUrl;
let fileName = "From URL";
if (isLikelyWebpUrl(validatedUrl)) {
const response = await fetch(`${config.apiUrl}/import/upload-image-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
imageUrl: validatedUrl,
upc: data[productIndex].upc || "",
supplier_no: data[productIndex].supplier_no || "",
}),
});
if (!response.ok) {
throw new Error("Failed to convert WebP image URL");
}
const result = await response.json();
if (!result.imageUrl) {
throw new Error("Upload response did not include an image URL");
}
imageUrl = result.imageUrl;
fileName = result.fileName || "Converted WebP URL";
}
// Create a unique ID for this image // Create a unique ID for this image
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -53,9 +91,9 @@ export const useUrlImageUpload = ({
const newImage: ProductImageSortable = { const newImage: ProductImageSortable = {
id: imageId, id: imageId,
productIndex, productIndex,
imageUrl: validatedUrl, imageUrl,
loading: false, // We're not loading from server, so it's ready immediately loading: false, // We're not loading from server, so it's ready immediately
fileName: "From URL", fileName,
// Add required schema fields // Add required schema fields
pid: data[productIndex].id || 0, pid: data[productIndex].id || 0,
iid: 0, iid: 0,
@@ -70,7 +108,7 @@ export const useUrlImageUpload = ({
setProductImages(prev => [...prev, newImage]); setProductImages(prev => [...prev, newImage]);
// Update the product data with the new image URL // Update the product data with the new image URL
addImageToProduct(productIndex, validatedUrl); addImageToProduct(productIndex, imageUrl);
// Clear the URL input field on success // Clear the URL input field on success
setUrlInputs(prev => ({ ...prev, [productIndex]: '' })); setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
@@ -8,9 +8,10 @@
import { memo, useEffect, useState, useRef } from 'react'; import { memo, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { X } from 'lucide-react'; import { X, ArrowDownToLine } from 'lucide-react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { useIsCopyDownActive } from '../store/selectors'; import { useIsCopyDownActive } from '../store/selectors';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
/** /**
* Copy-down instruction banner * Copy-down instruction banner
@@ -23,6 +24,11 @@ import { useIsCopyDownActive } from '../store/selectors';
*/ */
export const CopyDownBanner = memo(() => { export const CopyDownBanner = memo(() => {
const isActive = useIsCopyDownActive(); const isActive = useIsCopyDownActive();
// Subscribe only to the primitives we need to compute "rows below source".
// These are cheap to compare and only change while copy-down is active.
const rowCount = useValidationStore((state) => state.rows.length);
const sourceRowIndex = useValidationStore((state) => state.copyDownMode.sourceRowIndex);
const rowsBelow = sourceRowIndex !== null ? Math.max(0, rowCount - 1 - sourceRowIndex) : 0;
const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const bannerRef = useRef<HTMLDivElement>(null); const bannerRef = useRef<HTMLDivElement>(null);
@@ -57,13 +63,19 @@ export const CopyDownBanner = memo(() => {
const tableRect = tableContainer.getBoundingClientRect(); const tableRect = tableContainer.getBoundingClientRect();
const cellRect = cellElement.getBoundingClientRect(); const cellRect = cellElement.getBoundingClientRect();
// Calculate position relative to the table container // Measure actual banner height so it sits the right distance above the cell.
// Position banner centered horizontally on the cell, above it // Fallback covers the very first paint before the ref is attached.
const topPosition = cellRect.top - tableRect.top - 55; // 55px above the cell (enough to not cover it) const bannerHeight = bannerRef.current?.offsetHeight ?? 32;
const GAP = 10;
// Always position above the cell. Clamp to the top of the table container
// so the banner stays visible — a small overlap with the source cell is
// acceptable for top-row sources since the compact pill rarely needs it.
const topPosition = Math.max(cellRect.top - tableRect.top - bannerHeight - GAP, 8);
const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2; const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2;
setPosition({ setPosition({
top: Math.max(topPosition, 8), // Minimum 8px from top top: topPosition,
left: leftPosition, left: leftPosition,
}); });
}; };
@@ -85,6 +97,13 @@ export const CopyDownBanner = memo(() => {
useValidationStore.getState().cancelCopyDown(); useValidationStore.getState().cancelCopyDown();
}; };
const handleApplyToAll = () => {
const state = useValidationStore.getState();
const lastRowIndex = state.rows.length - 1;
if (lastRowIndex <= (state.copyDownMode.sourceRowIndex ?? -1)) return;
state.completeCopyDown(lastRowIndex);
};
return ( return (
<div <div
className="absolute z-30 pointer-events-none" className="absolute z-30 pointer-events-none"
@@ -95,18 +114,44 @@ export const CopyDownBanner = memo(() => {
}} }}
> >
<div ref={bannerRef} className="pointer-events-auto"> <div ref={bannerRef} className="pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200"> <div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" /> <div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300"> <span className="text-xs font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap">
Click on the last row you want to copy to Click row to copy to
</span> </span>
{rowsBelow > 0 && (
<>
<div className="h-4 w-px bg-blue-200 dark:bg-blue-800" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleApplyToAll}
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-100"
>
<ArrowDownToLine className="h-3 w-3 mr-1" />
All
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center">
{rowsBelow === 1
? "Copy to 1 row below"
: `Copy to all ${rowsBelow} rows below`}
</TooltipContent>
</Tooltip>
</>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleCancel} onClick={handleCancel}
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900" className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -923,21 +923,29 @@ const CellWrapper = memo(({
{/* Copy-down button - appears on hover, positioned to avoid error icons */} {/* Copy-down button - appears on hover, positioned to avoid error icons */}
{showCopyDownButton && ( {showCopyDownButton && (
<button <TooltipProvider>
type="button" <Tooltip>
onClick={handleStartCopyDown} <TooltipTrigger asChild>
className={cn( <button
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full', type="button"
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600', onClick={handleStartCopyDown}
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400', className={cn(
'shadow-sm', 'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
// Position further left if there are errors to avoid overlap 'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
hasErrors ? 'right-7' : 'right-0.5' 'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
)} 'shadow-sm',
title="Copy value to rows below" // Position further left if there are errors to avoid overlap
> hasErrors ? 'right-7' : 'right-0.5'
<ArrowDown className="h-3.5 w-3.5" /> )}
</button> >
<ArrowDown className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Copy value to rows below
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
{/* UPC Generate button - appears on hover for empty UPC cells */} {/* UPC Generate button - appears on hover for empty UPC cells */}
+30 -10
View File
@@ -1,4 +1,4 @@
import { createContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { createContext, useState, useEffect, useRef, ReactNode, useCallback } from 'react';
import config from '@/config'; import config from '@/config';
export interface Permission { export interface Permission {
@@ -61,7 +61,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to fetch user data'); console.error('Auth check failed:', response.status, errorData);
// Any failed /me response means the session is invalid — logout
logout();
return;
} }
const userData = await response.json(); const userData = await response.json();
@@ -76,14 +79,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred'; const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
setError(errorMessage); setError(errorMessage);
console.error('Auth error:', errorMessage); console.error('Auth error:', errorMessage);
// Network errors (server down, etc.) — don't logout, just show error
// Clear token if authentication failed
if (err instanceof Error &&
(err.message.includes('authentication') ||
err.message.includes('token') ||
err.message.includes('401'))) {
logout();
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -99,6 +95,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [token, fetchCurrentUser]); }, [token, fetchCurrentUser]);
// Re-validate auth when user returns to the tab after being away
const lastCheckRef = useRef(Date.now());
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && token) {
const elapsed = Date.now() - lastCheckRef.current;
// Only re-check if at least 5 minutes have passed
if (elapsed > 5 * 60 * 1000) {
lastCheckRef.current = Date.now();
fetchCurrentUser();
}
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [token, fetchCurrentUser]);
// Listen for auth:logout events from anywhere in the app (e.g. failed API calls)
useEffect(() => {
const onForceLogout = () => logout();
window.addEventListener('auth:logout', onForceLogout);
return () => window.removeEventListener('auth:logout', onForceLogout);
}, []);
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
try { try {
setIsLoading(true); setIsLoading(true);
File diff suppressed because it is too large Load Diff
+425
View File
@@ -0,0 +1,425 @@
/**
* Create / Add-to Purchase Order page.
*
* Supports two flows toggled at the top of the page:
* - "create" → pick a supplier and assemble a list of products, then POST
* to /apiv2/po/new/{supplierId}.
* - "add" → enter an existing PO number and assemble a list of
* additional line items, then POST to
* /apiv2/po/add_products/{poId}.
*
* Both modes use the same product-add UX (Search/Paste/Upload) and the same
* line items table. The "add" mode does NOT pull existing items from the
* target PO — only the newly assembled items are sent to the backend.
*
* State model:
* - mode → "create" | "add" (toggled via Tabs)
* - supplierId → controlled string, only used in create mode
* - existingPoInput → controlled string, only used in add mode
* - lineItems[] → working list (PoLineItem; local-only fields qty +
* moqOverride live here). Cleared when mode changes.
* - selectedPids: Set → checkbox state for the bulk-remove flow
* - addOpen → AddProductsDialog visibility
* - submitting → submit button spinner
* - confirmation → null while building; { poId, itemCount, mode }
* after a successful submit.
*
* Dedup is enforced server-naive: when AddProductsDialog returns a list of
* (pid, qty) pairs, we filter out pids already on the working list and show
* a brief toast indicating how many were skipped. In "add" mode we do NOT
* dedup against the target PO (we don't fetch its current contents).
*/
import { useCallback, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import { SupplierSelector } from "@/components/create-po/SupplierSelector";
import { LineItemsTable } from "@/components/create-po/LineItemsTable";
import { PoFloatingSelectionBar } from "@/components/create-po/PoFloatingSelectionBar";
import { AddProductsDialog } from "@/components/create-po/AddProductsDialog";
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
import type { PoLineItem } from "@/components/create-po/types";
import {
submitNewPurchaseOrder,
addProductsToPurchaseOrder,
} from "@/services/apiv2";
type Mode = "create" | "add";
export default function CreatePurchaseOrder() {
const [mode, setMode] = useState<Mode>("create");
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
const [existingPoInput, setExistingPoInput] = useState<string>("");
const [lineItems, setLineItems] = useState<PoLineItem[]>([]);
const [selectedPids, setSelectedPids] = useState<Set<number>>(new Set());
const [addOpen, setAddOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [hydrating, setHydrating] = useState(false);
const [confirmation, setConfirmation] = useState<{
poId: number;
itemCount: number;
mode: Mode;
} | null>(null);
// ---- Mode toggle ----------------------------------------------------------
// Switching modes clears the working list and target identifiers — the two
// flows submit to different endpoints with different keys, so mixing state
// would just make for confusing UX.
const handleModeChange = useCallback((next: string) => {
if (next !== "create" && next !== "add") return;
setMode(next);
setSupplierId(undefined);
setExistingPoInput("");
setLineItems([]);
setSelectedPids(new Set());
}, []);
// ---- Add products from any tab (Search/Paste/Upload) ----------------------
const handleAddProducts = useCallback(
async (items: Array<{ pid: number; qty: number }>) => {
if (items.length === 0) return;
// Dedup against existing line items — silently skip duplicates
const existing = new Set(lineItems.map((i) => i.pid));
const fresh = items.filter((i) => !existing.has(i.pid));
const skipped = items.length - fresh.length;
if (skipped > 0) {
toast.info(
skipped === 1
? "1 product already on PO"
: `${skipped} products already on PO`
);
}
if (fresh.length === 0) return;
setHydrating(true);
try {
const qtyByPid = new Map(fresh.map((i) => [i.pid, i.qty]));
const hydrated = await fetchBatchProducts(
fresh.map((i) => i.pid),
qtyByPid
);
if (hydrated.length === 0) {
toast.error("Could not load product details");
return;
}
// Append in the order returned by the backend (which preserves request order)
setLineItems((prev) => [...prev, ...hydrated]);
toast.success(
hydrated.length === 1
? "1 product added"
: `${hydrated.length} products added`
);
} catch (e) {
console.error(e);
toast.error("Failed to load product details");
} finally {
setHydrating(false);
}
},
[lineItems]
);
// ---- Row mutation handlers (passed to LineItemsTable) --------------------
const handleToggleSelect = useCallback((pid: number) => {
setSelectedPids((prev) => {
const next = new Set(prev);
if (next.has(pid)) next.delete(pid);
else next.add(pid);
return next;
});
}, []);
const handleToggleSelectAll = useCallback(
(selectAll: boolean) => {
if (selectAll) {
setSelectedPids(new Set(lineItems.map((i) => i.pid)));
} else {
setSelectedPids(new Set());
}
},
[lineItems]
);
const handleChangeQty = useCallback((pid: number, qty: number) => {
setLineItems((prev) =>
prev.map((i) => (i.pid === pid ? { ...i, qty } : i))
);
}, []);
const handleChangeMoqOverride = useCallback(
(pid: number, moq: number | undefined) => {
setLineItems((prev) =>
prev.map((i) => (i.pid === pid ? { ...i, moqOverride: moq } : i))
);
},
[]
);
const handleRemoveRow = useCallback((pid: number) => {
setLineItems((prev) => prev.filter((i) => i.pid !== pid));
setSelectedPids((prev) => {
const next = new Set(prev);
next.delete(pid);
return next;
});
}, []);
const handleBulkRemove = useCallback(() => {
setLineItems((prev) => prev.filter((i) => !selectedPids.has(i.pid)));
setSelectedPids(new Set());
toast.success(
selectedPids.size === 1
? "1 product removed"
: `${selectedPids.size} products removed`
);
}, [selectedPids]);
const handleClearSelection = useCallback(() => {
setSelectedPids(new Set());
}, []);
// ---- Submit ---------------------------------------------------------------
const validItems = lineItems
.filter((i) => i.qty > 0)
.map((i) => ({ pid: i.pid, qty: i.qty }));
const parsedPoId = (() => {
const trimmed = existingPoInput.trim();
if (!trimmed) return undefined;
const n = Number(trimmed);
return Number.isInteger(n) && n > 0 ? n : undefined;
})();
const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined;
const handleSubmit = useCallback(async () => {
if (mode === "create" && !supplierId) {
toast.error("Pick a supplier first");
return;
}
if (mode === "add" && parsedPoId === undefined) {
toast.error("Enter a valid PO number first");
return;
}
if (validItems.length === 0) {
toast.error("Add at least one product with a positive quantity");
return;
}
setSubmitting(true);
try {
if (mode === "create") {
const res = await submitNewPurchaseOrder({
supplierId: supplierId!,
items: validItems,
});
if (!res.success || !res.poId) {
const msg =
(typeof res.error === "string" && res.error) ||
res.message ||
"PO submission failed";
toast.error(msg);
return;
}
setConfirmation({
poId: res.poId,
itemCount: validItems.length,
mode: "create",
});
} else {
const res = await addProductsToPurchaseOrder({
poId: parsedPoId!,
items: validItems,
});
if (!res.success) {
const msg =
(typeof res.error === "string" && res.error) ||
res.message ||
"Failed to add products to PO";
toast.error(msg);
return;
}
setConfirmation({
poId: parsedPoId!,
itemCount: validItems.length,
mode: "add",
});
}
} catch (e) {
console.error(e);
toast.error(e instanceof Error ? e.message : "Submission failed");
} finally {
setSubmitting(false);
}
}, [mode, supplierId, parsedPoId, validItems]);
// ---- Reset for "Create another" / "Add more" ------------------------------
const handleCreateAnother = useCallback(() => {
setSupplierId(undefined);
setExistingPoInput("");
setLineItems([]);
setSelectedPids(new Set());
setConfirmation(null);
}, []);
// ---- Confirmation view (post-submit) --------------------------------------
if (confirmation) {
return (
<div className="container mx-auto p-6">
<ConfirmationView
poId={confirmation.poId}
itemCount={confirmation.itemCount}
mode={confirmation.mode}
onCreateAnother={handleCreateAnother}
/>
</div>
);
}
// ---- Builder view ---------------------------------------------------------
const totalQty = lineItems.reduce((sum, i) => sum + (i.qty > 0 ? i.qty : 0), 0);
const totalCost = lineItems.reduce(
(sum, i) =>
sum + (i.qty > 0 ? i.qty * (i.current_cost_price ?? 0) : 0),
0
);
const pageTitle =
mode === "create" ? "Create Purchase Order" : "Add to Purchase Order";
const targetCardTitle = mode === "create" ? "Supplier" : "Existing PO";
const submitLabel =
mode === "create" ? "Create purchase order" : "Add products to PO";
return (
<div className="container mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{pageTitle}</h1>
</div>
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList>
<TabsTrigger value="create">Create new PO</TabsTrigger>
<TabsTrigger value="add">Add to existing PO</TabsTrigger>
</TabsList>
</Tabs>
<Card>
<CardHeader>
<CardTitle>{targetCardTitle}</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md">
{mode === "create" ? (
<SupplierSelector value={supplierId} onChange={setSupplierId} />
) : (
<div className="space-y-2">
<Input
id="existing-po-id"
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="PO number"
value={existingPoInput}
onChange={(e) =>
setExistingPoInput(e.target.value.replace(/[^0-9]/g, ""))
}
/>
{existingPoInput.trim() !== "" && parsedPoId === undefined && (
<p className="text-sm text-destructive">
Enter a valid positive PO number.
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
Line Items
{lineItems.length > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({lineItems.length} {lineItems.length === 1 ? "product" : "products"} ·{" "}
{totalQty.toLocaleString()} units · $
{totalCost.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
)
</span>
)}
</CardTitle>
<Button onClick={() => setAddOpen(true)} disabled={hydrating}>
{hydrating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add products
</>
)}
</Button>
</CardHeader>
<CardContent>
<LineItemsTable
items={lineItems}
selectedPids={selectedPids}
supplierId={mode === "create" && supplierId ? Number(supplierId) : undefined}
onToggleSelect={handleToggleSelect}
onToggleSelectAll={handleToggleSelectAll}
onChangeQty={handleChangeQty}
onChangeMoqOverride={handleChangeMoqOverride}
onRemove={handleRemoveRow}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
size="lg"
onClick={handleSubmit}
disabled={submitting || lineItems.length === 0 || !targetReady}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting
</>
) : (
<>{submitLabel}</>
)}
</Button>
</div>
<AddProductsDialog
open={addOpen}
onOpenChange={setAddOpen}
existingPids={new Set(lineItems.map((i) => i.pid))}
onAdd={(result) => {
void handleAddProducts(result.items);
}}
/>
<PoFloatingSelectionBar
selectedCount={selectedPids.size}
onClear={handleClearSelection}
onRemove={handleBulkRemove}
/>
</div>
);
}
+13 -15
View File
@@ -62,6 +62,7 @@ export function DiscountSimulator() {
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE); const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST); const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual'); const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
const [applyHistoricalProductPromo, setApplyHistoricalProductPromo] = useState(false);
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE); const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false); const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE); const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
@@ -70,7 +71,6 @@ export function DiscountSimulator() {
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false); const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
const [loadedFromStorage, setLoadedFromStorage] = useState(false); const [loadedFromStorage, setLoadedFromStorage] = useState(false);
const initialRunRef = useRef(false);
const skipAutoRunRef = useRef(false); const skipAutoRunRef = useRef(false);
const latestPayloadKeyRef = useRef(''); const latestPayloadKeyRef = useRef('');
const pendingCountRef = useRef(0); const pendingCountRef = useRef(0);
@@ -127,13 +127,6 @@ export function DiscountSimulator() {
const promosLoading = !promosQuery.data && promosQuery.isLoading; const promosLoading = !promosQuery.data && promosQuery.isLoading;
const selectedPromoCode = useMemo(() => {
if (selectedPromoId == null) {
return undefined;
}
return promosQuery.data?.find((promo) => promo.id === selectedPromoId)?.code || undefined;
}, [promosQuery.data, selectedPromoId]);
const createPayload = useCallback((): DiscountSimulationRequest => { const createPayload = useCallback((): DiscountSimulationRequest => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
@@ -151,7 +144,6 @@ export function DiscountSimulator() {
filters: { filters: {
shipCountry: 'US', shipCountry: 'US',
promoIds: selectedPromoId ? [selectedPromoId] : undefined, promoIds: selectedPromoId ? [selectedPromoId] : undefined,
promoCodes: selectedPromoCode ? [selectedPromoCode] : undefined,
}, },
productPromo, productPromo,
shippingPromo, shippingPromo,
@@ -168,12 +160,12 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointsConfig: payloadPointsConfig, pointsConfig: payloadPointsConfig,
}; };
}, [ }, [
dateRange, dateRange,
selectedPromoId, selectedPromoId,
selectedPromoCode,
productPromo, productPromo,
shippingPromo, shippingPromo,
shippingTiers, shippingTiers,
@@ -181,6 +173,7 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue, pointDollarValue,
redemptionRate, redemptionRate,
]); ]);
@@ -264,6 +257,7 @@ export function DiscountSimulator() {
merchantFeePercent?: number; merchantFeePercent?: number;
fixedCostPerOrder?: number; fixedCostPerOrder?: number;
cogsCalculationMode?: CogsCalculationMode; cogsCalculationMode?: CogsCalculationMode;
applyHistoricalProductPromo?: boolean;
pointsConfig?: { pointsConfig?: {
pointsPerDollar?: number | null; pointsPerDollar?: number | null;
redemptionRate?: number | null; redemptionRate?: number | null;
@@ -319,6 +313,10 @@ export function DiscountSimulator() {
setCogsCalculationMode(parsed.cogsCalculationMode); setCogsCalculationMode(parsed.cogsCalculationMode);
} }
if (typeof parsed.applyHistoricalProductPromo === 'boolean') {
setApplyHistoricalProductPromo(parsed.applyHistoricalProductPromo);
}
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') { if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
setPointDollarValue(parsed.pointsConfig.pointDollarValue); setPointDollarValue(parsed.pointsConfig.pointDollarValue);
setPointDollarTouched(true); setPointDollarTouched(true);
@@ -361,10 +359,11 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue, pointDollarValue,
redemptionRate, redemptionRate,
}); });
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]); }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, applyHistoricalProductPromo, pointDollarValue, redemptionRate]);
useEffect(() => { useEffect(() => {
if (!hasLoadedConfig) { if (!hasLoadedConfig) {
@@ -379,10 +378,6 @@ export function DiscountSimulator() {
}, [serializedConfig, hasLoadedConfig]); }, [serializedConfig, hasLoadedConfig]);
useEffect(() => { useEffect(() => {
if (!initialRunRef.current) {
initialRunRef.current = true;
}
if (skipAutoRunRef.current) { if (skipAutoRunRef.current) {
skipAutoRunRef.current = false; skipAutoRunRef.current = false;
return; return;
@@ -448,6 +443,7 @@ export function DiscountSimulator() {
setMerchantFeePercent(DEFAULT_MERCHANT_FEE); setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST); setFixedCostPerOrder(DEFAULT_FIXED_COST);
setCogsCalculationMode('actual'); setCogsCalculationMode('actual');
setApplyHistoricalProductPromo(false);
setPointDollarValue(DEFAULT_POINT_VALUE); setPointDollarValue(DEFAULT_POINT_VALUE);
setPointDollarTouched(false); setPointDollarTouched(false);
setRedemptionRate(DEFAULT_REDEMPTION_RATE); setRedemptionRate(DEFAULT_REDEMPTION_RATE);
@@ -499,6 +495,8 @@ export function DiscountSimulator() {
onFixedCostChange={setFixedCostPerOrder} onFixedCostChange={setFixedCostPerOrder}
cogsCalculationMode={cogsCalculationMode} cogsCalculationMode={cogsCalculationMode}
onCogsCalculationModeChange={setCogsCalculationMode} onCogsCalculationModeChange={setCogsCalculationMode}
applyHistoricalProductPromo={applyHistoricalProductPromo}
onApplyHistoricalProductPromoChange={setApplyHistoricalProductPromo}
pointsPerDollar={currentPointsPerDollar} pointsPerDollar={currentPointsPerDollar}
redemptionRate={redemptionRate} redemptionRate={redemptionRate}
onRedemptionRateChange={setRedemptionRate} onRedemptionRateChange={setRedemptionRate}
-18
View File
@@ -37,7 +37,6 @@ import { Input } from "@/components/ui/input";
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react"; import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
type GroupMode = "line" | "category" | "designer"; type GroupMode = "line" | "category" | "designer";
@@ -211,20 +210,6 @@ export default function Forecasting() {
state: { sorting: designerSorting }, 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 ───────────────────────────────────────────────────── // ─── Summary stats ─────────────────────────────────────────────────────
const totalProducts = filteredProducts.length; const totalProducts = filteredProducts.length;
@@ -409,9 +394,6 @@ export default function Forecasting() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Quick Order Builder (unchanged interface) */}
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
</div> </div>
); );
} }
+846
View File
@@ -0,0 +1,846 @@
import { Fragment, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Loader2,
PackageOpen,
ExternalLink,
ChevronRight,
ChevronDown,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { PHASE_CONFIG } from "@/utils/lifecyclePhases";
const getPhaseLabel = (phase: string): string =>
PHASE_CONFIG[phase]?.label || phase;
const getPhaseColor = (phase: string): string =>
PHASE_CONFIG[phase]?.color || "#94A3B8";
// Notions = supplier 92; the daily auto-PO workflow runs here by default.
const DEFAULT_SUPPLIER_ID = 92;
type Supplier = {
supplierId: number;
vendorName: string;
lineCount: number;
poCount: number;
lastPoDate: string | null;
};
type RepeatOrderRow = {
pid: number;
sku: string | null;
title: string | null;
brand: string | null;
vendor: string | null;
imageUrl: string | null;
isVisible: boolean | null;
isReplenishable: boolean | null;
moq: number | null;
currentStock: number;
notionsInvCount: number | null;
onOrderQty: number;
sales30d: number | null;
velocity: number;
coverDays: number | null;
replenishmentUnits: number | null;
toOrderUnits: number | null;
avgLeadTimeDays: number | null;
lifecyclePhase: string | null;
earliestExpectedDate: string | null;
poLineCount: number;
poDaysActive: number;
poTotalUnits: number | null;
poAvgQty: number;
poMinQty: number | null;
poMaxQty: number | null;
firstPoDate: string | null;
lastPoDate: string | null;
poTotalCost: number;
poOpenLineCount: number;
recvLineCount: number;
recvDaysActive: number;
recvTotalUnits: number;
lastRecvDate: string | null;
forecast30d: number;
forecast60d: number;
repeatScore: number;
};
type RepeatOrdersResponse = {
params: {
supplierId: number;
windowDays: number;
minPoCount: number;
maxAvgQty: number;
};
supplierSummary: {
totalPoLines: number;
distinctPoIds: number;
distinctProducts: number;
distinctDays: number;
totalUnits: number;
avgQtyPerLine: number;
};
matchSummary: {
matchCount: number;
totalSmallPoLines: number;
totalSmallPoUnits: number;
totalSales30d: number;
totalSuggestedUnits: number;
};
results: RepeatOrderRow[];
};
type HistoryResponse = {
pid: number;
supplierId: number;
purchaseOrders: {
poId: string;
date: string;
expectedDate: string | null;
ordered: number;
costPrice: number;
status: string;
notes: string | null;
}[];
receivings: {
receivingId: string;
receivedDate: string;
qtyEach: number;
costEach: number;
status: string;
}[];
};
type SortKey =
| "po_count"
| "repeat_score"
| "avg_qty"
| "sales"
| "velocity"
| "cover"
| "notions"
| "last_po";
const COLUMN_COUNT = 13;
const fmtNum = (v: number | null | undefined, decimals = 0) => {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const fmtDate = (v: string | null | undefined) => {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
const fmtDateLong = (v: string | null | undefined) => {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
};
const daysSince = (v: string | null | undefined) => {
if (!v) return null;
const d = new Date(v);
if (Number.isNaN(d.getTime())) return null;
return Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24));
};
function ProductHistory({
pid,
supplierId,
windowDays,
}: {
pid: number;
supplierId: number;
windowDays: number;
}) {
const { data, isFetching, error } = useQuery<HistoryResponse>({
queryKey: ["repeat-orders-history", pid, supplierId, windowDays],
queryFn: async () => {
const params = new URLSearchParams({
supplierId: String(supplierId),
windowDays: String(windowDays),
});
const res = await fetch(
`/api/repeat-orders/${pid}/history?${params.toString()}`
);
if (!res.ok) throw new Error("Failed to load history");
return res.json();
},
staleTime: 60 * 1000,
});
if (isFetching && !data) {
return (
<div className="py-6 text-center text-muted-foreground text-xs">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
);
}
if (error) {
return (
<div className="py-4 text-center text-destructive text-xs">
{(error as Error).message}
</div>
);
}
if (!data) return null;
return (
<div className="grid gap-6 p-4 bg-muted/30 md:grid-cols-2">
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Purchase orders ({data.purchaseOrders.length})
</div>
{data.purchaseOrders.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
No PO lines in window.
</div>
) : (
<div className="rounded border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-8 text-xs">Date</TableHead>
<TableHead className="h-8 text-xs">PO</TableHead>
<TableHead className="h-8 text-xs text-right">Qty</TableHead>
<TableHead className="h-8 text-xs text-right">Cost</TableHead>
<TableHead className="h-8 text-xs">Status</TableHead>
<TableHead className="h-8 text-xs">Expected</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.purchaseOrders.map((po) => (
<TableRow key={`${po.poId}-${po.date}`}>
<TableCell className="py-1.5 text-xs whitespace-nowrap">
{fmtDateLong(po.date)}
</TableCell>
<TableCell className="py-1.5 text-xs font-mono">
{po.poId}
</TableCell>
<TableCell className="py-1.5 text-xs text-right tabular-nums">
{po.ordered}
</TableCell>
<TableCell className="py-1.5 text-xs text-right tabular-nums">
{fmtNum(po.costPrice, 2)}
</TableCell>
<TableCell className="py-1.5 text-xs">
<Badge
variant={
po.status === "canceled"
? "outline"
: po.status === "done"
? "secondary"
: "default"
}
className="text-[10px] px-1.5 py-0 font-normal"
>
{po.status}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{fmtDate(po.expectedDate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Receivings ({data.receivings.length})
</div>
{data.receivings.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
No receivings in window.
</div>
) : (
<div className="rounded border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-8 text-xs">Date</TableHead>
<TableHead className="h-8 text-xs">Recv</TableHead>
<TableHead className="h-8 text-xs text-right">Qty</TableHead>
<TableHead className="h-8 text-xs text-right">Cost</TableHead>
<TableHead className="h-8 text-xs">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.receivings.map((rc) => (
<TableRow key={`${rc.receivingId}-${rc.receivedDate}`}>
<TableCell className="py-1.5 text-xs whitespace-nowrap">
{fmtDateLong(rc.receivedDate)}
</TableCell>
<TableCell className="py-1.5 text-xs font-mono">
{rc.receivingId}
</TableCell>
<TableCell className="py-1.5 text-xs text-right tabular-nums">
{rc.qtyEach}
</TableCell>
<TableCell className="py-1.5 text-xs text-right tabular-nums">
{fmtNum(rc.costEach, 2)}
</TableCell>
<TableCell className="py-1.5 text-xs">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 font-normal"
>
{rc.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}
export default function RepeatOrders() {
const [supplierId, setSupplierId] = useState<number>(DEFAULT_SUPPLIER_ID);
const [windowDays, setWindowDays] = useState<number>(30);
const [minPoCount, setMinPoCount] = useState<number>(3);
const [maxAvgQty, setMaxAvgQty] = useState<number>(10);
const [sortKey, setSortKey] = useState<SortKey>("po_count");
const [search, setSearch] = useState<string>("");
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const toggleRow = (pid: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(pid)) next.delete(pid);
else next.add(pid);
return next;
});
};
const { data: suppliersData } = useQuery<{ suppliers: Supplier[] }>({
queryKey: ["repeat-orders-suppliers"],
queryFn: async () => {
const res = await fetch(`/api/repeat-orders/suppliers?windowDays=90`);
if (!res.ok) throw new Error("Failed to load suppliers");
return res.json();
},
staleTime: 5 * 60 * 1000,
});
const { data, isFetching, error } = useQuery<RepeatOrdersResponse>({
queryKey: ["repeat-orders", supplierId, windowDays, minPoCount, maxAvgQty],
queryFn: async () => {
const params = new URLSearchParams({
supplierId: String(supplierId),
windowDays: String(windowDays),
minPoCount: String(minPoCount),
maxAvgQty: String(maxAvgQty),
});
const res = await fetch(`/api/repeat-orders?${params.toString()}`);
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload.error || "Failed to load repeat orders");
}
return res.json();
},
staleTime: 60 * 1000,
});
const sortedResults = useMemo(() => {
if (!data?.results) return [];
const filtered = search.trim()
? data.results.filter((r) => {
const q = search.toLowerCase();
return (
(r.title || "").toLowerCase().includes(q) ||
(r.sku || "").toLowerCase().includes(q) ||
(r.brand || "").toLowerCase().includes(q)
);
})
: data.results;
const sorted = [...filtered];
sorted.sort((a, b) => {
switch (sortKey) {
case "po_count":
return (
b.poLineCount - a.poLineCount ||
(b.sales30d || 0) - (a.sales30d || 0)
);
case "repeat_score":
return b.repeatScore - a.repeatScore;
case "avg_qty":
return a.poAvgQty - b.poAvgQty;
case "sales":
return (b.sales30d || 0) - (a.sales30d || 0);
case "velocity":
return (b.velocity || 0) - (a.velocity || 0);
case "cover":
return (a.coverDays ?? Infinity) - (b.coverDays ?? Infinity);
case "notions":
return (b.notionsInvCount ?? 0) - (a.notionsInvCount ?? 0);
case "last_po":
return (
new Date(b.lastPoDate || 0).getTime() -
new Date(a.lastPoDate || 0).getTime()
);
default:
return 0;
}
});
return sorted;
}, [data?.results, sortKey, search]);
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">Repeat Order Opportunities</h1>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-6">
<div className="flex flex-col gap-1.5 md:col-span-1">
<Label htmlFor="supplier" className="h-5 leading-5">
Supplier
</Label>
<Select
value={String(supplierId)}
onValueChange={(v) => setSupplierId(Number(v))}
>
<SelectTrigger id="supplier" className="h-9">
<SelectValue placeholder="Select supplier" />
</SelectTrigger>
<SelectContent>
{(suppliersData?.suppliers || []).map((s) => (
<SelectItem key={s.supplierId} value={String(s.supplierId)}>
{s.vendorName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="window" className="h-5 leading-5">
Window
</Label>
<Select
value={String(windowDays)}
onValueChange={(v) => setWindowDays(Number(v))}
>
<SelectTrigger id="window" className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="60">Last 60 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="minPo" className="h-5 leading-5">
Min POs
</Label>
<Input
id="minPo"
type="number"
min={2}
max={50}
value={minPoCount}
onChange={(e) => setMinPoCount(Number(e.target.value) || 3)}
className="h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxAvg" className="h-5 leading-5">
Max avg qty
</Label>
<Input
id="maxAvg"
type="number"
min={1}
max={1000}
value={maxAvgQty}
onChange={(e) => setMaxAvgQty(Number(e.target.value) || 10)}
className="h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sort" className="h-5 leading-5">
Sort by
</Label>
<Select
value={sortKey}
onValueChange={(v) => setSortKey(v as SortKey)}
>
<SelectTrigger id="sort" className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="po_count">PO count</SelectItem>
<SelectItem value="repeat_score">Repeat score</SelectItem>
<SelectItem value="avg_qty">Smallest avg qty</SelectItem>
<SelectItem value="sales">30d sales</SelectItem>
<SelectItem value="velocity">Velocity</SelectItem>
<SelectItem value="cover">Days of cover</SelectItem>
<SelectItem value="notions">Notions stock</SelectItem>
<SelectItem value="last_po">Most recent PO</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="search" className="h-5 leading-5">
Search
</Label>
<Input
id="search"
placeholder="Title, SKU, brand…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9"
/>
</div>
</div>
</CardContent>
</Card>
{/* Summary */}
{data && (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Matching pattern</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{fmtNum(data.matchSummary.matchCount)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Small POs in window</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{fmtNum(data.matchSummary.totalSmallPoLines)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Combined 30d sales</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{fmtNum(data.matchSummary.totalSales30d)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Suggested consolidation</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{fmtNum(data.matchSummary.totalSuggestedUnits)}
</CardTitle>
</CardHeader>
</Card>
</div>
)}
{/* Results */}
<Card>
<CardContent className="pt-6">
{isFetching && !data ? (
<div className="py-16 text-center text-muted-foreground">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="py-10 text-center text-sm text-destructive">
{(error as Error).message}
</div>
) : !data?.results.length ? (
<div className="py-12 text-center text-muted-foreground space-y-3">
<PackageOpen className="mx-auto h-10 w-10" />
<div className="text-sm">
No products matched. Try lowering the Min POs threshold or
widening the window.
</div>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead className="min-w-[260px]">Product</TableHead>
<TableHead className="text-right">POs</TableHead>
<TableHead className="text-right">Avg qty</TableHead>
<TableHead className="text-right">Ordered</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Notions</TableHead>
<TableHead className="text-right">On order</TableHead>
<TableHead className="text-right">Cover</TableHead>
<TableHead className="text-right">30d sales</TableHead>
<TableHead className="text-right">Forecast 30d</TableHead>
<TableHead className="text-right">Suggested</TableHead>
<TableHead className="text-right">Last PO</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedResults.map((row) => {
const stale = daysSince(row.lastPoDate);
const urgentCover =
row.coverDays !== null && row.coverDays < 7;
const smallAvg = row.poAvgQty <= 2;
const isExpanded = expanded.has(row.pid);
return (
<Fragment key={row.pid}>
<TableRow
className="cursor-pointer hover:bg-muted/40"
onClick={() => toggleRow(row.pid)}
>
<TableCell className="w-8 py-2 align-middle">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
<TableCell className="py-2 align-middle">
<div className="flex items-center gap-3">
{row.imageUrl ? (
<img
src={row.imageUrl}
alt=""
className="h-10 w-10 rounded object-cover bg-muted shrink-0"
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0">
<PackageOpen className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0">
<a
href={`https://backend.acherryontop.com/product/${row.pid}`}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-medium text-primary hover:underline flex items-center gap-1 group"
>
<span className="truncate max-w-[280px]">
{row.title}
</span>
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
</a>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{row.sku}</span>
{row.brand && (
<>
<span></span>
<span className="truncate max-w-[140px]">
{row.brand}
</span>
</>
)}
{row.lifecyclePhase && (
<span
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
style={{
backgroundColor: getPhaseColor(
row.lifecyclePhase
),
}}
>
{getPhaseLabel(row.lifecyclePhase)}
</span>
)}
</div>
</div>
</div>
</TableCell>
<TableCell className="py-2 align-middle text-right font-medium tabular-nums">
{row.poLineCount}
{row.poOpenLineCount > 0 && (
<div className="text-[10px] text-muted-foreground font-normal">
{row.poOpenLineCount} open
</div>
)}
</TableCell>
<TableCell
className={cn(
"py-2 align-middle text-right tabular-nums",
smallAvg && "text-amber-600 font-medium"
)}
>
{fmtNum(row.poAvgQty, row.poAvgQty < 10 ? 1 : 0)}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums">
{fmtNum(row.poTotalUnits)}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums text-muted-foreground">
{fmtNum(row.recvTotalUnits)}
</TableCell>
<TableCell
className={cn(
"py-2 align-middle text-right tabular-nums",
row.currentStock === 0 && "text-destructive"
)}
>
{fmtNum(row.currentStock)}
</TableCell>
<TableCell
className={cn(
"py-2 align-middle text-right tabular-nums",
(row.notionsInvCount ?? 0) === 0
? "text-muted-foreground"
: (row.notionsInvCount ?? 0) >= 50
? "text-emerald-600 font-medium"
: ""
)}
>
{fmtNum(row.notionsInvCount)}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums text-muted-foreground">
{fmtNum(row.onOrderQty)}
</TableCell>
<TableCell
className={cn(
"py-2 align-middle text-right tabular-nums",
urgentCover && "text-destructive font-medium"
)}
>
{row.coverDays !== null
? `${fmtNum(row.coverDays, 1)}d`
: "—"}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums">
{fmtNum(row.sales30d)}
{row.velocity > 0 && (
<div className="text-[10px] text-muted-foreground">
{fmtNum(row.velocity, 2)}/day
</div>
)}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums">
{fmtNum(row.forecast30d, 0)}
</TableCell>
<TableCell className="py-2 align-middle text-right tabular-nums font-medium">
{row.replenishmentUnits &&
row.replenishmentUnits > 0 ? (
<Badge
variant="secondary"
className="tabular-nums"
>
{fmtNum(row.replenishmentUnits)}
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="py-2 align-middle text-right text-xs text-muted-foreground whitespace-nowrap">
{fmtDate(row.lastPoDate)}
{stale !== null && (
<div className="text-[10px]">
{stale === 0 ? "today" : `${stale}d ago`}
</div>
)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={COLUMN_COUNT + 1}
className="p-0"
>
<ProductHistory
pid={row.pid}
supplierId={supplierId}
windowDays={windowDays}
/>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+734
View File
@@ -0,0 +1,734 @@
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search, Loader2, PackageOpen, Copy, Check, ChevronsUpDown, X } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
type NumericAggregate = {
count: number;
sample_size: number;
avg?: number;
median?: number;
min?: number;
max?: number;
stddev?: number;
mode?: number | null;
mode_count?: number;
recent_mode?: number | null;
recent_mode_count?: number;
recent_window?: number;
trending?: boolean;
distribution: { value: number; count: number }[];
};
type CategoricalEntry = { value: string; count: number };
type DescriptionAggregate = {
duplicates: { value: string; count: number }[];
samples: { value: string; title: string; pid: number; sku: string }[];
};
type ProductRow = {
pid: number;
title: string;
sku: string;
brand: string | null;
vendor: string | null;
artist: string | null;
country_of_origin: string | null;
harmonized_tariff_code: string | null;
description: string | null;
categories: string | null;
cost_price: number | null;
regular_price: number | null;
moq: number | null;
weight: number | null;
length: number | null;
width: number | null;
height: number | null;
created_at: string | null;
};
type SpecLookupResponse = {
company: string;
term: string;
total: number;
truncated: boolean;
products: ProductRow[];
aggregates: {
numeric: Record<string, NumericAggregate>;
categorical: Record<string, CategoricalEntry[]>;
categories: CategoricalEntry[];
themes: CategoricalEntry[];
description: DescriptionAggregate;
};
};
type NumericFieldDef = {
key: keyof SpecLookupResponse["aggregates"]["numeric"] | string;
label: string;
format: (n: number) => string;
};
const NUMERIC_FIELDS: NumericFieldDef[] = [
{ key: "moq", label: "Min Qty", format: (n) => formatInt(n) },
{ key: "cost_price", label: "Wholesale", format: (n) => formatCurrency(n) },
{ key: "regular_price", label: "MSRP", format: (n) => formatCurrency(n) },
{ key: "weight", label: "Weight (oz)", format: (n) => `${formatNumber(n)} oz` },
{ key: "length", label: "Length (in)", format: (n) => `${formatNumber(n)} in` },
{ key: "width", label: "Width (in)", format: (n) => `${formatNumber(n)} in` },
{ key: "height", label: "Height (in)", format: (n) => `${formatNumber(n)} in` },
];
const CATEGORICAL_FIELDS: { key: string; label: string }[] = [
{ key: "artist", label: "Artist" },
{ key: "country_of_origin", label: "COO" },
{ key: "harmonized_tariff_code", label: "HTS Code" },
];
function formatNumber(n: number): string {
if (Math.abs(n) >= 100) return n.toFixed(1);
if (Math.abs(n) >= 1) return n.toFixed(2);
return n.toFixed(3);
}
function formatInt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(2);
}
function formatCurrency(n: number): string {
return `$${n.toFixed(2)}`;
}
export default function SpecLookup() {
const { toast } = useToast();
const [company, setCompany] = useState("");
const [term, setTerm] = useState("");
const [companyOpen, setCompanyOpen] = useState(false);
const [submitted, setSubmitted] = useState<{ company: string; term: string } | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const copyTimerRef = useRef<number | null>(null);
const { data: brandsData } = useQuery<{ brands: string[] }>({
queryKey: ["spec-lookup-brands"],
queryFn: async () => {
const response = await fetch(`/api/brands-aggregate/filter-options`);
if (!response.ok) throw new Error("Failed to load brands");
return response.json();
},
staleTime: 10 * 60 * 1000,
});
const brands = brandsData?.brands ?? [];
const queryKey = useMemo(
() => ["spec-lookup", submitted?.company ?? "", submitted?.term ?? ""],
[submitted],
);
const { data, error, isFetching, isFetched, refetch } = useQuery<SpecLookupResponse>({
queryKey,
enabled: false,
queryFn: async () => {
const params = new URLSearchParams();
if (submitted?.company) params.set("company", submitted.company);
if (submitted?.term) params.set("term", submitted.term);
const response = await fetch(`/api/spec-lookup?${params.toString()}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload.error === "string" ? payload.error : "Failed to fetch spec lookup";
throw new Error(message);
}
return payload as SpecLookupResponse;
},
staleTime: 2 * 60 * 1000,
});
useEffect(() => {
if (submitted) void refetch();
}, [submitted, refetch]);
useEffect(() => {
return () => {
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current);
};
}, []);
useEffect(() => {
if (error instanceof Error) {
toast({ title: "Search failed", description: error.message, variant: "destructive" });
}
}, [error, toast]);
const handleSubmit = (event?: FormEvent) => {
event?.preventDefault();
const trimmedCompany = company.trim();
const trimmedTerm = term.trim();
if (!trimmedCompany && !trimmedTerm) {
toast({ title: "Enter a company or product type" });
return;
}
if (submitted?.company === trimmedCompany && submitted?.term === trimmedTerm) {
void refetch();
} else {
setSubmitted({ company: trimmedCompany, term: trimmedTerm });
}
};
const handleCopy = async (event: MouseEvent<HTMLButtonElement>, key: string, value: string) => {
event.preventDefault();
event.stopPropagation();
if (!navigator?.clipboard) {
toast({ title: "Clipboard unavailable", variant: "destructive" });
return;
}
try {
await navigator.clipboard.writeText(value);
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current);
setCopied(key);
copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200);
} catch (err) {
toast({
title: "Copy failed",
description: err instanceof Error ? err.message : "Unable to copy",
variant: "destructive",
});
}
};
const renderEmpty = (label: string) => (
<div className="text-xs italic text-muted-foreground">No {label} data in matches</div>
);
const renderNumericCard = (def: NumericFieldDef) => {
const agg = data?.aggregates.numeric[def.key];
const hasData = agg && agg.count > 0;
const modeKey = `num:${def.key}`;
// When we've detected a recent shift, the recommended (headline) value becomes the recent mode
// — but the overall mode and full distribution are still shown below for verification.
const recommendedValue = !hasData
? null
: agg.trending && agg.recent_mode != null
? agg.recent_mode
: agg.mode;
const formattedRecommended =
hasData && recommendedValue != null ? def.format(recommendedValue) : "—";
return (
<Card key={def.key}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<CardDescription>{def.label}</CardDescription>
{hasData && agg.trending && (
<Badge variant="default" className="h-4 px-1.5 text-[9px] font-medium uppercase">
Recent shift
</Badge>
)}
</div>
{hasData && recommendedValue != null && (
<Button
type="button"
size="icon"
variant={copied === modeKey ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label={`Copy ${def.label}`}
onClick={(e) => handleCopy(e, modeKey, String(recommendedValue))}
>
{copied === modeKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
<CardTitle className="text-2xl">{formattedRecommended}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
{hasData ? (
<>
{agg.trending && agg.recent_mode != null ? (
<>
<div>
Recent: <span className="text-foreground font-medium">{def.format(agg.recent_mode)}</span> ({agg.recent_mode_count} of last {agg.recent_window})
</div>
<div>
Overall mode was <span className="text-foreground">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
</div>
</>
) : (
<div>
Mode: <span className="text-foreground font-medium">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
</div>
)}
<div>
Median: <span className="text-foreground">{def.format(agg.median!)}</span>
{" · "}
Avg: <span className="text-foreground">{def.format(agg.avg!)}</span>
</div>
<div>
Range: {def.format(agg.min!)} {def.format(agg.max!)}
</div>
<div>
{agg.count} of {agg.sample_size} products have this set
</div>
{agg.distribution.length > 1 && (
<Accordion type="single" collapsible>
<AccordionItem value="dist" className="border-none">
<AccordionTrigger className="py-1 text-xs">Distribution ({agg.distribution.length} distinct)</AccordionTrigger>
<AccordionContent>
<div className="max-h-56 overflow-auto pr-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-7">Value</TableHead>
<TableHead className="h-7 text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agg.distribution.map((d) => (
<TableRow key={`${def.key}-${d.value}`}>
<TableCell className="py-1 font-mono">{def.format(d.value)}</TableCell>
<TableCell className="py-1 text-right">{d.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</>
) : (
renderEmpty(def.label)
)}
</CardContent>
</Card>
);
};
const renderCategoricalCard = (key: string, label: string) => {
const entries = data?.aggregates.categorical[key] ?? [];
const top = entries[0];
const cardKey = `cat:${key}`;
return (
<Card key={key}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardDescription>{label}</CardDescription>
{top && (
<Button
type="button"
size="icon"
variant={copied === cardKey ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label={`Copy top ${label}`}
onClick={(e) => handleCopy(e, cardKey, top.value)}
>
{copied === cardKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
<CardTitle className="text-base break-words">{top ? top.value : "—"}</CardTitle>
{top && (
<div className="text-xs text-muted-foreground">
{top.count} of {data?.total ?? 0} products
</div>
)}
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{entries.length === 0 ? (
renderEmpty(label)
) : entries.length === 1 ? (
<div>Only one distinct value across matches.</div>
) : (
<Accordion type="single" collapsible>
<AccordionItem value="all" className="border-none">
<AccordionTrigger className="py-1 text-xs">All values ({entries.length})</AccordionTrigger>
<AccordionContent>
<div className="max-h-56 overflow-auto pr-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-7">Value</TableHead>
<TableHead className="h-7 text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={`${key}-${e.value}`}>
<TableCell className="py-1 break-all">{e.value}</TableCell>
<TableCell className="py-1 text-right">{e.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
);
};
const renderTokenCard = (
title: string,
emptyLabel: string,
entries: CategoricalEntry[],
) => {
const dominantThreshold = Math.max(2, (data?.total ?? 0) * 0.5);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
renderEmpty(emptyLabel)
) : (
<div className="flex flex-wrap gap-2">
{entries.map((e) => (
<Badge
key={e.value}
variant={e.count >= dominantThreshold ? "default" : "outline"}
className="font-normal"
>
{e.value}
<span className="ml-1.5 text-[10px] opacity-70">×{e.count}</span>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
};
const renderDescriptionCard = () => {
const desc = data?.aggregates.description;
if (!desc) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Description</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Used More Than Once {desc.duplicates.length > 0 && `(${desc.duplicates.length})`}
</div>
{desc.duplicates.length === 0 ? (
<div className="text-xs italic text-muted-foreground">No description was used more than once.</div>
) : (
<div className="max-h-96 space-y-2 overflow-auto pr-2">
{desc.duplicates.map((d, i) => (
<div key={`dup-${i}`} className="rounded-md border p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<Badge variant="default">Used {d.count}× </Badge>
<Button
type="button"
size="icon"
variant={copied === `dup:${i}` ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label="Copy description"
onClick={(e) => handleCopy(e, `dup:${i}`, d.value)}
>
{copied === `dup:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="whitespace-pre-wrap text-sm">{d.value}</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent samples
</div>
{desc.samples.length === 0 ? (
<div className="text-xs italic text-muted-foreground">No descriptions in matches.</div>
) : (
<div className="max-h-96 space-y-2 overflow-auto pr-2">
{desc.samples.map((s, i) => (
<div key={`sample-${s.pid}`} className="rounded-md border p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<a
href={`https://backend.acherryontop.com/product/${s.pid}`}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-primary hover:underline"
>
{s.title}
</a>
<Button
type="button"
size="icon"
variant={copied === `sample:${i}` ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label="Copy description"
onClick={(e) => handleCopy(e, `sample:${i}`, s.value)}
>
{copied === `sample:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="whitespace-pre-wrap text-sm">{s.value}</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
};
const renderProductsTable = () => {
if (!data || data.products.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Matched products ({data.total})</CardTitle>
<CardDescription>
{data.truncated ? "Showing first 500 matches (newest first)." : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="max-h-96 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">MSRP</TableHead>
<TableHead className="text-right">Wt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.products.map((p) => (
<TableRow key={p.pid}>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${p.pid}`}
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
{p.title}
</a>
</TableCell>
<TableCell className="whitespace-nowrap">{p.sku}</TableCell>
<TableCell>{p.brand || "—"}</TableCell>
<TableCell className="text-right">{p.cost_price != null ? formatCurrency(p.cost_price) : "—"}</TableCell>
<TableCell className="text-right">{p.regular_price != null ? formatCurrency(p.regular_price) : "—"}</TableCell>
<TableCell className="text-right">{p.weight != null ? formatNumber(p.weight) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold">Spec Lookup</h1>
<p className="text-sm text-muted-foreground">
Use this to compare existing values across similar products when setting up a new product.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Search</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-1">
<Label htmlFor="pd-company">Company</Label>
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<Button
id="pd-company"
type="button"
variant="outline"
role="combobox"
aria-expanded={companyOpen}
className={cn("w-full justify-between font-normal", !company && "text-muted-foreground")}
>
<span className="truncate">{company || "Select company..."}</span>
<div className="flex shrink-0 items-center gap-1">
{company && (
<span
role="button"
tabIndex={0}
aria-label="Clear company"
className="rounded hover:bg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCompany("");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setCompany("");
}
}}
>
<X className="h-3 w-3 opacity-60" />
</span>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList>
<CommandEmpty>No matching company.</CommandEmpty>
<CommandGroup>
{brands.map((b) => (
<CommandItem
key={b}
value={b}
onSelect={(value) => {
setCompany(value === company ? "" : value);
setCompanyOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", company === b ? "opacity-100" : "opacity-0")} />
{b}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label htmlFor="pd-term">Product Type</Label>
<Input
id="pd-term"
placeholder="Paper pad, washi, stickers, etc."
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isFetching}>
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-2">Search</span>
</Button>
{isFetched && (
<Button
type="button"
variant="outline"
disabled={isFetching}
onClick={() => {
setCompany("");
setTerm("");
setSubmitted(null);
}}
>
Clear
</Button>
)}
</div>
</form>
</CardContent>
</Card>
{isFetched && data && data.total === 0 && (
<Card>
<CardContent className="space-y-3 py-8 text-center text-muted-foreground">
<PackageOpen className="mx-auto h-10 w-10" />
<div>No products matched.</div>
</CardContent>
</Card>
)}
{isFetched && data && data.total > 0 && (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardDescription>Matched products</CardDescription>
<CardTitle className="text-3xl">{data.total}</CardTitle>
{data.truncated && (
<div className="text-xs text-muted-foreground">capped at 500 refine search to narrow</div>
)}
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Company filter</CardDescription>
<CardTitle className="text-base break-words">{data.company || "(any)"}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Product type</CardDescription>
<CardTitle className="text-base break-words">{data.term || "(any)"}</CardTitle>
</CardHeader>
</Card>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Numeric fields</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{NUMERIC_FIELDS.map(renderNumericCard)}
</div>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Categorical fields</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{CATEGORICAL_FIELDS.map((f) => renderCategoricalCard(f.key, f.label))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{renderTokenCard(
"Categories",
"category",
data.aggregates.categories,
)}
{renderTokenCard(
"Themes",
"theme",
data.aggregates.themes ?? [],
)}
</div>
{renderDescriptionCard()}
{renderProductsTable()}
</>
)}
</div>
);
}
-506
View File
@@ -1,506 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { motion } from "framer-motion";
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
// Matches backend COLUMN_MAP keys for sorting
type VendorSortableColumns =
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d'
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
interface VendorMetric {
vendor_id: string | number;
vendor_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
on_order_units: number;
on_order_cost: string | number;
po_count_365d: number;
avg_lead_time_days: number | null;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
// Growth metrics
sales_growth_30d_vs_prev: string | number | null;
revenue_growth_30d_vs_prev: string | number | null;
// New fields added by vendorsAggregate
status: string;
vendor_status: string;
cost_metrics_30d: {
avg_unit_cost: number;
total_spend: number;
order_count: number;
};
// Camel case versions
vendorId: string | number;
vendorName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
onOrderUnits: number;
onOrderCost: string | number;
poCount_365d: number;
avgLeadTimeDays: number | null;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
salesGrowth30dVsPrev: string | number | null;
revenueGrowth30dVsPrev: string | number | null;
}
// Define response type to avoid type errors
interface VendorResponse {
vendors: VendorMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
interface VendorFilterOptions {
statuses: string[];
}
interface VendorStats {
totalVendors: number;
activeVendors: number;
totalActiveProducts: number;
totalValue: number;
totalOnOrderValue: number;
avgLeadTime: number;
}
interface VendorFilters {
search: string;
status: string;
showInactive: boolean;
}
const ITEMS_PER_PAGE = 50;
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
const formatDays = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)} days`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)} days`;
};
// Growth formatting with color coding
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
if (value == null) return <span className="text-muted-foreground">N/A</span>;
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
return <span className={colorClass}>{formatted}</span>;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'discontinued':
return 'destructive';
default:
return 'outline';
}
};
export function Vendors() {
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<VendorFilters>({
search: "",
status: "all",
showInactive: false, // Default to hiding vendors with 0 active products
});
// --- Data Fetching ---
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
if (filters.search) {
params.set('vendorName_ilike', filters.search); // Filter by name
}
if (filters.status !== 'all') {
params.set('status', filters.status); // Filter by status
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
}
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
queryKey: ['vendors', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
return response.json();
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
queryKey: ['vendorsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendor stats");
return response.json();
},
});
// Fetch filter options
const { data: filterOptions } = useQuery<VendorFilterOptions, Error>({
queryKey: ['vendorsFilterOptions'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch filter options");
return response.json();
},
});
// --- Event Handlers ---
const handleSort = useCallback((column: VendorSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
const handleFilterChange = useCallback((filterName: keyof VendorFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const vendors = listData?.vendors ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
<p className="text-xs text-muted-foreground">
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
`${formatNumber(statsData?.activeVendors)} active`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
<p className="text-xs text-muted-foreground">
Current cost value
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Value On Order</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalOnOrderValue)}</div>}
<p className="text-xs text-muted-foreground">
Total cost on open POs
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatDays(statsData?.avgLeadTime)}</div>}
<p className="text-xs text-muted-foreground">
Average across vendors
</p>
</CardContent>
</Card>
</motion.div>
{/* Filter Controls */}
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
<Input
placeholder="Search vendors..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full sm:w-[250px]"
/>
<Select
value={filters.status}
onValueChange={(value) => handleFilterChange('status', value)}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{filterOptions?.statuses?.map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2 ml-auto">
<Switch
id="show-inactive-vendors"
checked={filters.showInactive}
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
/>
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("vendorName")} className="cursor-pointer">Vendor</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Value</TableHead>
<TableHead onClick={() => handleSort("onOrderUnits")} className="cursor-pointer text-right">On Order (Units)</TableHead>
<TableHead onClick={() => handleSort("onOrderCost")} className="cursor-pointer text-right">On Order (Cost)</TableHead>
<TableHead onClick={() => handleSort("avgLeadTimeDays")} className="cursor-pointer text-right">Avg Lead Time</TableHead>
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
<TableRow key={`skel-${i}`}>
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-8 text-destructive">
Error loading vendors: {listError.message}
</TableCell>
</TableRow>
) : vendors.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
No vendors found matching your criteria.
</TableCell>
</TableRow>
) : (
vendors.map((vendor: VendorMetric) => (
<TableRow key={vendor.vendor_id} className={vendor.active_product_count === 0 ? "opacity-60" : ""}>
<TableCell className="font-medium">{vendor.vendor_name}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.active_product_count || vendor.activeProductCount)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.on_order_units || vendor.onOrderUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.on_order_cost as number)}</TableCell>
<TableCell className="text-right">{formatDays(vendor.avg_lead_time_days || vendor.avgLeadTimeDays)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.revenue_30d as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.sales_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.revenue_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">
<Badge variant={getStatusVariant(vendor.status)}>
{vendor.status || 'Unknown'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<div className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
aria-disabled={pagination.currentPage >= totalPages}
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</motion.div>
);
}
export default Vendors;
+208
View File
@@ -12,6 +12,36 @@ export interface SubmitNewProductsResponse {
error?: unknown; error?: unknown;
} }
export interface PoLineItemSubmit {
pid: number;
qty: number;
}
export interface SubmitNewPurchaseOrderArgs {
supplierId: number | string;
items: PoLineItemSubmit[];
}
export interface SubmitNewPurchaseOrderResponse {
success: boolean;
poId?: number;
message?: string;
error?: unknown;
raw?: unknown;
}
export interface AddProductsToPurchaseOrderArgs {
poId: number | string;
items: PoLineItemSubmit[];
}
export interface AddProductsToPurchaseOrderResponse {
success: boolean;
message?: string;
error?: unknown;
raw?: unknown;
}
export interface CreateProductCategoryArgs { export interface CreateProductCategoryArgs {
masterCatId: string | number; masterCatId: string | number;
name: string; name: string;
@@ -204,3 +234,181 @@ export async function createProductCategory({
return normalizedResponse; return normalizedResponse;
} }
/**
* Creates a new purchase order on the legacy PHP backend.
*
* Mirrors the auth/serialization pattern of `submitNewProducts`:
* - URL-encoded body
* - cookie-based auth (`credentials: 'include'`)
* - HTML response → "Backend authentication required" guard
*
* The endpoint accepts a JSON array of `{pid, qty}` items in the `items` body
* field, with the supplier ID as a path parameter. Returns `{po_id: number}`
* on success. Designed to be called from anywhere in the app — not just the
* Create PO page.
*/
export async function submitNewPurchaseOrder({
supplierId,
items,
}: SubmitNewPurchaseOrderArgs): Promise<SubmitNewPurchaseOrderResponse> {
if (!supplierId) {
throw new Error("supplierId is required");
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error("At least one item is required");
}
const cleanItems = items
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
if (cleanItems.length === 0) {
throw new Error("No valid items to submit");
}
const targetUrl = `/apiv2/po/new/${encodeURIComponent(String(supplierId))}`;
const payload = new URLSearchParams();
payload.append("items", JSON.stringify(cleanItems));
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
credentials: "include",
});
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const record = parsed as Record<string, unknown>;
// The PHP /apiv2 backend wraps successful responses in an envelope:
// {"success": true, "data": { "po_id": "32705" }}
// po_id can appear either at the top level (legacy shape) OR nested
// inside `data` (current shape), and the value is sometimes a string
// (e.g. "32705") rather than a number. We look in both locations and
// coerce to Number to handle both cases.
const data =
record.data && typeof record.data === "object"
? (record.data as Record<string, unknown>)
: {};
const rawPoId = record.po_id ?? record.poId ?? data.po_id ?? data.poId;
const poIdNum = typeof rawPoId === "number" ? rawPoId : Number(rawPoId);
const hasValidPoId = Number.isFinite(poIdNum) && poIdNum > 0;
// Trust the backend's own `success` flag when it's present — it's the
// authoritative signal. Fall back to "HTTP OK + valid po_id" for older
// response shapes that don't include the flag.
const backendSuccess =
record.success === true ||
record.success === "true" ||
record.success === 1;
const success = response.ok && hasValidPoId && (backendSuccess || record.success === undefined);
return {
success,
poId: success ? poIdNum : undefined,
message: typeof record.message === "string" ? record.message : undefined,
error: record.error ?? record.errors ?? record.error_msg,
raw: parsed,
};
}
/**
* Adds line items to an existing purchase order on the legacy PHP backend.
*
* Mirrors `submitNewPurchaseOrder` exactly except the URL takes a po_id path
* param and we don't expect (or use) a po_id in the response. Same
* URL-encoded body shape, same cookie-auth flow, same HTML-response guard.
*/
export async function addProductsToPurchaseOrder({
poId,
items,
}: AddProductsToPurchaseOrderArgs): Promise<AddProductsToPurchaseOrderResponse> {
const poIdNum = Number(poId);
if (!Number.isInteger(poIdNum) || poIdNum <= 0) {
throw new Error("A valid PO number is required");
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error("At least one item is required");
}
const cleanItems = items
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
if (cleanItems.length === 0) {
throw new Error("No valid items to submit");
}
const targetUrl = `/apiv2/po/add_products/${encodeURIComponent(String(poIdNum))}`;
const payload = new URLSearchParams();
payload.append("items", JSON.stringify(cleanItems));
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
credentials: "include",
});
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const record = parsed as Record<string, unknown>;
const backendSuccess =
record.success === true ||
record.success === "true" ||
record.success === 1;
const success = response.ok && (backendSuccess || record.success === undefined);
return {
success,
message: typeof record.message === "string" ? record.message : undefined,
error: record.error ?? record.errors ?? record.error_msg,
raw: parsed,
};
}
@@ -106,6 +106,7 @@ export interface DiscountSimulationRequest {
merchantFeePercent: number; merchantFeePercent: number;
fixedCostPerOrder: number; fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode; cogsCalculationMode: CogsCalculationMode;
applyHistoricalProductPromo: boolean;
pointsConfig: { pointsConfig: {
pointsPerDollar: number | null; pointsPerDollar: number | null;
redemptionRate: number | null; redemptionRate: number | null;
+13
View File
@@ -40,6 +40,19 @@ export const formatDate = (dateString: string | null | undefined): string => {
} }
}; };
export const formatDateShort = (dateString: string | null | undefined): string => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric'
});
} catch (e) {
return 'Invalid Date';
}
};
export const formatBoolean = (value: boolean | null | undefined): string => { export const formatBoolean = (value: boolean | null | undefined): string => {
if (value == null) return 'N/A'; if (value == null) return 'N/A';
return value ? 'Yes' : 'No'; return value ? 'Yes' : 'No';
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
if (useRsync) { if (useRsync) {
// Use rsync over SSH - much faster than sshfs copying // Use rsync over SSH - much faster than sshfs copying
const deployTarget = process.env.DEPLOY_TARGET; const deployTarget = process.env.DEPLOY_TARGET;
const targetPath = process.env.DEPLOY_PATH || '/var/www/html/inventory/inventory-server/frontend'; const targetPath = process.env.DEPLOY_PATH || '/var/www/inventory/inventory-server/frontend';
try { try {
console.log(`Deploying to ${deployTarget}:${targetPath}...`); console.log(`Deploying to ${deployTarget}:${targetPath}...`);
+1 -1
View File
@@ -4,4 +4,4 @@
umount '/Users/matt/Dev/inventory/inventory-server' umount '/Users/matt/Dev/inventory/inventory-server'
#Mount #Mount
sshfs matt@159.195.13.70:/var/www/html/inventory '/Users/matt/Dev/inventory/inventory-server/' sshfs matt@159.195.13.70:/var/www/inventory '/Users/matt/Dev/inventory/inventory-server/'