Add create PO page, remove old quick order builder from forecasting page, reorder sidebar, combine brands/vendors pages

This commit is contained in:
2026-04-16 14:49:11 -04:00
parent 338f829eb6
commit 9ab5d4300a
26 changed files with 3506 additions and 1890 deletions

0
inventory-server/chat/export-chat-data.sh Executable file → Normal file
View File

0
inventory-server/chat/verify-migration.js Executable file → Normal file
View File

View File

@@ -504,6 +504,152 @@ router.get('/search', async (req, res) => {
}
});
// Batch lookup of product display data by pid list (used by Create PO page)
// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500.
// Returns rows in the same order as the deduped input pids; missing pids are silently dropped.
router.get('/batch', async (req, res) => {
const pool = req.app.locals.pool;
const raw = req.query.pids;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'pids query parameter is required' });
}
const pids = Array.from(new Set(
raw.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => Number.isInteger(n) && n > 0)
)).slice(0, 500);
if (pids.length === 0) {
return res.status(400).json({ error: 'No valid pids provided' });
}
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.image_175 AS image_url,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.notions_inv_count,
pm.current_stock,
p.baskets,
pm.on_order_qty,
p.total_sold,
pm.current_cost_price,
pm.date_last_sold,
pm.date_first_received,
p.moq
FROM products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
WHERE p.pid = ANY($1::bigint[])
`, [pids]);
// products.pid is BIGINT, which the pg driver returns as a STRING by
// default (to preserve precision for values > 2^53). Coerce to Number
// so the JSON response has numeric pids and Map lookups work.
const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) }));
const byPid = new Map(normalized.map(r => [r.pid, r]));
// Preserve the requested order so the frontend can append rows in input order
const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean);
res.json(ordered);
} catch (error) {
console.error('Error fetching batch products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Bulk resolve a list of identifiers (UPC / SKU / supplier # / notions # / pid)
// to candidate products in ONE query. Used by the Create PO paste/upload flow.
// Body: { identifiers: string[] }
// Response: { results: Array<{ identifier: string, candidates: Candidate[] }> }
// Results are returned in the same order as the input identifiers, with
// duplicates preserved (so the caller can pair results back to input rows
// positionally).
router.post('/resolve-identifiers', async (req, res) => {
const pool = req.app.locals.pool;
const body = req.body || {};
const raw = Array.isArray(body.identifiers) ? body.identifiers : null;
if (!raw) {
return res.status(400).json({ error: 'identifiers array is required' });
}
// Clean and cap. Cleaned keeps ORIGINAL order (duplicates preserved) so the
// response aligns with the caller's input rows positionally.
const cleaned = raw
.map(s => (typeof s === 'string' ? s.trim() : ''))
.filter(s => s.length > 0)
.slice(0, 1000);
if (cleaned.length === 0) {
return res.json({ results: [] });
}
// Dedupe for the DB lookup, and split numeric-looking values off for a
// separate bigint equality check (so the pid index can be used).
const uniqueTextIds = Array.from(new Set(cleaned));
const numericPids = Array.from(new Set(
uniqueTextIds
.filter(s => /^\d+$/.test(s) && s.length <= 18) // safe for Number()
.map(s => Number(s))
.filter(n => Number.isSafeInteger(n) && n > 0)
));
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.sku,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.brand
FROM products p
WHERE p.sku = ANY($1::text[])
OR p.barcode = ANY($1::text[])
OR p.vendor_reference = ANY($1::text[])
OR p.notions_reference = ANY($1::text[])
OR p.pid = ANY($2::bigint[])
`, [uniqueTextIds, numericPids]);
// Normalize pid to Number once (products.pid is BIGINT → pg returns string)
const products = rows.map(r => ({
pid: Number(r.pid),
title: r.title,
sku: r.sku,
barcode: r.barcode,
vendor_reference: r.vendor_reference,
notions_reference: r.notions_reference,
brand: r.brand,
}));
// Group per-input-identifier. A product counts as a match for an
// identifier if any of its indexable fields equals the identifier string
// (or the pid matches when the identifier is numeric). The comparison is
// done in JS against the fetched products — cheap because the product
// count is bounded by the DB result set.
const results = cleaned.map(identifier => {
const candidates = products.filter(p => (
p.sku === identifier ||
p.barcode === identifier ||
p.vendor_reference === identifier ||
p.notions_reference === identifier ||
String(p.pid) === identifier
));
return { identifier, candidates };
});
res.json({ results });
} catch (error) {
console.error('Error resolving identifiers:', error);
res.status(500).json({ error: 'Failed to resolve identifiers' });
}
});
// Get a single product
router.get('/:id', async (req, res) => {
try {