5 Commits

42 changed files with 5555 additions and 2234 deletions
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();
// 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 = [
10, 20, 30, 40, 50, 60, 70, 80, 90,
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() {
const ranges = [];
let previous = 0;
for (const bound of RANGE_BOUNDS) {
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
const key = bound.toString().padStart(5, '0');
ranges.push({
min: previous,
max: bound,
label,
label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
key,
sort: 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;
}
const RANGE_DEFINITIONS = buildRangeDefinitions();
const BUCKET_CASE = (() => {
const parts = [];
for (let i = 0; i < RANGE_BOUNDS.length; i++) {
const bound = RANGE_BOUNDS[i];
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}'`);
function bucketKeyFor(subtotal) {
for (const range of RANGE_DEFINITIONS) {
if (range.max == null) return range.key;
if (subtotal <= range.max) return range.key;
}
return FINAL_BUCKET_KEY;
}
return `CASE\n ${parts.join('\n ')}\n END`;
})();
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1
const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
const DEFAULTS = {
merchantFeePercent: 2.9,
fixedCostPerOrder: 1.5,
pointsPerDollar: 0,
pointsRedemptionRate: 0, // Will be calculated from actual data
fixedCostPerOrder: 1.25,
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
};
@@ -73,13 +70,6 @@ function formatDateForSql(dt) {
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) => {
let connection;
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) => {
const {
dateRange = {},
@@ -167,6 +339,7 @@ router.post('/simulate', async (req, res) => {
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode = 'actual',
applyHistoricalProductPromo = false,
pointsConfig = {}
} = req.body || {};
@@ -176,20 +349,15 @@ router.post('/simulate', async (req, res) => {
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US';
const rawPromoFilters = [
const promoIds = Array.from(
new Set(
[
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
];
const promoCodes = Array.from(
new Set(
rawPromoFilters
]
.map((value) => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
return '';
})
.filter((value) => value.length > 0)
@@ -199,6 +367,8 @@ router.post('/simulate', async (req, res) => {
const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
productPromo: {
type: productPromo.type || 'none',
value: Number(productPromo.value || 0),
@@ -248,300 +418,131 @@ router.post('/simulate', async (req, res) => {
connection = dbConn.connection;
release = dbConn.release;
const filteredOrdersParams = [
shipCountry,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const promoJoin = promoCodes.length > 0
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
: '';
let promoFilterClause = '';
if (promoCodes.length > 0) {
const placeholders = promoCodes.map(() => '?').join(',');
promoFilterClause = `AND od.discount_code IN (${placeholders})`;
filteredOrdersParams.push(...promoCodes);
const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
let promoExistsClause = '';
if (promoIds.length > 0) {
const placeholders = promoIds.map(() => '?').join(',');
promoExistsClause = `
AND EXISTS (
SELECT 1 FROM order_discounts od
WHERE od.order_id = o.order_id
AND od.discount_active = 1
AND od.discount_type = 10
AND od.discount_code IN (${placeholders})
)
`;
params.push(...promoIds);
}
const filteredOrdersQuery = `
const ordersQuery = `
SELECT
o.order_id,
o.order_cid,
o.summary_subtotal,
o.summary_discount_subtotal,
o.summary_shipping,
o.ship_method_rate,
o.ship_method_cost,
o.summary_points,
${BUCKET_CASE} AS bucket_key
COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
COALESCE(o.summary_shipping, 0) AS summary_shipping,
COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
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
${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 (
SELECT order_id, SUM(cogs_amount) AS total_cogs
FROM report_sales_data
WHERE action IN (1,2,3)
AND date_change BETWEEN ? AND ?
GROUP BY order_id
) AS c ON c.order_id = f.order_id
) c ON c.order_id = o.order_id
LEFT JOIN (
SELECT order_id, SUM(discount_amount) AS points_redeemed
SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id
) AS p ON p.order_id = f.order_id
GROUP BY f.bucket_key
) p ON p.order_id = o.order_id
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 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
});
}
const [orders] = await connection.execute(ordersQuery, params);
if (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({
dateRange: {
start: startDt.toISO(),
end: endDt.toISO()
},
totals: {
orders: totals.orders,
subtotal: totals.subtotal,
orders: totalOrders,
subtotal: totalSubtotal,
productDiscountRate,
pointsPerDollar,
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/payroll-metrics', require('./routes/payroll-metrics'));
app.use('/api/acot/operations-metrics', require('./routes/operations-metrics'));
app.use('/api/acot/customers', require('./routes/customers'));
// Error handling middleware
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 };
+1
View File
@@ -1237,6 +1237,7 @@ router.get('/search-products', async (req, res) => {
ELSE 6
END
`}
${isPidSearch ? '' : 'LIMIT 100'}
`;
// 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
router.get('/:id', async (req, res) => {
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;
+2
View File
@@ -28,6 +28,7 @@ const importAuditLogRouter = require('./routes/import-audit-log');
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
const newsletterRouter = require('./routes/newsletter');
const linesAggregateRouter = require('./routes/linesAggregate');
const repeatOrdersRouter = require('./routes/repeat-orders');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -140,6 +141,7 @@ async function startServer() {
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
app.use('/api/newsletter', newsletterRouter);
app.use('/api/lines-aggregate', linesAggregateRouter);
app.use('/api/repeat-orders', repeatOrdersRouter);
// Basic health check route
app.get('/health', (req, res) => {
+16 -8
View File
@@ -23,13 +23,14 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
const Forecasting = lazy(() => import('./pages/Forecasting'));
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
const Vendors = lazy(() => import('./pages/Vendors'));
const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands'));
const ProductLines = lazy(() => import('./pages/ProductLines'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Newsletter = lazy(() => import('./pages/Newsletter'));
const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -136,13 +137,6 @@ function App() {
</Suspense>
</Protected>
} />
<Route path="/vendors" element={
<Protected page="vendors">
<Suspense fallback={<PageLoading />}>
<Vendors />
</Suspense>
</Protected>
} />
<Route path="/brands" element={
<Protected page="brands">
<Suspense fallback={<PageLoading />}>
@@ -192,6 +186,20 @@ function App() {
</Suspense>
</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 */}
<Route path="/settings" element={
@@ -10,7 +10,7 @@ const PAGES = [
{ path: "/overview", permission: "access:overview" },
{ path: "/products", permission: "access:products" },
{ path: "/categories", permission: "access:categories" },
{ path: "/vendors", permission: "access:vendors" },
{ path: "/brands", permission: "access:brands" },
{ path: "/purchase-orders", permission: "access:purchase_orders" },
{ path: "/analytics", permission: "access:analytics" },
{ path: "/discount-simulator", permission: "access:discount_simulator" },
+1 -2
View File
@@ -129,8 +129,7 @@ Admin users automatically have all permissions.
| `access:overview` | Access to Overview page |
| `access:products` | Access to Products page |
| `access:categories` | Access to Categories page |
| `access:brands` | Access to Brands page |
| `access:vendors` | Access to Vendors page |
| `access:brands` | Access to Brands & Vendors page |
| `access:purchase_orders` | Access to Purchase Orders page |
| `access:analytics` | Access to Analytics page |
| `access:discount_simulator` | Access to Discount Simulator page |
@@ -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,57 @@
/**
* Post-submit success screen.
*
* Shows when the legacy backend has accepted the PO and returned a po_id.
* The single primary action is the external link to the legacy admin's PO
* editor; secondary action is "Create another" which resets the page.
*/
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle2, ExternalLink, Plus } from "lucide-react";
interface ConfirmationViewProps {
poId: number;
itemCount: number;
onCreateAnother: () => void;
}
export function ConfirmationView({
poId,
itemCount,
onCreateAnother,
}: ConfirmationViewProps) {
const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`;
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">Purchase order created</h2>
<p className="text-muted-foreground mb-6">
PO #{poId} with {itemCount} {itemCount === 1 ? "item" : "items"} has been
submitted to the backend.
</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" />
Create another
</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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { formatNumber } from "@/utils/productUtils";
import { PlusIcon, X } from "lucide-react";
@@ -43,6 +44,8 @@ interface ConfigPanelProps {
onFixedCostChange: (value: number) => void;
cogsCalculationMode: CogsCalculationMode;
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
applyHistoricalProductPromo: boolean;
onApplyHistoricalProductPromoChange: (value: boolean) => void;
pointsPerDollar: number;
redemptionRate: number;
onRedemptionRateChange: (value: number) => void;
@@ -58,6 +61,9 @@ interface ConfigPanelProps {
}
function parseNumber(value: string, fallback = 0) {
if (value.trim() === '') {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
@@ -113,6 +119,8 @@ export function ConfigPanel({
onFixedCostChange,
cogsCalculationMode,
onCogsCalculationModeChange,
applyHistoricalProductPromo,
onApplyHistoricalProductPromoChange,
pointsPerDollar,
redemptionRate,
onRedemptionRateChange,
@@ -480,6 +488,23 @@ export function ConfigPanel({
</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>
</section>
@@ -826,11 +851,11 @@ export function ConfigPanel({
<Input
className={compactNumberClass}
type="number"
step="1"
value={Math.round(redemptionRate * 100)}
step="0.01"
value={Number((redemptionRate * 100).toFixed(2))}
onChange={(event) => {
onConfigInputChange();
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100);
onRedemptionRateChange(parseNumber(event.target.value, redemptionRate * 100) / 100);
}}
onBlur={handleFieldBlur}
/>
@@ -67,12 +67,11 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
return null;
}
// Use the same format as the table - show top range value
const labels = buckets.map((bucket) => {
if (bucket.max == null) {
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)));
@@ -109,13 +108,28 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
pointBorderColor: pointColors,
pointRadius: 6,
pointHoverRadius: 8,
tension: 0.3,
tension: 0,
fill: false,
},
],
};
}, [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(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -157,8 +171,8 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
type: 'linear' as const,
display: true,
position: 'left' as const,
min: 0,
max: 50,
min: yAxisBounds.min,
max: yAxisBounds.max,
ticks: {
stepSize: 5,
callback: (value: number | string) => `${Number(value).toFixed(0)}`,
@@ -177,7 +191,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
},
},
},
}), [buckets]);
}), [buckets, yAxisBounds]);
if (isLoading && !chartData) {
return (
@@ -145,10 +145,10 @@ const rowLabels: RowConfig[] = [
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
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) {
@@ -173,7 +173,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<colgroup>
<col style={{ width: '156px' }} />
{buckets.map((bucket) => (
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
<col key={bucket.key} style={{ minWidth: '95px', width: '110px' }} />
))}
</colgroup>
<TableHeader>
@@ -57,6 +57,10 @@ interface SummaryCardProps {
onClearBaseline?: () => void;
}
function rangesMatch(a: DiscountSimulationResponse, b: DiscountSimulationResponse): boolean {
return a.dateRange.start === b.dateRange.start && a.dateRange.end === b.dateRange.end;
}
function calculateAnnualizedProfitDiff(
current: DiscountSimulationResponse,
baseline: DiscountSimulationResponse
@@ -65,6 +69,9 @@ function calculateAnnualizedProfitDiff(
const baselineTotals = baseline.totals;
if (!currentTotals || !baselineTotals) return null;
if (!Number.isFinite(currentTotals.weightedProfitAmount) || !Number.isFinite(baselineTotals.weightedProfitAmount)) {
return null;
}
// Calculate days in the current simulation period
const startDate = new Date(current.dateRange.start);
@@ -118,7 +125,9 @@ export function SummaryCard({
}
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) : '—';
// Get color for profit percentage
@@ -130,8 +139,9 @@ export function SummaryCard({
: "destructive"
: "secondary";
// Calculate annualized profit difference if baseline exists
const annualizedDiff = result && baselineResult
const baselineScopeMatches = !!(result && baselineResult && rangesMatch(result, baselineResult));
// Calculate annualized profit difference if baseline exists and scope matches
const annualizedDiff = result && baselineResult && baselineScopeMatches
? calculateAnnualizedProfitDiff(result, baselineResult)
: null;
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 */}
{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;
+68 -61
View File
@@ -7,7 +7,6 @@ import {
Tags,
PackagePlus,
ShoppingBag,
Truck,
MessageCircle,
LayoutDashboard,
Percent,
@@ -17,6 +16,8 @@ import {
PenLine,
Mail,
Layers,
Repeat,
ClipboardPlus,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -79,18 +80,6 @@ const inventoryItems = [
url: "/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",
icon: ClipboardList,
@@ -105,18 +94,12 @@ const inventoryItems = [
}
];
const toolsItems = [
const buyingItems = [
{
title: "Discount Simulator",
icon: Percent,
url: "/discount-simulator",
permission: "access:discount_simulator"
},
{
title: "HTS Lookup",
icon: FileSearch,
url: "/hts-lookup",
permission: "access:hts_lookup"
title: "Product Lines",
icon: Layers,
url: "/product-lines",
permission: "access:product_lines"
},
{
title: "Forecasting",
@@ -124,6 +107,27 @@ const toolsItems = [
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,
@@ -135,25 +139,28 @@ const toolsItems = [
icon: PenLine,
url: "/bulk-edit",
permission: "access:bulk_edit"
},
}
];
const toolsItems = [
{
title: "Newsletter",
icon: Mail,
url: "/newsletter",
permission: "access:newsletter"
}
];
const productSetupItems = [
},
{
title: "Create Products",
icon: PackagePlus,
url: "/import",
permission: "access:import"
}
];
const chatItems = [
title: "Discount Simulator",
icon: Percent,
url: "/discount-simulator",
permission: "access:discount_simulator"
},
{
title: "HTS Lookup",
icon: FileSearch,
url: "/hts-lookup",
permission: "access:hts_lookup"
},
{
title: "Chat Archive",
icon: MessageCircle,
@@ -259,6 +266,30 @@ export function AppSidebar() {
</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 */}
{hasAccessToSection(toolsItems) && (
<SidebarGroup>
@@ -271,30 +302,6 @@ export function AppSidebar() {
</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 */}
<Protected permission="access:settings" fallback={null}>
<SidebarGroup>
@@ -87,6 +87,7 @@ export function EditableInput({
<button
type="button"
onClick={() => setEditing(true)}
onFocus={() => setEditing(true)}
className="flex items-center gap-1.5 flex-1 min-w-0"
>
{label && (
@@ -50,6 +50,7 @@ export function ProductSearch({
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [fullByPid, setFullByPid] = useState<Map<number, SearchProduct>>(new Map());
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
@@ -59,8 +60,29 @@ export function ProductSearch({
const res = await axios.get("/api/products/search", {
params: { q: searchTerm },
});
if (res.data.total === 0) {
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);
} catch {
toast.error("Search failed");
@@ -72,6 +94,12 @@ export function ProductSearch({
const handleSelect = useCallback(
async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return;
const cached = fullByPid.get(Number(product.pid));
if (cached) {
onSelect(cached);
setResultsOpen(false);
return;
}
setIsLoadingProduct(product.pid);
try {
const res = await axios.get("/api/import/search-products", {
@@ -90,7 +118,7 @@ export function ProductSearch({
setIsLoadingProduct(null);
}
},
[onSelect, loadedPids]
[onSelect, loadedPids, fullByPid]
);
const handleLoadAll = useCallback(() => {
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
/**
* Create Purchase Order page.
*
* Lets the user pick a supplier and assemble a list of products via search,
* paste, or file upload, then submits the PO to the legacy PHP backend via
* the existing /apiv2 proxy. On success, shows a confirmation view with a
* link to the new PO in the legacy admin.
*
* State model:
* - supplierId → controlled string from SupplierSelector
* - lineItems[] → the working list (PoLineItem; local-only fields
* qty + moqOverride live here)
* - selectedPids: Set → checkbox state for the bulk-remove flow
* - addOpen → AddProductsDialog visibility
* - submitting → submit button spinner
* - confirmation → null while building; { poId, itemCount } after
* a successful submit
*
* Dedup is enforced server-naive: when AddProductsDialog returns a list of
* (pid, qty) pairs, we filter out pids that are already on the PO and show
* a brief toast indicating how many were skipped. The user can edit existing
* rows manually if they want to bump quantities — the dialog never mutates
* existing rows.
*/
import { useCallback, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 } from "@/services/apiv2";
export default function CreatePurchaseOrder() {
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
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;
} | null>(null);
// ---- 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 handleSubmit = useCallback(async () => {
if (!supplierId) {
toast.error("Pick a supplier first");
return;
}
const validItems = lineItems
.filter((i) => i.qty > 0)
.map((i) => ({ pid: i.pid, qty: i.qty }));
if (validItems.length === 0) {
toast.error("Add at least one product with a positive quantity");
return;
}
setSubmitting(true);
try {
const res = await submitNewPurchaseOrder({ 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 });
} catch (e) {
console.error(e);
toast.error(e instanceof Error ? e.message : "PO submission failed");
} finally {
setSubmitting(false);
}
}, [supplierId, lineItems]);
// ---- Reset for "Create another" -------------------------------------------
const handleCreateAnother = useCallback(() => {
setSupplierId(undefined);
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}
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
);
return (
<div className="container mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Create Purchase Order</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Supplier</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md">
<SupplierSelector value={supplierId} onChange={setSupplierId} />
</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={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 || !supplierId}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting
</>
) : (
<>
Create purchase order
</>
)}
</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 [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
const [applyHistoricalProductPromo, setApplyHistoricalProductPromo] = useState(false);
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
@@ -70,7 +71,6 @@ export function DiscountSimulator() {
const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
const initialRunRef = useRef(false);
const skipAutoRunRef = useRef(false);
const latestPayloadKeyRef = useRef('');
const pendingCountRef = useRef(0);
@@ -127,13 +127,6 @@ export function DiscountSimulator() {
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 { from, to } = ensureDateRange(dateRange);
@@ -151,7 +144,6 @@ export function DiscountSimulator() {
filters: {
shipCountry: 'US',
promoIds: selectedPromoId ? [selectedPromoId] : undefined,
promoCodes: selectedPromoCode ? [selectedPromoCode] : undefined,
},
productPromo,
shippingPromo,
@@ -168,12 +160,12 @@ export function DiscountSimulator() {
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo,
pointsConfig: payloadPointsConfig,
};
}, [
dateRange,
selectedPromoId,
selectedPromoCode,
productPromo,
shippingPromo,
shippingTiers,
@@ -181,6 +173,7 @@ export function DiscountSimulator() {
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue,
redemptionRate,
]);
@@ -264,6 +257,7 @@ export function DiscountSimulator() {
merchantFeePercent?: number;
fixedCostPerOrder?: number;
cogsCalculationMode?: CogsCalculationMode;
applyHistoricalProductPromo?: boolean;
pointsConfig?: {
pointsPerDollar?: number | null;
redemptionRate?: number | null;
@@ -319,6 +313,10 @@ export function DiscountSimulator() {
setCogsCalculationMode(parsed.cogsCalculationMode);
}
if (typeof parsed.applyHistoricalProductPromo === 'boolean') {
setApplyHistoricalProductPromo(parsed.applyHistoricalProductPromo);
}
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
setPointDollarValue(parsed.pointsConfig.pointDollarValue);
setPointDollarTouched(true);
@@ -361,10 +359,11 @@ export function DiscountSimulator() {
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue,
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(() => {
if (!hasLoadedConfig) {
@@ -379,10 +378,6 @@ export function DiscountSimulator() {
}, [serializedConfig, hasLoadedConfig]);
useEffect(() => {
if (!initialRunRef.current) {
initialRunRef.current = true;
}
if (skipAutoRunRef.current) {
skipAutoRunRef.current = false;
return;
@@ -448,6 +443,7 @@ export function DiscountSimulator() {
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST);
setCogsCalculationMode('actual');
setApplyHistoricalProductPromo(false);
setPointDollarValue(DEFAULT_POINT_VALUE);
setPointDollarTouched(false);
setRedemptionRate(DEFAULT_REDEMPTION_RATE);
@@ -499,6 +495,8 @@ export function DiscountSimulator() {
onFixedCostChange={setFixedCostPerOrder}
cogsCalculationMode={cogsCalculationMode}
onCogsCalculationModeChange={setCogsCalculationMode}
applyHistoricalProductPromo={applyHistoricalProductPromo}
onApplyHistoricalProductPromoChange={setApplyHistoricalProductPromo}
pointsPerDollar={currentPointsPerDollar}
redemptionRate={redemptionRate}
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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
type GroupMode = "line" | "category" | "designer";
@@ -211,20 +210,6 @@ export default function Forecasting() {
state: { sorting: designerSorting },
});
// ─── QuickOrderBuilder data (always category-based) ─────────────────────
const qobCategories = useMemo(
() =>
categoryGroups.map((c) => ({
category: c.category,
categoryPath: c.categoryPath,
avgTotalSold: c.avgLifetimeSales,
minSold: c.minSales,
maxSold: c.maxSales,
})),
[categoryGroups]
);
// ─── Summary stats ─────────────────────────────────────────────────────
const totalProducts = filteredProducts.length;
@@ -409,9 +394,6 @@ export default function Forecasting() {
)}
</CardContent>
</Card>
{/* Quick Order Builder (unchanged interface) */}
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
</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>
);
}
-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;
+118
View File
@@ -12,6 +12,24 @@ export interface SubmitNewProductsResponse {
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 CreateProductCategoryArgs {
masterCatId: string | number;
name: string;
@@ -204,3 +222,103 @@ export async function createProductCategory({
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,
};
}
@@ -106,6 +106,7 @@ export interface DiscountSimulationRequest {
merchantFeePercent: number;
fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode;
applyHistoricalProductPromo: boolean;
pointsConfig: {
pointsPerDollar: 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 => {
if (value == null) return 'N/A';
return value ? 'Yes' : 'No';
File diff suppressed because one or more lines are too long