Update for project move on server, add ability to update existing POs, add spec lookup page, enhance copy down functionality.
This commit is contained in:
+2
-2
@@ -84,7 +84,7 @@ npm run setup # Create required directories (logs, uploads)
|
|||||||
- PostgreSQL with connection pooling (pg library)
|
- PostgreSQL with connection pooling (pg library)
|
||||||
- Pool initialized in `utils/db.js` via `initPool()`
|
- Pool initialized in `utils/db.js` via `initPool()`
|
||||||
- Pool attached to `app.locals.pool` for route access
|
- Pool attached to `app.locals.pool` for route access
|
||||||
- Environment variables loaded from `/var/www/html/inventory/.env` (production path)
|
- Environment variables loaded from `/var/www/inventory/.env` (production path)
|
||||||
|
|
||||||
**API Routes:** All prefixed with `/api/`
|
**API Routes:** All prefixed with `/api/`
|
||||||
- `/api/products` - Product CRUD operations
|
- `/api/products` - Product CRUD operations
|
||||||
@@ -164,7 +164,7 @@ Run tests for individual components or features:
|
|||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- Environment variables must be configured in `/var/www/html/inventory/.env` for production
|
- Environment variables must be configured in `/var/www/inventory/.env` for production
|
||||||
- The frontend expects the backend at `/api` (proxied in dev, served together in production)
|
- The frontend expects the backend at `/api` (proxied in dev, served together in production)
|
||||||
- PM2 is used for production process management
|
- PM2 is used for production process management
|
||||||
- Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var)
|
- Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var)
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ Not all of the information in this database is relevant as it's a direct export
|
|||||||
|
|
||||||
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
|
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
|
||||||
|
|
||||||
The folder you see as inventory-server is actually a direct mount of the /var/www/html/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
|
The folder you see as inventory-server is actually a direct mount of the /var/www/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
|
||||||
|
|
||||||
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
|
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
// Load klaviyo .env for API key
|
// Load klaviyo .env for API key
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
// Also load the main inventory-server .env for DB credentials
|
// Also load the main inventory-server .env for DB credentials
|
||||||
const mainEnvPath = '/var/www/html/inventory/.env';
|
const mainEnvPath = '/var/www/inventory/.env';
|
||||||
if (fs.existsSync(mainEnvPath)) {
|
if (fs.existsSync(mainEnvPath)) {
|
||||||
dotenv.config({ path: mainEnvPath });
|
dotenv.config({ path: mainEnvPath });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const envPaths = [
|
|||||||
path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env)
|
path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env)
|
||||||
path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env)
|
path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env)
|
||||||
path.resolve(__dirname, '.env'), // Same directory
|
path.resolve(__dirname, '.env'), // Same directory
|
||||||
'/var/www/html/inventory/.env' // Server absolute path
|
'/var/www/inventory/.env' // Server absolute path
|
||||||
];
|
];
|
||||||
|
|
||||||
let envLoaded = false;
|
let envLoaded = false;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*
|
*
|
||||||
* Environment:
|
* Environment:
|
||||||
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
|
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
|
||||||
* /var/www/html/inventory/.env (or current process env).
|
* /var/www/inventory/.env (or current process env).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
@@ -20,7 +20,7 @@ const fs = require('fs');
|
|||||||
|
|
||||||
// Load .env file if it exists (production path)
|
// Load .env file if it exists (production path)
|
||||||
const envPaths = [
|
const envPaths = [
|
||||||
'/var/www/html/inventory/.env',
|
'/var/www/inventory/.env',
|
||||||
path.join(__dirname, '../../.env'),
|
path.join(__dirname, '../../.env'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const axios = require('axios');
|
|||||||
const net = require('net');
|
const net = require('net');
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
// Create uploads directory if it doesn't exist
|
||||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
const uploadsDir = path.join('/var/www/inventory/uploads/products');
|
||||||
const reusableUploadsDir = path.join('/var/www/html/inventory/uploads/reusable');
|
const reusableUploadsDir = path.join('/var/www/inventory/uploads/reusable');
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
fs.mkdirSync(reusableUploadsDir, { recursive: true });
|
fs.mkdirSync(reusableUploadsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -513,10 +513,12 @@ const storage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB
|
fileSize: MAX_UPLOAD_BYTES,
|
||||||
},
|
},
|
||||||
fileFilter: function (req, file, cb) {
|
fileFilter: function (req, file, cb) {
|
||||||
// Accept only image files
|
// Accept only image files
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Create reusable uploads directory if it doesn't exist
|
// Create reusable uploads directory if it doesn't exist
|
||||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable');
|
const uploadsDir = path.join('/var/www/inventory/uploads/reusable');
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MAX_MATCHES = 500;
|
||||||
|
const DESCRIPTION_SAMPLE_LIMIT = 8;
|
||||||
|
|
||||||
|
// GET /api/spec-lookup?company=...&term=...
|
||||||
|
// Returns aggregated specs across products matching company (brand) and term (title).
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const company = typeof req.query.company === 'string' ? req.query.company.trim() : '';
|
||||||
|
const term = typeof req.query.term === 'string' ? req.query.term.trim() : '';
|
||||||
|
|
||||||
|
if (!company && !term) {
|
||||||
|
return res.status(400).json({ error: 'company or term is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (company) {
|
||||||
|
params.push(`%${company}%`);
|
||||||
|
conditions.push(`brand ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
if (term) {
|
||||||
|
params.push(`%${term}%`);
|
||||||
|
conditions.push(`title ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(MAX_MATCHES);
|
||||||
|
const limitParam = `$${params.length}`;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
pid::TEXT AS pid,
|
||||||
|
title, sku, brand, vendor, artist,
|
||||||
|
country_of_origin, harmonized_tariff_code,
|
||||||
|
description, categories,
|
||||||
|
cost_price, regular_price,
|
||||||
|
moq, weight, length, width, height,
|
||||||
|
created_at
|
||||||
|
FROM products
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC NULLS LAST
|
||||||
|
LIMIT ${limitParam}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
|
||||||
|
// Resolve category cat_ids → names. products.categories is a comma-separated cat_id string.
|
||||||
|
const catIds = new Set();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.categories) continue;
|
||||||
|
for (const tok of String(r.categories).split(',')) {
|
||||||
|
const trimmed = tok.trim();
|
||||||
|
if (trimmed && /^\d+$/.test(trimmed)) catIds.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Map cat_id → {name, type}. Types 10-13 are Section/Category/Subcategory/Sub-Subcategory; 20-21 are Theme/Subtheme.
|
||||||
|
const catIdToInfo = new Map();
|
||||||
|
if (catIds.size > 0) {
|
||||||
|
const { rows: catRows } = await pool.query(
|
||||||
|
`SELECT cat_id::TEXT AS cat_id, name, type FROM categories WHERE cat_id = ANY($1::bigint[])`,
|
||||||
|
[Array.from(catIds)],
|
||||||
|
);
|
||||||
|
for (const c of catRows) catIdToInfo.set(c.cat_id, { name: c.name, type: Number(c.type) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = rows.map(r => ({
|
||||||
|
pid: Number(r.pid),
|
||||||
|
title: r.title,
|
||||||
|
sku: r.sku,
|
||||||
|
brand: r.brand,
|
||||||
|
vendor: r.vendor,
|
||||||
|
artist: r.artist,
|
||||||
|
country_of_origin: r.country_of_origin,
|
||||||
|
harmonized_tariff_code: r.harmonized_tariff_code,
|
||||||
|
description: r.description,
|
||||||
|
categories: r.categories,
|
||||||
|
cost_price: toNumberOrNull(r.cost_price),
|
||||||
|
regular_price: toNumberOrNull(r.regular_price),
|
||||||
|
moq: toNumberOrNull(r.moq),
|
||||||
|
weight: toNumberOrNull(r.weight),
|
||||||
|
length: toNumberOrNull(r.length),
|
||||||
|
width: toNumberOrNull(r.width),
|
||||||
|
height: toNumberOrNull(r.height),
|
||||||
|
created_at: r.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
company,
|
||||||
|
term,
|
||||||
|
total: products.length,
|
||||||
|
truncated: products.length === MAX_MATCHES,
|
||||||
|
products,
|
||||||
|
aggregates: {
|
||||||
|
numeric: {
|
||||||
|
cost_price: numericAggregate(products, 'cost_price'),
|
||||||
|
regular_price: numericAggregate(products, 'regular_price'),
|
||||||
|
moq: numericAggregate(products, 'moq'),
|
||||||
|
weight: numericAggregate(products, 'weight'),
|
||||||
|
length: numericAggregate(products, 'length'),
|
||||||
|
width: numericAggregate(products, 'width'),
|
||||||
|
height: numericAggregate(products, 'height'),
|
||||||
|
},
|
||||||
|
categorical: {
|
||||||
|
artist: categoricalAggregate(products, 'artist'),
|
||||||
|
country_of_origin: categoricalAggregate(products, 'country_of_origin'),
|
||||||
|
harmonized_tariff_code: categoricalAggregate(products, 'harmonized_tariff_code'),
|
||||||
|
},
|
||||||
|
categories: groupedAggregate(products, catIdToInfo, new Set([10, 11, 12, 13])),
|
||||||
|
themes: groupedAggregate(products, catIdToInfo, new Set([20, 21])),
|
||||||
|
description: descriptionAggregate(products),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in spec-lookup:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to compute spec lookup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toNumberOrNull(v) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate a numeric field. Treats null/0 as unset since 0 is the codebase's "no value" sentinel.
|
||||||
|
// `products` is assumed to be ordered most-recent-first (created_at DESC) so the head of the
|
||||||
|
// list is also the recency window we use for trend detection.
|
||||||
|
function numericAggregate(products, field) {
|
||||||
|
const values = [];
|
||||||
|
// Iterate products in order so we know which values came from the most-recent rows.
|
||||||
|
for (const p of products) {
|
||||||
|
const v = p[field];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) values.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.length) {
|
||||||
|
return { count: 0, sample_size: products.length, distribution: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const sum = values.reduce((s, v) => s + v, 0);
|
||||||
|
const avg = sum / values.length;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
const median = sorted.length % 2 === 0
|
||||||
|
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||||
|
: sorted[mid];
|
||||||
|
const variance = values.reduce((s, v) => s + (v - avg) ** 2, 0) / values.length;
|
||||||
|
const stddev = Math.sqrt(variance);
|
||||||
|
|
||||||
|
const counts = new Map();
|
||||||
|
for (const v of values) {
|
||||||
|
const key = roundForKey(v);
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
const distribution = Array.from(counts.entries())
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.value - b.value);
|
||||||
|
|
||||||
|
const mode = distribution[0]?.value ?? null;
|
||||||
|
const mode_count = distribution[0]?.count ?? 0;
|
||||||
|
|
||||||
|
// Trend detection: scan only the most-recent N values. N adapts to sample size so this
|
||||||
|
// can never look at more than ~20% of the data when the sample is small.
|
||||||
|
const recentN = Math.min(20, Math.max(5, Math.floor(values.length / 4)));
|
||||||
|
const recentValues = values.slice(0, recentN);
|
||||||
|
let recent_mode = null;
|
||||||
|
let recent_mode_count = 0;
|
||||||
|
let trending = false;
|
||||||
|
if (recentValues.length >= 3) {
|
||||||
|
const recentCounts = new Map();
|
||||||
|
for (const v of recentValues) {
|
||||||
|
const key = roundForKey(v);
|
||||||
|
recentCounts.set(key, (recentCounts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
const recentSorted = Array.from(recentCounts.entries()).sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
||||||
|
recent_mode = recentSorted[0][0];
|
||||||
|
recent_mode_count = recentSorted[0][1];
|
||||||
|
// Trend = recent mode differs from overall AND dominates the window AND has min absolute support.
|
||||||
|
const majority = recent_mode_count >= Math.ceil(recentValues.length * 0.6);
|
||||||
|
const minSupport = recent_mode_count >= 3;
|
||||||
|
trending = recent_mode !== mode && majority && minSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
sample_size: products.length,
|
||||||
|
avg,
|
||||||
|
median,
|
||||||
|
min: sorted[0],
|
||||||
|
max: sorted[sorted.length - 1],
|
||||||
|
stddev,
|
||||||
|
mode,
|
||||||
|
mode_count,
|
||||||
|
recent_mode,
|
||||||
|
recent_mode_count,
|
||||||
|
recent_window: recentValues.length,
|
||||||
|
trending,
|
||||||
|
distribution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round to 4 decimals so JS-FP noise doesn't fragment the histogram.
|
||||||
|
function roundForKey(v) {
|
||||||
|
return Math.round(v * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoricalAggregate(products, field) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const p of products) {
|
||||||
|
const v = p[field];
|
||||||
|
if (v === null || v === undefined) continue;
|
||||||
|
const key = String(v).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate cat_id token counts, including only entries whose category type is in `acceptedTypes`.
|
||||||
|
function groupedAggregate(products, catIdToInfo, acceptedTypes) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const p of products) {
|
||||||
|
if (!p.categories) continue;
|
||||||
|
const tokens = String(p.categories).split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
for (const t of tokens) {
|
||||||
|
const info = catIdToInfo.get(t);
|
||||||
|
if (!info || !acceptedTypes.has(info.type)) continue;
|
||||||
|
counts.set(info.name, (counts.get(info.name) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptionAggregate(products) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const p of products) {
|
||||||
|
if (!p.description) continue;
|
||||||
|
const key = String(p.description).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = Array.from(counts.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Recent unique samples (products are already ordered by created_at DESC).
|
||||||
|
const seen = new Set();
|
||||||
|
const samples = [];
|
||||||
|
for (const p of products) {
|
||||||
|
const desc = (p.description || '').trim();
|
||||||
|
if (!desc || seen.has(desc)) continue;
|
||||||
|
seen.add(desc);
|
||||||
|
samples.push({ value: desc, title: p.title, pid: p.pid, sku: p.sku });
|
||||||
|
if (samples.length >= DESCRIPTION_SAMPLE_LIMIT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { duplicates, samples };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
|||||||
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||||
const htsLookupRouter = require('./routes/hts-lookup');
|
const htsLookupRouter = require('./routes/hts-lookup');
|
||||||
|
const specLookupRouter = require('./routes/spec-lookup');
|
||||||
const importSessionsRouter = require('./routes/import-sessions');
|
const importSessionsRouter = require('./routes/import-sessions');
|
||||||
const importAuditLogRouter = require('./routes/import-audit-log');
|
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||||
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
||||||
@@ -31,7 +32,7 @@ const linesAggregateRouter = require('./routes/linesAggregate');
|
|||||||
const repeatOrdersRouter = require('./routes/repeat-orders');
|
const repeatOrdersRouter = require('./routes/repeat-orders');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/inventory/.env';
|
||||||
console.log('Looking for .env file at:', envPath);
|
console.log('Looking for .env file at:', envPath);
|
||||||
console.log('.env file exists:', fs.existsSync(envPath));
|
console.log('.env file exists:', fs.existsSync(envPath));
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ async function startServer() {
|
|||||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
app.use('/api/reusable-images', reusableImagesRouter);
|
||||||
app.use('/api/hts-lookup', htsLookupRouter);
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
|
app.use('/api/spec-lookup', specLookupRouter);
|
||||||
app.use('/api/import-sessions', importSessionsRouter);
|
app.use('/api/import-sessions', importSessionsRouter);
|
||||||
app.use('/api/import-audit-log', importAuditLogRouter);
|
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||||
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/html/inventory/frontend vite build",
|
"build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/inventory/frontend vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"mount": "../mountremote.command"
|
"mount": "../mountremote.command"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
|
|||||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||||
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
||||||
|
const SpecLookup = lazy(() => import('./pages/SpecLookup'));
|
||||||
const Categories = lazy(() => import('./pages/Categories'));
|
const Categories = lazy(() => import('./pages/Categories'));
|
||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
||||||
@@ -179,6 +180,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/spec-lookup" element={
|
||||||
|
<Protected page="spec_lookup">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<SpecLookup />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/forecasting" element={
|
<Route path="/forecasting" element={
|
||||||
<Protected page="forecasting">
|
<Protected page="forecasting">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const PAGES = [
|
|||||||
{ path: "/analytics", permission: "access:analytics" },
|
{ path: "/analytics", permission: "access:analytics" },
|
||||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||||
{ path: "/hts-lookup", permission: "access:hts_lookup" },
|
{ path: "/hts-lookup", permission: "access:hts_lookup" },
|
||||||
|
{ path: "/spec-lookup", permission: "access:spec_lookup" },
|
||||||
{ path: "/forecasting", permission: "access:forecasting" },
|
{ path: "/forecasting", permission: "access:forecasting" },
|
||||||
{ path: "/import", permission: "access:import" },
|
{ path: "/import", permission: "access:import" },
|
||||||
{ path: "/settings", permission: "access:settings" },
|
{ path: "/settings", permission: "access:settings" },
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ Admin users automatically have all permissions.
|
|||||||
| `access:analytics` | Access to Analytics page |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||||
| `access:hts_lookup` | Access to HTS Lookup page |
|
| `access:hts_lookup` | Access to HTS Lookup page |
|
||||||
|
| `access:spec_lookup` | Access to Spec Lookup page |
|
||||||
| `access:forecasting` | Access to Forecasting page |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
| `access:import` | Access to Import page |
|
| `access:import` | Access to Import page |
|
||||||
| `access:settings` | Access to Settings page |
|
| `access:settings` | Access to Settings page |
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Post-submit success screen.
|
* Post-submit success screen.
|
||||||
*
|
*
|
||||||
* Shows when the legacy backend has accepted the PO and returned a po_id.
|
* Shows when the legacy backend has accepted the submission. Supports both
|
||||||
|
* flows: creating a brand-new PO and adding line items to an existing PO.
|
||||||
* The single primary action is the external link to the legacy admin's PO
|
* The single primary action is the external link to the legacy admin's PO
|
||||||
* editor; secondary action is "Create another" which resets the page.
|
* editor; the secondary action resets the page for another submission.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CheckCircle2, ExternalLink, Plus } from "lucide-react";
|
import { CheckCircle2, ExternalLink, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
type Mode = "create" | "add";
|
||||||
|
|
||||||
interface ConfirmationViewProps {
|
interface ConfirmationViewProps {
|
||||||
poId: number;
|
poId: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
mode: Mode;
|
||||||
onCreateAnother: () => void;
|
onCreateAnother: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationView({
|
export function ConfirmationView({
|
||||||
poId,
|
poId,
|
||||||
itemCount,
|
itemCount,
|
||||||
|
mode,
|
||||||
onCreateAnother,
|
onCreateAnother,
|
||||||
}: ConfirmationViewProps) {
|
}: ConfirmationViewProps) {
|
||||||
const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`;
|
const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`;
|
||||||
|
const heading =
|
||||||
|
mode === "create" ? "Purchase order created" : "Products added to purchase order";
|
||||||
|
const itemNoun = itemCount === 1 ? "item" : "items";
|
||||||
|
const subhead =
|
||||||
|
mode === "create"
|
||||||
|
? `PO #${poId} with ${itemCount} ${itemNoun} has been submitted to the backend.`
|
||||||
|
: `${itemCount} ${itemNoun} added to PO #${poId}.`;
|
||||||
|
const resetLabel = mode === "create" ? "Create another" : "Add more";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto pt-12">
|
<div className="max-w-2xl mx-auto pt-12">
|
||||||
@@ -31,11 +44,8 @@ export function ConfirmationView({
|
|||||||
<div className="rounded-full bg-emerald-100 p-3 mb-4">
|
<div className="rounded-full bg-emerald-100 p-3 mb-4">
|
||||||
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
|
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold mb-1">Purchase order created</h2>
|
<h2 className="text-2xl font-semibold mb-1">{heading}</h2>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">{subhead}</p>
|
||||||
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">
|
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||||
<Button asChild size="lg">
|
<Button asChild size="lg">
|
||||||
@@ -46,7 +56,7 @@ export function ConfirmationView({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="lg" onClick={onCreateAnother}>
|
<Button variant="outline" size="lg" onClick={onCreateAnother}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create another
|
{resetLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Repeat,
|
Repeat,
|
||||||
ClipboardPlus,
|
ClipboardPlus,
|
||||||
|
PackageSearch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +162,12 @@ const toolsItems = [
|
|||||||
url: "/hts-lookup",
|
url: "/hts-lookup",
|
||||||
permission: "access:hts_lookup"
|
permission: "access:hts_lookup"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Spec Lookup",
|
||||||
|
icon: PackageSearch,
|
||||||
|
url: "/spec-lookup",
|
||||||
|
permission: "access:spec_lookup"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Chat Archive",
|
title: "Chat Archive",
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
|
|||||||
@@ -293,16 +293,6 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
width: 500,
|
width: 500,
|
||||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Private Notes",
|
|
||||||
key: "priv_notes",
|
|
||||||
description: "Internal notes about the product",
|
|
||||||
fieldType: {
|
|
||||||
type: "input",
|
|
||||||
multiline: true
|
|
||||||
},
|
|
||||||
width: 300,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Categories",
|
label: "Categories",
|
||||||
key: "categories",
|
key: "categories",
|
||||||
@@ -335,6 +325,16 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
},
|
},
|
||||||
width: 200,
|
width: 200,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Private Notes",
|
||||||
|
key: "priv_notes",
|
||||||
|
description: "Internal notes about the product",
|
||||||
|
fieldType: {
|
||||||
|
type: "input",
|
||||||
|
multiline: true
|
||||||
|
},
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
|
export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
|
||||||
+13
@@ -1,8 +1,11 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, Upload } from "lucide-react";
|
import { Loader2, Upload } from "lucide-react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
interface GenericDropzoneProps {
|
interface GenericDropzoneProps {
|
||||||
processingBulk: boolean;
|
processingBulk: boolean;
|
||||||
unassignedImages: { previewUrl: string; file: File }[];
|
unassignedImages: { previewUrl: string; file: File }[];
|
||||||
@@ -22,7 +25,17 @@ export const GenericDropzone = ({
|
|||||||
accept: {
|
accept: {
|
||||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||||
},
|
},
|
||||||
|
maxSize: MAX_UPLOAD_BYTES,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
onDropRejected: (rejections) => {
|
||||||
|
rejections.forEach((rejection) => {
|
||||||
|
const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large');
|
||||||
|
const reason = tooLarge
|
||||||
|
? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit`
|
||||||
|
: rejection.errors[0]?.message ?? 'rejected';
|
||||||
|
toast.error(`${rejection.file.name}: ${reason}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
multiple: true
|
multiple: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
@@ -1,7 +1,10 @@
|
|||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
interface ImageDropzoneProps {
|
interface ImageDropzoneProps {
|
||||||
productIndex: number;
|
productIndex: number;
|
||||||
onDrop: (files: File[]) => void;
|
onDrop: (files: File[]) => void;
|
||||||
@@ -12,9 +15,19 @@ export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
|
|||||||
accept: {
|
accept: {
|
||||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||||
},
|
},
|
||||||
|
maxSize: MAX_UPLOAD_BYTES,
|
||||||
onDrop: (acceptedFiles) => {
|
onDrop: (acceptedFiles) => {
|
||||||
onDrop(acceptedFiles);
|
onDrop(acceptedFiles);
|
||||||
},
|
},
|
||||||
|
onDropRejected: (rejections) => {
|
||||||
|
rejections.forEach((rejection) => {
|
||||||
|
const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large');
|
||||||
|
const reason = tooLarge
|
||||||
|
? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit`
|
||||||
|
: rejection.errors[0]?.message ?? 'rejected';
|
||||||
|
toast.error(`${rejection.file.name}: ${reason}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+56
-11
@@ -8,9 +8,10 @@
|
|||||||
|
|
||||||
import { memo, useEffect, useState, useRef } from 'react';
|
import { memo, useEffect, useState, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { X } from 'lucide-react';
|
import { X, ArrowDownToLine } from 'lucide-react';
|
||||||
import { useValidationStore } from '../store/validationStore';
|
import { useValidationStore } from '../store/validationStore';
|
||||||
import { useIsCopyDownActive } from '../store/selectors';
|
import { useIsCopyDownActive } from '../store/selectors';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy-down instruction banner
|
* Copy-down instruction banner
|
||||||
@@ -23,6 +24,11 @@ import { useIsCopyDownActive } from '../store/selectors';
|
|||||||
*/
|
*/
|
||||||
export const CopyDownBanner = memo(() => {
|
export const CopyDownBanner = memo(() => {
|
||||||
const isActive = useIsCopyDownActive();
|
const isActive = useIsCopyDownActive();
|
||||||
|
// Subscribe only to the primitives we need to compute "rows below source".
|
||||||
|
// These are cheap to compare and only change while copy-down is active.
|
||||||
|
const rowCount = useValidationStore((state) => state.rows.length);
|
||||||
|
const sourceRowIndex = useValidationStore((state) => state.copyDownMode.sourceRowIndex);
|
||||||
|
const rowsBelow = sourceRowIndex !== null ? Math.max(0, rowCount - 1 - sourceRowIndex) : 0;
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
const bannerRef = useRef<HTMLDivElement>(null);
|
const bannerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -57,13 +63,19 @@ export const CopyDownBanner = memo(() => {
|
|||||||
const tableRect = tableContainer.getBoundingClientRect();
|
const tableRect = tableContainer.getBoundingClientRect();
|
||||||
const cellRect = cellElement.getBoundingClientRect();
|
const cellRect = cellElement.getBoundingClientRect();
|
||||||
|
|
||||||
// Calculate position relative to the table container
|
// Measure actual banner height so it sits the right distance above the cell.
|
||||||
// Position banner centered horizontally on the cell, above it
|
// Fallback covers the very first paint before the ref is attached.
|
||||||
const topPosition = cellRect.top - tableRect.top - 55; // 55px above the cell (enough to not cover it)
|
const bannerHeight = bannerRef.current?.offsetHeight ?? 32;
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
|
// Always position above the cell. Clamp to the top of the table container
|
||||||
|
// so the banner stays visible — a small overlap with the source cell is
|
||||||
|
// acceptable for top-row sources since the compact pill rarely needs it.
|
||||||
|
const topPosition = Math.max(cellRect.top - tableRect.top - bannerHeight - GAP, 8);
|
||||||
const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2;
|
const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2;
|
||||||
|
|
||||||
setPosition({
|
setPosition({
|
||||||
top: Math.max(topPosition, 8), // Minimum 8px from top
|
top: topPosition,
|
||||||
left: leftPosition,
|
left: leftPosition,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -85,6 +97,13 @@ export const CopyDownBanner = memo(() => {
|
|||||||
useValidationStore.getState().cancelCopyDown();
|
useValidationStore.getState().cancelCopyDown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyToAll = () => {
|
||||||
|
const state = useValidationStore.getState();
|
||||||
|
const lastRowIndex = state.rows.length - 1;
|
||||||
|
if (lastRowIndex <= (state.copyDownMode.sourceRowIndex ?? -1)) return;
|
||||||
|
state.completeCopyDown(lastRowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute z-30 pointer-events-none"
|
className="absolute z-30 pointer-events-none"
|
||||||
@@ -95,18 +114,44 @@ export const CopyDownBanner = memo(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={bannerRef} className="pointer-events-auto">
|
<div ref={bannerRef} className="pointer-events-auto">
|
||||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
<span className="text-xs font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap">
|
||||||
Click on the last row you want to copy to
|
Click row to copy to
|
||||||
</span>
|
</span>
|
||||||
|
{rowsBelow > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-px bg-blue-200 dark:bg-blue-800" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyToAll}
|
||||||
|
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-100"
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="h-3 w-3 mr-1" />
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center">
|
||||||
|
{rowsBelow === 1
|
||||||
|
? "Copy to 1 row below"
|
||||||
|
: `Copy to all ${rowsBelow} rows below`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
|
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-15
@@ -923,21 +923,29 @@ const CellWrapper = memo(({
|
|||||||
|
|
||||||
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
|
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
|
||||||
{showCopyDownButton && (
|
{showCopyDownButton && (
|
||||||
<button
|
<TooltipProvider>
|
||||||
type="button"
|
<Tooltip>
|
||||||
onClick={handleStartCopyDown}
|
<TooltipTrigger asChild>
|
||||||
className={cn(
|
<button
|
||||||
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
|
type="button"
|
||||||
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
onClick={handleStartCopyDown}
|
||||||
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
|
className={cn(
|
||||||
'shadow-sm',
|
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
|
||||||
// Position further left if there are errors to avoid overlap
|
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
||||||
hasErrors ? 'right-7' : 'right-0.5'
|
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
|
||||||
)}
|
'shadow-sm',
|
||||||
title="Copy value to rows below"
|
// Position further left if there are errors to avoid overlap
|
||||||
>
|
hasErrors ? 'right-7' : 'right-0.5'
|
||||||
<ArrowDown className="h-3.5 w-3.5" />
|
)}
|
||||||
</button>
|
>
|
||||||
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Copy value to rows below
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* UPC Generate button - appears on hover for empty UPC cells */}
|
{/* UPC Generate button - appears on hover for empty UPC cells */}
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* Create Purchase Order page.
|
* Create / Add-to Purchase Order page.
|
||||||
*
|
*
|
||||||
* Lets the user pick a supplier and assemble a list of products via search,
|
* Supports two flows toggled at the top of the page:
|
||||||
* paste, or file upload, then submits the PO to the legacy PHP backend via
|
* - "create" → pick a supplier and assemble a list of products, then POST
|
||||||
* the existing /apiv2 proxy. On success, shows a confirmation view with a
|
* to /apiv2/po/new/{supplierId}.
|
||||||
* link to the new PO in the legacy admin.
|
* - "add" → enter an existing PO number and assemble a list of
|
||||||
|
* additional line items, then POST to
|
||||||
|
* /apiv2/po/add_products/{poId}.
|
||||||
|
*
|
||||||
|
* Both modes use the same product-add UX (Search/Paste/Upload) and the same
|
||||||
|
* line items table. The "add" mode does NOT pull existing items from the
|
||||||
|
* target PO — only the newly assembled items are sent to the backend.
|
||||||
*
|
*
|
||||||
* State model:
|
* State model:
|
||||||
* - supplierId → controlled string from SupplierSelector
|
* - mode → "create" | "add" (toggled via Tabs)
|
||||||
* - lineItems[] → the working list (PoLineItem; local-only fields
|
* - supplierId → controlled string, only used in create mode
|
||||||
* qty + moqOverride live here)
|
* - existingPoInput → controlled string, only used in add mode
|
||||||
* - selectedPids: Set → checkbox state for the bulk-remove flow
|
* - lineItems[] → working list (PoLineItem; local-only fields qty +
|
||||||
* - addOpen → AddProductsDialog visibility
|
* moqOverride live here). Cleared when mode changes.
|
||||||
* - submitting → submit button spinner
|
* - selectedPids: Set → checkbox state for the bulk-remove flow
|
||||||
* - confirmation → null while building; { poId, itemCount } after
|
* - addOpen → AddProductsDialog visibility
|
||||||
* a successful submit
|
* - submitting → submit button spinner
|
||||||
|
* - confirmation → null while building; { poId, itemCount, mode }
|
||||||
|
* after a successful submit.
|
||||||
*
|
*
|
||||||
* Dedup is enforced server-naive: when AddProductsDialog returns a list of
|
* 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
|
* (pid, qty) pairs, we filter out pids already on the working list and show
|
||||||
* a brief toast indicating how many were skipped. The user can edit existing
|
* a brief toast indicating how many were skipped. In "add" mode we do NOT
|
||||||
* rows manually if they want to bump quantities — the dialog never mutates
|
* dedup against the target PO (we don't fetch its current contents).
|
||||||
* existing rows.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Loader2, Plus } from "lucide-react";
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SupplierSelector } from "@/components/create-po/SupplierSelector";
|
import { SupplierSelector } from "@/components/create-po/SupplierSelector";
|
||||||
@@ -35,10 +46,17 @@ import { AddProductsDialog } from "@/components/create-po/AddProductsDialog";
|
|||||||
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
|
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
|
||||||
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
|
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
|
||||||
import type { PoLineItem } from "@/components/create-po/types";
|
import type { PoLineItem } from "@/components/create-po/types";
|
||||||
import { submitNewPurchaseOrder } from "@/services/apiv2";
|
import {
|
||||||
|
submitNewPurchaseOrder,
|
||||||
|
addProductsToPurchaseOrder,
|
||||||
|
} from "@/services/apiv2";
|
||||||
|
|
||||||
|
type Mode = "create" | "add";
|
||||||
|
|
||||||
export default function CreatePurchaseOrder() {
|
export default function CreatePurchaseOrder() {
|
||||||
|
const [mode, setMode] = useState<Mode>("create");
|
||||||
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
|
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
|
||||||
|
const [existingPoInput, setExistingPoInput] = useState<string>("");
|
||||||
const [lineItems, setLineItems] = useState<PoLineItem[]>([]);
|
const [lineItems, setLineItems] = useState<PoLineItem[]>([]);
|
||||||
const [selectedPids, setSelectedPids] = useState<Set<number>>(new Set());
|
const [selectedPids, setSelectedPids] = useState<Set<number>>(new Set());
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
@@ -47,8 +65,22 @@ export default function CreatePurchaseOrder() {
|
|||||||
const [confirmation, setConfirmation] = useState<{
|
const [confirmation, setConfirmation] = useState<{
|
||||||
poId: number;
|
poId: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
mode: Mode;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// ---- Mode toggle ----------------------------------------------------------
|
||||||
|
// Switching modes clears the working list and target identifiers — the two
|
||||||
|
// flows submit to different endpoints with different keys, so mixing state
|
||||||
|
// would just make for confusing UX.
|
||||||
|
const handleModeChange = useCallback((next: string) => {
|
||||||
|
if (next !== "create" && next !== "add") return;
|
||||||
|
setMode(next);
|
||||||
|
setSupplierId(undefined);
|
||||||
|
setExistingPoInput("");
|
||||||
|
setLineItems([]);
|
||||||
|
setSelectedPids(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---- Add products from any tab (Search/Paste/Upload) ----------------------
|
// ---- Add products from any tab (Search/Paste/Upload) ----------------------
|
||||||
const handleAddProducts = useCallback(
|
const handleAddProducts = useCallback(
|
||||||
async (items: Array<{ pid: number; qty: number }>) => {
|
async (items: Array<{ pid: number; qty: number }>) => {
|
||||||
@@ -157,14 +189,28 @@ export default function CreatePurchaseOrder() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- Submit ---------------------------------------------------------------
|
// ---- Submit ---------------------------------------------------------------
|
||||||
|
const validItems = lineItems
|
||||||
|
.filter((i) => i.qty > 0)
|
||||||
|
.map((i) => ({ pid: i.pid, qty: i.qty }));
|
||||||
|
|
||||||
|
const parsedPoId = (() => {
|
||||||
|
const trimmed = existingPoInput.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const n = Number(trimmed);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined;
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!supplierId) {
|
if (mode === "create" && !supplierId) {
|
||||||
toast.error("Pick a supplier first");
|
toast.error("Pick a supplier first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const validItems = lineItems
|
if (mode === "add" && parsedPoId === undefined) {
|
||||||
.filter((i) => i.qty > 0)
|
toast.error("Enter a valid PO number first");
|
||||||
.map((i) => ({ pid: i.pid, qty: i.qty }));
|
return;
|
||||||
|
}
|
||||||
if (validItems.length === 0) {
|
if (validItems.length === 0) {
|
||||||
toast.error("Add at least one product with a positive quantity");
|
toast.error("Add at least one product with a positive quantity");
|
||||||
return;
|
return;
|
||||||
@@ -172,27 +218,55 @@ export default function CreatePurchaseOrder() {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await submitNewPurchaseOrder({ supplierId, items: validItems });
|
if (mode === "create") {
|
||||||
if (!res.success || !res.poId) {
|
const res = await submitNewPurchaseOrder({
|
||||||
const msg =
|
supplierId: supplierId!,
|
||||||
(typeof res.error === "string" && res.error) ||
|
items: validItems,
|
||||||
res.message ||
|
});
|
||||||
"PO submission failed";
|
if (!res.success || !res.poId) {
|
||||||
toast.error(msg);
|
const msg =
|
||||||
return;
|
(typeof res.error === "string" && res.error) ||
|
||||||
|
res.message ||
|
||||||
|
"PO submission failed";
|
||||||
|
toast.error(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmation({
|
||||||
|
poId: res.poId,
|
||||||
|
itemCount: validItems.length,
|
||||||
|
mode: "create",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await addProductsToPurchaseOrder({
|
||||||
|
poId: parsedPoId!,
|
||||||
|
items: validItems,
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
const msg =
|
||||||
|
(typeof res.error === "string" && res.error) ||
|
||||||
|
res.message ||
|
||||||
|
"Failed to add products to PO";
|
||||||
|
toast.error(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmation({
|
||||||
|
poId: parsedPoId!,
|
||||||
|
itemCount: validItems.length,
|
||||||
|
mode: "add",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setConfirmation({ poId: res.poId, itemCount: validItems.length });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error(e instanceof Error ? e.message : "PO submission failed");
|
toast.error(e instanceof Error ? e.message : "Submission failed");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [supplierId, lineItems]);
|
}, [mode, supplierId, parsedPoId, validItems]);
|
||||||
|
|
||||||
// ---- Reset for "Create another" -------------------------------------------
|
// ---- Reset for "Create another" / "Add more" ------------------------------
|
||||||
const handleCreateAnother = useCallback(() => {
|
const handleCreateAnother = useCallback(() => {
|
||||||
setSupplierId(undefined);
|
setSupplierId(undefined);
|
||||||
|
setExistingPoInput("");
|
||||||
setLineItems([]);
|
setLineItems([]);
|
||||||
setSelectedPids(new Set());
|
setSelectedPids(new Set());
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
@@ -205,6 +279,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
<ConfirmationView
|
<ConfirmationView
|
||||||
poId={confirmation.poId}
|
poId={confirmation.poId}
|
||||||
itemCount={confirmation.itemCount}
|
itemCount={confirmation.itemCount}
|
||||||
|
mode={confirmation.mode}
|
||||||
onCreateAnother={handleCreateAnother}
|
onCreateAnother={handleCreateAnother}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,19 +294,53 @@ export default function CreatePurchaseOrder() {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pageTitle =
|
||||||
|
mode === "create" ? "Create Purchase Order" : "Add to Purchase Order";
|
||||||
|
const targetCardTitle = mode === "create" ? "Supplier" : "Existing PO";
|
||||||
|
const submitLabel =
|
||||||
|
mode === "create" ? "Create purchase order" : "Add products to PO";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-4">
|
<div className="container mx-auto p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">Create Purchase Order</h1>
|
<h1 className="text-2xl font-semibold">{pageTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="create">Create new PO</TabsTrigger>
|
||||||
|
<TabsTrigger value="add">Add to existing PO</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Supplier</CardTitle>
|
<CardTitle>{targetCardTitle}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<SupplierSelector value={supplierId} onChange={setSupplierId} />
|
{mode === "create" ? (
|
||||||
|
<SupplierSelector value={supplierId} onChange={setSupplierId} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
id="existing-po-id"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
placeholder="PO number"
|
||||||
|
value={existingPoInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
setExistingPoInput(e.target.value.replace(/[^0-9]/g, ""))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{existingPoInput.trim() !== "" && parsedPoId === undefined && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Enter a valid positive PO number.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -270,7 +379,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
<LineItemsTable
|
<LineItemsTable
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
selectedPids={selectedPids}
|
selectedPids={selectedPids}
|
||||||
supplierId={supplierId ? Number(supplierId) : undefined}
|
supplierId={mode === "create" && supplierId ? Number(supplierId) : undefined}
|
||||||
onToggleSelect={handleToggleSelect}
|
onToggleSelect={handleToggleSelect}
|
||||||
onToggleSelectAll={handleToggleSelectAll}
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
onChangeQty={handleChangeQty}
|
onChangeQty={handleChangeQty}
|
||||||
@@ -284,7 +393,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting || lineItems.length === 0 || !supplierId}
|
disabled={submitting || lineItems.length === 0 || !targetReady}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -292,9 +401,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
Submitting…
|
Submitting…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>{submitLabel}</>
|
||||||
Create purchase order
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Search, Loader2, PackageOpen, Copy, Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type NumericAggregate = {
|
||||||
|
count: number;
|
||||||
|
sample_size: number;
|
||||||
|
avg?: number;
|
||||||
|
median?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
stddev?: number;
|
||||||
|
mode?: number | null;
|
||||||
|
mode_count?: number;
|
||||||
|
recent_mode?: number | null;
|
||||||
|
recent_mode_count?: number;
|
||||||
|
recent_window?: number;
|
||||||
|
trending?: boolean;
|
||||||
|
distribution: { value: number; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoricalEntry = { value: string; count: number };
|
||||||
|
|
||||||
|
type DescriptionAggregate = {
|
||||||
|
duplicates: { value: string; count: number }[];
|
||||||
|
samples: { value: string; title: string; pid: number; sku: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductRow = {
|
||||||
|
pid: number;
|
||||||
|
title: string;
|
||||||
|
sku: string;
|
||||||
|
brand: string | null;
|
||||||
|
vendor: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
country_of_origin: string | null;
|
||||||
|
harmonized_tariff_code: string | null;
|
||||||
|
description: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
cost_price: number | null;
|
||||||
|
regular_price: number | null;
|
||||||
|
moq: number | null;
|
||||||
|
weight: number | null;
|
||||||
|
length: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpecLookupResponse = {
|
||||||
|
company: string;
|
||||||
|
term: string;
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
products: ProductRow[];
|
||||||
|
aggregates: {
|
||||||
|
numeric: Record<string, NumericAggregate>;
|
||||||
|
categorical: Record<string, CategoricalEntry[]>;
|
||||||
|
categories: CategoricalEntry[];
|
||||||
|
themes: CategoricalEntry[];
|
||||||
|
description: DescriptionAggregate;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type NumericFieldDef = {
|
||||||
|
key: keyof SpecLookupResponse["aggregates"]["numeric"] | string;
|
||||||
|
label: string;
|
||||||
|
format: (n: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMERIC_FIELDS: NumericFieldDef[] = [
|
||||||
|
{ key: "moq", label: "Min Qty", format: (n) => formatInt(n) },
|
||||||
|
{ key: "cost_price", label: "Wholesale", format: (n) => formatCurrency(n) },
|
||||||
|
{ key: "regular_price", label: "MSRP", format: (n) => formatCurrency(n) },
|
||||||
|
{ key: "weight", label: "Weight (oz)", format: (n) => `${formatNumber(n)} oz` },
|
||||||
|
{ key: "length", label: "Length (in)", format: (n) => `${formatNumber(n)} in` },
|
||||||
|
{ key: "width", label: "Width (in)", format: (n) => `${formatNumber(n)} in` },
|
||||||
|
{ key: "height", label: "Height (in)", format: (n) => `${formatNumber(n)} in` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORICAL_FIELDS: { key: string; label: string }[] = [
|
||||||
|
{ key: "artist", label: "Artist" },
|
||||||
|
{ key: "country_of_origin", label: "COO" },
|
||||||
|
{ key: "harmonized_tariff_code", label: "HTS Code" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (Math.abs(n) >= 100) return n.toFixed(1);
|
||||||
|
if (Math.abs(n) >= 1) return n.toFixed(2);
|
||||||
|
return n.toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInt(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(n: number): string {
|
||||||
|
return `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpecLookup() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [company, setCompany] = useState("");
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
const [companyOpen, setCompanyOpen] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState<{ company: string; term: string } | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { data: brandsData } = useQuery<{ brands: string[] }>({
|
||||||
|
queryKey: ["spec-lookup-brands"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`/api/brands-aggregate/filter-options`);
|
||||||
|
if (!response.ok) throw new Error("Failed to load brands");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const brands = brandsData?.brands ?? [];
|
||||||
|
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => ["spec-lookup", submitted?.company ?? "", submitted?.term ?? ""],
|
||||||
|
[submitted],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error, isFetching, isFetched, refetch } = useQuery<SpecLookupResponse>({
|
||||||
|
queryKey,
|
||||||
|
enabled: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (submitted?.company) params.set("company", submitted.company);
|
||||||
|
if (submitted?.term) params.set("term", submitted.term);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/spec-lookup?${params.toString()}`);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof payload.error === "string" ? payload.error : "Failed to fetch spec lookup";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return payload as SpecLookupResponse;
|
||||||
|
},
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitted) void refetch();
|
||||||
|
}, [submitted, refetch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast({ title: "Search failed", description: error.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
const handleSubmit = (event?: FormEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const trimmedCompany = company.trim();
|
||||||
|
const trimmedTerm = term.trim();
|
||||||
|
|
||||||
|
if (!trimmedCompany && !trimmedTerm) {
|
||||||
|
toast({ title: "Enter a company or product type" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted?.company === trimmedCompany && submitted?.term === trimmedTerm) {
|
||||||
|
void refetch();
|
||||||
|
} else {
|
||||||
|
setSubmitted({ company: trimmedCompany, term: trimmedTerm });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (event: MouseEvent<HTMLButtonElement>, key: string, value: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!navigator?.clipboard) {
|
||||||
|
toast({ title: "Clipboard unavailable", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current);
|
||||||
|
setCopied(key);
|
||||||
|
copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Copy failed",
|
||||||
|
description: err instanceof Error ? err.message : "Unable to copy",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmpty = (label: string) => (
|
||||||
|
<div className="text-xs italic text-muted-foreground">No {label} data in matches</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNumericCard = (def: NumericFieldDef) => {
|
||||||
|
const agg = data?.aggregates.numeric[def.key];
|
||||||
|
const hasData = agg && agg.count > 0;
|
||||||
|
const modeKey = `num:${def.key}`;
|
||||||
|
// When we've detected a recent shift, the recommended (headline) value becomes the recent mode
|
||||||
|
// — but the overall mode and full distribution are still shown below for verification.
|
||||||
|
const recommendedValue = !hasData
|
||||||
|
? null
|
||||||
|
: agg.trending && agg.recent_mode != null
|
||||||
|
? agg.recent_mode
|
||||||
|
: agg.mode;
|
||||||
|
const formattedRecommended =
|
||||||
|
hasData && recommendedValue != null ? def.format(recommendedValue) : "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={def.key}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CardDescription>{def.label}</CardDescription>
|
||||||
|
{hasData && agg.trending && (
|
||||||
|
<Badge variant="default" className="h-4 px-1.5 text-[9px] font-medium uppercase">
|
||||||
|
Recent shift
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasData && recommendedValue != null && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={copied === modeKey ? "secondary" : "ghost"}
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-label={`Copy ${def.label}`}
|
||||||
|
onClick={(e) => handleCopy(e, modeKey, String(recommendedValue))}
|
||||||
|
>
|
||||||
|
{copied === modeKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{formattedRecommended}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
{agg.trending && agg.recent_mode != null ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
Recent: <span className="text-foreground font-medium">{def.format(agg.recent_mode)}</span> ({agg.recent_mode_count} of last {agg.recent_window})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Overall mode was <span className="text-foreground">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
Mode: <span className="text-foreground font-medium">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
Median: <span className="text-foreground">{def.format(agg.median!)}</span>
|
||||||
|
{" · "}
|
||||||
|
Avg: <span className="text-foreground">{def.format(agg.avg!)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Range: {def.format(agg.min!)} – {def.format(agg.max!)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{agg.count} of {agg.sample_size} products have this set
|
||||||
|
</div>
|
||||||
|
{agg.distribution.length > 1 && (
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="dist" className="border-none">
|
||||||
|
<AccordionTrigger className="py-1 text-xs">Distribution ({agg.distribution.length} distinct)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="max-h-56 overflow-auto pr-2">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-7">Value</TableHead>
|
||||||
|
<TableHead className="h-7 text-right">Count</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{agg.distribution.map((d) => (
|
||||||
|
<TableRow key={`${def.key}-${d.value}`}>
|
||||||
|
<TableCell className="py-1 font-mono">{def.format(d.value)}</TableCell>
|
||||||
|
<TableCell className="py-1 text-right">{d.count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
renderEmpty(def.label)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCategoricalCard = (key: string, label: string) => {
|
||||||
|
const entries = data?.aggregates.categorical[key] ?? [];
|
||||||
|
const top = entries[0];
|
||||||
|
const cardKey = `cat:${key}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={key}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
{top && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={copied === cardKey ? "secondary" : "ghost"}
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-label={`Copy top ${label}`}
|
||||||
|
onClick={(e) => handleCopy(e, cardKey, top.value)}
|
||||||
|
>
|
||||||
|
{copied === cardKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base break-words">{top ? top.value : "—"}</CardTitle>
|
||||||
|
{top && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{top.count} of {data?.total ?? 0} products
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
renderEmpty(label)
|
||||||
|
) : entries.length === 1 ? (
|
||||||
|
<div>Only one distinct value across matches.</div>
|
||||||
|
) : (
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="all" className="border-none">
|
||||||
|
<AccordionTrigger className="py-1 text-xs">All values ({entries.length})</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="max-h-56 overflow-auto pr-2">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-7">Value</TableHead>
|
||||||
|
<TableHead className="h-7 text-right">Count</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<TableRow key={`${key}-${e.value}`}>
|
||||||
|
<TableCell className="py-1 break-all">{e.value}</TableCell>
|
||||||
|
<TableCell className="py-1 text-right">{e.count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTokenCard = (
|
||||||
|
title: string,
|
||||||
|
emptyLabel: string,
|
||||||
|
entries: CategoricalEntry[],
|
||||||
|
) => {
|
||||||
|
const dominantThreshold = Math.max(2, (data?.total ?? 0) * 0.5);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
renderEmpty(emptyLabel)
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<Badge
|
||||||
|
key={e.value}
|
||||||
|
variant={e.count >= dominantThreshold ? "default" : "outline"}
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
{e.value}
|
||||||
|
<span className="ml-1.5 text-[10px] opacity-70">×{e.count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDescriptionCard = () => {
|
||||||
|
const desc = data?.aggregates.description;
|
||||||
|
if (!desc) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Description</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Used More Than Once {desc.duplicates.length > 0 && `(${desc.duplicates.length})`}
|
||||||
|
</div>
|
||||||
|
{desc.duplicates.length === 0 ? (
|
||||||
|
<div className="text-xs italic text-muted-foreground">No description was used more than once.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-96 space-y-2 overflow-auto pr-2">
|
||||||
|
{desc.duplicates.map((d, i) => (
|
||||||
|
<div key={`dup-${i}`} className="rounded-md border p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<Badge variant="default">Used {d.count}× </Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={copied === `dup:${i}` ? "secondary" : "ghost"}
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-label="Copy description"
|
||||||
|
onClick={(e) => handleCopy(e, `dup:${i}`, d.value)}
|
||||||
|
>
|
||||||
|
{copied === `dup:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap text-sm">{d.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Recent samples
|
||||||
|
</div>
|
||||||
|
{desc.samples.length === 0 ? (
|
||||||
|
<div className="text-xs italic text-muted-foreground">No descriptions in matches.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-96 space-y-2 overflow-auto pr-2">
|
||||||
|
{desc.samples.map((s, i) => (
|
||||||
|
<div key={`sample-${s.pid}`} className="rounded-md border p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${s.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={copied === `sample:${i}` ? "secondary" : "ghost"}
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-label="Copy description"
|
||||||
|
onClick={(e) => handleCopy(e, `sample:${i}`, s.value)}
|
||||||
|
>
|
||||||
|
{copied === `sample:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap text-sm">{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProductsTable = () => {
|
||||||
|
if (!data || data.products.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Matched products ({data.total})</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data.truncated ? "Showing first 500 matches (newest first)." : ""}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="max-h-96 overflow-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>SKU</TableHead>
|
||||||
|
<TableHead>Brand</TableHead>
|
||||||
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
|
<TableHead className="text-right">MSRP</TableHead>
|
||||||
|
<TableHead className="text-right">Wt</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.products.map((p) => (
|
||||||
|
<TableRow key={p.pid}>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${p.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{p.sku}</TableCell>
|
||||||
|
<TableCell>{p.brand || "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{p.cost_price != null ? formatCurrency(p.cost_price) : "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{p.regular_price != null ? formatCurrency(p.regular_price) : "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{p.weight != null ? formatNumber(p.weight) : "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">Spec Lookup</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Use this to compare existing values across similar products when setting up a new product.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Search</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="pd-company">Company</Label>
|
||||||
|
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
id="pd-company"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={companyOpen}
|
||||||
|
className={cn("w-full justify-between font-normal", !company && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
<span className="truncate">{company || "Select company..."}</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{company && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Clear company"
|
||||||
|
className="rounded hover:bg-muted"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCompany("");
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCompany("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 opacity-60" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search companies..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matching company.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{brands.map((b) => (
|
||||||
|
<CommandItem
|
||||||
|
key={b}
|
||||||
|
value={b}
|
||||||
|
onSelect={(value) => {
|
||||||
|
setCompany(value === company ? "" : value);
|
||||||
|
setCompanyOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", company === b ? "opacity-100" : "opacity-0")} />
|
||||||
|
{b}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="pd-term">Product Type</Label>
|
||||||
|
<Input
|
||||||
|
id="pd-term"
|
||||||
|
placeholder="Paper pad, washi, stickers, etc."
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={isFetching}>
|
||||||
|
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
<span className="ml-2">Search</span>
|
||||||
|
</Button>
|
||||||
|
{isFetched && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isFetching}
|
||||||
|
onClick={() => {
|
||||||
|
setCompany("");
|
||||||
|
setTerm("");
|
||||||
|
setSubmitted(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isFetched && data && data.total === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-3 py-8 text-center text-muted-foreground">
|
||||||
|
<PackageOpen className="mx-auto h-10 w-10" />
|
||||||
|
<div>No products matched.</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFetched && data && data.total > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Matched products</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">{data.total}</CardTitle>
|
||||||
|
{data.truncated && (
|
||||||
|
<div className="text-xs text-muted-foreground">capped at 500 — refine search to narrow</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Company filter</CardDescription>
|
||||||
|
<CardTitle className="text-base break-words">{data.company || "(any)"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Product type</CardDescription>
|
||||||
|
<CardTitle className="text-base break-words">{data.term || "(any)"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Numeric fields</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{NUMERIC_FIELDS.map(renderNumericCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Categorical fields</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{CATEGORICAL_FIELDS.map((f) => renderCategoricalCard(f.key, f.label))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{renderTokenCard(
|
||||||
|
"Categories",
|
||||||
|
"category",
|
||||||
|
data.aggregates.categories,
|
||||||
|
)}
|
||||||
|
{renderTokenCard(
|
||||||
|
"Themes",
|
||||||
|
"theme",
|
||||||
|
data.aggregates.themes ?? [],
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderDescriptionCard()}
|
||||||
|
{renderProductsTable()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,18 @@ export interface SubmitNewPurchaseOrderResponse {
|
|||||||
raw?: unknown;
|
raw?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddProductsToPurchaseOrderArgs {
|
||||||
|
poId: number | string;
|
||||||
|
items: PoLineItemSubmit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddProductsToPurchaseOrderResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: unknown;
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProductCategoryArgs {
|
export interface CreateProductCategoryArgs {
|
||||||
masterCatId: string | number;
|
masterCatId: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -322,3 +334,81 @@ export async function submitNewPurchaseOrder({
|
|||||||
raw: parsed,
|
raw: parsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds line items to an existing purchase order on the legacy PHP backend.
|
||||||
|
*
|
||||||
|
* Mirrors `submitNewPurchaseOrder` exactly except the URL takes a po_id path
|
||||||
|
* param and we don't expect (or use) a po_id in the response. Same
|
||||||
|
* URL-encoded body shape, same cookie-auth flow, same HTML-response guard.
|
||||||
|
*/
|
||||||
|
export async function addProductsToPurchaseOrder({
|
||||||
|
poId,
|
||||||
|
items,
|
||||||
|
}: AddProductsToPurchaseOrderArgs): Promise<AddProductsToPurchaseOrderResponse> {
|
||||||
|
const poIdNum = Number(poId);
|
||||||
|
if (!Number.isInteger(poIdNum) || poIdNum <= 0) {
|
||||||
|
throw new Error("A valid PO number is required");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
throw new Error("At least one item is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanItems = items
|
||||||
|
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
|
||||||
|
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
|
||||||
|
|
||||||
|
if (cleanItems.length === 0) {
|
||||||
|
throw new Error("No valid items to submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `/apiv2/po/add_products/${encodeURIComponent(String(poIdNum))}`;
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.append("items", JSON.stringify(cleanItems));
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch (networkError) {
|
||||||
|
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
|
||||||
|
if (isHtmlResponse(rawBody)) {
|
||||||
|
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Empty response from backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const backendSuccess =
|
||||||
|
record.success === true ||
|
||||||
|
record.success === "true" ||
|
||||||
|
record.success === 1;
|
||||||
|
const success = response.ok && (backendSuccess || record.success === undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
message: typeof record.message === "string" ? record.message : undefined,
|
||||||
|
error: record.error ?? record.errors ?? record.error_msg,
|
||||||
|
raw: parsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
if (useRsync) {
|
if (useRsync) {
|
||||||
// Use rsync over SSH - much faster than sshfs copying
|
// Use rsync over SSH - much faster than sshfs copying
|
||||||
const deployTarget = process.env.DEPLOY_TARGET;
|
const deployTarget = process.env.DEPLOY_TARGET;
|
||||||
const targetPath = process.env.DEPLOY_PATH || '/var/www/html/inventory/inventory-server/frontend';
|
const targetPath = process.env.DEPLOY_PATH || '/var/www/inventory/inventory-server/frontend';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Deploying to ${deployTarget}:${targetPath}...`);
|
console.log(`Deploying to ${deployTarget}:${targetPath}...`);
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
umount '/Users/matt/Dev/inventory/inventory-server'
|
umount '/Users/matt/Dev/inventory/inventory-server'
|
||||||
|
|
||||||
#Mount
|
#Mount
|
||||||
sshfs matt@159.195.13.70:/var/www/html/inventory '/Users/matt/Dev/inventory/inventory-server/'
|
sshfs matt@159.195.13.70:/var/www/inventory '/Users/matt/Dev/inventory/inventory-server/'
|
||||||
Reference in New Issue
Block a user