Compare commits
5 Commits
546a675b5f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| edfa86608c | |||
| 8721ba67df | |||
| 123946c159 | |||
| 9ab5d4300a | |||
| 338f829eb6 |
Executable → Regular
Executable → Regular
@@ -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 };
|
||||
@@ -1237,6 +1237,7 @@ router.get('/search-products', async (req, res) => {
|
||||
ELSE 6
|
||||
END
|
||||
`}
|
||||
${isPidSearch ? '' : 'LIMIT 100'}
|
||||
`;
|
||||
|
||||
// Prepare query parameters based on search type
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+697
-344
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user