Compare commits
5 Commits
217abd41af
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b2b4e421 | |||
| b81dfb9649 | |||
| 9be0f34f07 | |||
| ad5b797ce6 | |||
| 78932360d1 |
@@ -13,6 +13,13 @@ const {
|
||||
const TIMEZONE = 'America/New_York';
|
||||
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
|
||||
|
||||
// Cherry Box order types to exclude when excludeCherryBox=true is passed
|
||||
// 3 = cherrybox_subscription, 4 = cherrybox_sending, 5 = cherrybox_subscription_renew, 7 = cherrybox_refund
|
||||
const EXCLUDED_ORDER_TYPES = [3, 4, 5, 7];
|
||||
const getCherryBoxClause = (exclude) => exclude ? `order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
||||
const getCherryBoxClauseAliased = (alias, exclude) => exclude ? `${alias}.order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
||||
const parseBoolParam = (value) => value === 'true' || value === '1';
|
||||
|
||||
// Image URL generation utility
|
||||
const getImageUrls = (pid, iid = 1) => {
|
||||
const imageUrlBase = 'https://sbing.com/i/products/0000/';
|
||||
@@ -39,14 +46,15 @@ router.get('/stats', async (req, res) => {
|
||||
|
||||
try {
|
||||
const mainOperation = async () => {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
console.log(`[STATS] Getting DB connection...`);
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
||||
const { connection, release } = await getDbConnection();
|
||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||
|
||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Main order stats query
|
||||
// Main order stats query (optionally excludes Cherry Box orders)
|
||||
const mainStatsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as orderCount,
|
||||
@@ -61,32 +69,32 @@ router.get('/stats', async (req, res) => {
|
||||
SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount,
|
||||
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
||||
const stats = mainStats[0];
|
||||
|
||||
// Refunds query
|
||||
// Refunds query (optionally excludes Cherry Box orders)
|
||||
const refundsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as refundCount,
|
||||
ABS(SUM(payment_amount)) as refundTotal
|
||||
FROM order_payment op
|
||||
JOIN _order o ON op.order_id = o.order_id
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
`;
|
||||
|
||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||
|
||||
// Best revenue day query
|
||||
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||
const bestDayQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
SUM(summary_total) as revenue,
|
||||
COUNT(*) as orders
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 1
|
||||
@@ -94,7 +102,7 @@ router.get('/stats', async (req, res) => {
|
||||
|
||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
||||
|
||||
// Peak hour query (for single day periods)
|
||||
// Peak hour query (for single day periods, optionally excludes Cherry Box orders)
|
||||
let peakHour = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
const peakHourQuery = `
|
||||
@@ -102,7 +110,7 @@ router.get('/stats', async (req, res) => {
|
||||
HOUR(date_placed) as hour,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY HOUR(date_placed)
|
||||
ORDER BY count DESC
|
||||
LIMIT 1
|
||||
@@ -122,7 +130,7 @@ router.get('/stats', async (req, res) => {
|
||||
}
|
||||
|
||||
// Brands and categories query - simplified for now since we don't have the category tables
|
||||
// We'll use a simple approach without company table for now
|
||||
// We'll use a simple approach without company table for now (optionally excludes Cherry Box orders)
|
||||
const brandsQuery = `
|
||||
SELECT
|
||||
'Various Brands' as brandName,
|
||||
@@ -132,13 +140,13 @@ router.get('/stats', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
HAVING revenue > 0
|
||||
`;
|
||||
|
||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
||||
|
||||
// For categories, we'll use a simplified approach
|
||||
// For categories, we'll use a simplified approach (optionally excludes Cherry Box orders)
|
||||
const categoriesQuery = `
|
||||
SELECT
|
||||
'General' as categoryName,
|
||||
@@ -148,13 +156,13 @@ router.get('/stats', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
HAVING revenue > 0
|
||||
`;
|
||||
|
||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
||||
|
||||
// Shipping locations query
|
||||
// Shipping locations query (optionally excludes Cherry Box orders)
|
||||
const shippingQuery = `
|
||||
SELECT
|
||||
ship_country,
|
||||
@@ -162,7 +170,7 @@ router.get('/stats', async (req, res) => {
|
||||
ship_method_selected,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status IN (100, 92) AND ${whereClause}
|
||||
WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY ship_country, ship_state, ship_method_selected
|
||||
`;
|
||||
|
||||
@@ -171,13 +179,13 @@ router.get('/stats', async (req, res) => {
|
||||
// Process shipping data
|
||||
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
||||
|
||||
// Order value range query
|
||||
// Order value range query (optionally excludes Cherry Box orders)
|
||||
const orderRangeQuery = `
|
||||
SELECT
|
||||
MIN(summary_total) as smallest,
|
||||
MAX(summary_total) as largest
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
||||
@@ -189,7 +197,7 @@ router.get('/stats', async (req, res) => {
|
||||
}
|
||||
|
||||
// Previous period comparison data
|
||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate);
|
||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
||||
|
||||
const response = {
|
||||
timeRange: dateRange,
|
||||
@@ -316,13 +324,14 @@ router.get('/stats', async (req, res) => {
|
||||
router.get('/stats/details', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate, metric, daily } = req.query;
|
||||
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Daily breakdown query
|
||||
// Daily breakdown query (optionally excludes Cherry Box orders)
|
||||
const dailyQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
@@ -331,7 +340,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
AVG(summary_total) as averageOrderValue,
|
||||
SUM(stats_prod_pieces) as itemCount
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
ORDER BY DATE(date_placed)
|
||||
`;
|
||||
@@ -359,7 +368,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
}
|
||||
|
||||
// Get previous period daily data
|
||||
// Get previous period daily data (optionally excludes Cherry Box orders)
|
||||
const prevQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
@@ -367,7 +376,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
SUM(summary_total) as prevRevenue,
|
||||
AVG(summary_total) as prevAvgOrderValue
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${prevWhereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
`;
|
||||
|
||||
@@ -424,7 +433,8 @@ router.get('/stats/details', async (req, res) => {
|
||||
router.get('/financials', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
@@ -450,7 +460,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
|
||||
const [totalsRows] = await connection.execute(
|
||||
buildFinancialTotalsQuery(financialWhere),
|
||||
buildFinancialTotalsQuery(financialWhere, excludeCB),
|
||||
params
|
||||
);
|
||||
|
||||
@@ -462,7 +472,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
|
||||
const [trendRows] = await connection.execute(
|
||||
buildFinancialTrendQuery(financialWhere),
|
||||
buildFinancialTrendQuery(financialWhere, excludeCB),
|
||||
params
|
||||
);
|
||||
|
||||
@@ -489,7 +499,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
|
||||
const [previousRows] = await connection.execute(
|
||||
buildFinancialTotalsQuery(prevWhere),
|
||||
buildFinancialTotalsQuery(prevWhere, excludeCB),
|
||||
previousRange.params
|
||||
);
|
||||
previousTotals = normalizeFinancialTotals(previousRows[0]);
|
||||
@@ -549,12 +559,14 @@ router.get('/financials', async (req, res) => {
|
||||
router.get('/products', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Products query (optionally excludes Cherry Box orders)
|
||||
const productsQuery = `
|
||||
SELECT
|
||||
p.pid,
|
||||
@@ -566,7 +578,7 @@ router.get('/products', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
GROUP BY p.pid, p.description
|
||||
ORDER BY totalRevenue DESC
|
||||
LIMIT 500
|
||||
@@ -609,7 +621,8 @@ router.get('/products', async (req, res) => {
|
||||
router.get('/projection', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
|
||||
// Only provide projections for incomplete periods
|
||||
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||
@@ -622,19 +635,20 @@ router.get('/projection', async (req, res) => {
|
||||
// Get current period data
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Current period query (optionally excludes Cherry Box orders)
|
||||
const currentQuery = `
|
||||
SELECT
|
||||
SUM(summary_total) as currentRevenue,
|
||||
COUNT(*) as currentOrders
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [currentResult] = await connection.execute(currentQuery, params);
|
||||
const current = currentResult[0];
|
||||
|
||||
// Get historical data for the same period type
|
||||
const historicalQuery = await getHistoricalProjectionData(connection, timeRange);
|
||||
const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB);
|
||||
|
||||
// Calculate projection based on current progress and historical patterns
|
||||
const periodProgress = calculatePeriodProgress(timeRange);
|
||||
@@ -765,7 +779,24 @@ function calculatePeriodProgress(timeRange) {
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
||||
}
|
||||
|
||||
function buildFinancialTotalsQuery(whereClause) {
|
||||
function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
|
||||
// Optionally join to _order to exclude Cherry Box orders
|
||||
if (excludeCherryBox) {
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(SUM(r.sale_amount), 0) as grossSales,
|
||||
COALESCE(SUM(r.refund_amount), 0) as refunds,
|
||||
COALESCE(SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount), 0) as shippingFees,
|
||||
COALESCE(SUM(r.tax_collected_amount), 0) as taxCollected,
|
||||
COALESCE(SUM(r.discount_total_amount), 0) as discounts,
|
||||
COALESCE(SUM(r.cogs_amount), 0) as cogs
|
||||
FROM report_sales_data r
|
||||
JOIN _order o ON r.order_id = o.order_id
|
||||
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
||||
AND r.action IN (1, 2, 3)
|
||||
AND ${getCherryBoxClauseAliased('o', true)}
|
||||
`;
|
||||
}
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(SUM(sale_amount), 0) as grossSales,
|
||||
@@ -780,8 +811,31 @@ function buildFinancialTotalsQuery(whereClause) {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildFinancialTrendQuery(whereClause) {
|
||||
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
|
||||
const businessDayOffset = BUSINESS_DAY_START_HOUR;
|
||||
// Optionally join to _order to exclude Cherry Box orders
|
||||
if (excludeCherryBox) {
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
|
||||
'%Y-%m-%d'
|
||||
) as businessDate,
|
||||
SUM(r.sale_amount) as grossSales,
|
||||
SUM(r.refund_amount) as refunds,
|
||||
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
|
||||
SUM(r.tax_collected_amount) as taxCollected,
|
||||
SUM(r.discount_total_amount) as discounts,
|
||||
SUM(r.cogs_amount) as cogs
|
||||
FROM report_sales_data r
|
||||
JOIN _order o ON r.order_id = o.order_id
|
||||
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
||||
AND r.action IN (1, 2, 3)
|
||||
AND ${getCherryBoxClauseAliased('o', true)}
|
||||
GROUP BY businessDate
|
||||
ORDER BY businessDate ASC
|
||||
`;
|
||||
}
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
@@ -940,7 +994,7 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||
}
|
||||
|
||||
async function getPreviousPeriodData(connection, timeRange, startDate, endDate) {
|
||||
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
|
||||
// Calculate previous period dates
|
||||
let prevWhereClause, prevParams;
|
||||
|
||||
@@ -962,13 +1016,14 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate)
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
}
|
||||
|
||||
// Previous period query (optionally excludes Cherry Box orders)
|
||||
const prevQuery = `
|
||||
SELECT
|
||||
COUNT(*) as orderCount,
|
||||
SUM(summary_total) as revenue,
|
||||
AVG(summary_total) as averageOrderValue
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${prevWhereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} AND ${prevWhereClause}
|
||||
`;
|
||||
|
||||
const [prevResult] = await connection.execute(prevQuery, prevParams);
|
||||
@@ -994,8 +1049,8 @@ function getPreviousTimeRange(timeRange) {
|
||||
return map[timeRange] || timeRange;
|
||||
}
|
||||
|
||||
async function getHistoricalProjectionData(connection, timeRange) {
|
||||
// Get historical data for projection calculations
|
||||
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
|
||||
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
|
||||
// This is a simplified version - you could make this more sophisticated
|
||||
const historicalQuery = `
|
||||
SELECT
|
||||
@@ -1003,6 +1058,7 @@ async function getHistoricalProjectionData(connection, timeRange) {
|
||||
COUNT(*) as orders
|
||||
FROM _order
|
||||
WHERE order_status > 15
|
||||
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
||||
`;
|
||||
|
||||
170
inventory-server/src/routes/hts-lookup.js
Normal file
170
inventory-server/src/routes/hts-lookup.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/hts-lookup?search=term
|
||||
// Finds matching products and groups them by harmonized tariff code
|
||||
router.get('/', async (req, res) => {
|
||||
const searchTerm = typeof req.query.search === 'string' ? req.query.search.trim() : '';
|
||||
|
||||
if (!searchTerm) {
|
||||
return res.status(400).json({ error: 'Search term is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
const likeTerm = `%${searchTerm}%`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
WITH matched_products AS (
|
||||
SELECT
|
||||
pid,
|
||||
title,
|
||||
sku,
|
||||
barcode,
|
||||
brand,
|
||||
vendor,
|
||||
harmonized_tariff_code,
|
||||
NULLIF(
|
||||
LOWER(
|
||||
REGEXP_REPLACE(
|
||||
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), ''),
|
||||
'[^0-9A-Za-z]',
|
||||
'',
|
||||
'g'
|
||||
)
|
||||
),
|
||||
''
|
||||
) AS normalized_code
|
||||
FROM products
|
||||
WHERE visible = TRUE
|
||||
AND (
|
||||
title ILIKE $1
|
||||
OR sku ILIKE $1
|
||||
OR barcode ILIKE $1
|
||||
OR vendor ILIKE $1
|
||||
OR brand ILIKE $1
|
||||
OR vendor_reference ILIKE $1
|
||||
OR harmonized_tariff_code ILIKE $1
|
||||
)
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), 'Unspecified') AS harmonized_tariff_code,
|
||||
normalized_code,
|
||||
COUNT(*)::INT AS product_count,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'pid', pid,
|
||||
'title', title,
|
||||
'sku', sku,
|
||||
'barcode', barcode,
|
||||
'brand', brand,
|
||||
'vendor', vendor
|
||||
)
|
||||
ORDER BY title
|
||||
) AS products
|
||||
FROM matched_products
|
||||
GROUP BY
|
||||
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), 'Unspecified'),
|
||||
normalized_code
|
||||
),
|
||||
hts_lookup AS (
|
||||
SELECT
|
||||
h."HTS Number" AS hts_number,
|
||||
h."Indent" AS indent,
|
||||
h."Description" AS description,
|
||||
h."Unit of Quantity" AS unit_of_quantity,
|
||||
h."General Rate of Duty" AS general_rate_of_duty,
|
||||
h."Special Rate of Duty" AS special_rate_of_duty,
|
||||
h."Column 2 Rate of Duty" AS column2_rate_of_duty,
|
||||
h."Quota Quantity" AS quota_quantity,
|
||||
h."Additional Duties" AS additional_duties,
|
||||
NULLIF(
|
||||
LOWER(
|
||||
REGEXP_REPLACE(
|
||||
COALESCE(h."HTS Number", ''),
|
||||
'[^0-9A-Za-z]',
|
||||
'',
|
||||
'g'
|
||||
)
|
||||
),
|
||||
''
|
||||
) AS normalized_hts_number
|
||||
FROM htsdata h
|
||||
)
|
||||
SELECT
|
||||
g.harmonized_tariff_code,
|
||||
g.product_count,
|
||||
g.products,
|
||||
hts.hts_details
|
||||
FROM grouped g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'hts_number', h.hts_number,
|
||||
'indent', h.indent,
|
||||
'description', h.description,
|
||||
'unit_of_quantity', h.unit_of_quantity,
|
||||
'general_rate_of_duty', h.general_rate_of_duty,
|
||||
'special_rate_of_duty', h.special_rate_of_duty,
|
||||
'column2_rate_of_duty', h.column2_rate_of_duty,
|
||||
'quota_quantity', h.quota_quantity,
|
||||
'additional_duties', h.additional_duties
|
||||
)
|
||||
ORDER BY LENGTH(COALESCE(h.normalized_hts_number, '')) ASC NULLS LAST,
|
||||
NULLIF(h.indent, '')::INT NULLS LAST
|
||||
) AS hts_details
|
||||
FROM hts_lookup h
|
||||
WHERE COALESCE(g.normalized_code, '') <> ''
|
||||
AND COALESCE(h.normalized_hts_number, '') <> ''
|
||||
AND (
|
||||
g.normalized_code LIKE h.normalized_hts_number || '%'
|
||||
OR h.normalized_hts_number LIKE g.normalized_code || '%'
|
||||
)
|
||||
) hts ON TRUE
|
||||
ORDER BY g.product_count DESC, g.harmonized_tariff_code ASC
|
||||
`,
|
||||
[likeTerm]
|
||||
);
|
||||
|
||||
const totalMatches = rows.reduce((sum, row) => sum + (parseInt(row.product_count, 10) || 0), 0);
|
||||
|
||||
res.json({
|
||||
search: searchTerm,
|
||||
total: totalMatches,
|
||||
results: rows.map(row => ({
|
||||
harmonized_tariff_code: row.harmonized_tariff_code,
|
||||
product_count: parseInt(row.product_count, 10) || 0,
|
||||
hts_details: Array.isArray(row.hts_details)
|
||||
? row.hts_details.map(detail => ({
|
||||
hts_number: detail.hts_number,
|
||||
indent: detail.indent,
|
||||
description: detail.description,
|
||||
unit_of_quantity: detail.unit_of_quantity,
|
||||
general_rate_of_duty: detail.general_rate_of_duty,
|
||||
special_rate_of_duty: detail.special_rate_of_duty,
|
||||
column2_rate_of_duty: detail.column2_rate_of_duty,
|
||||
quota_quantity: detail.quota_quantity,
|
||||
additional_duties: detail.additional_duties
|
||||
}))
|
||||
: [],
|
||||
products: Array.isArray(row.products)
|
||||
? row.products.map(product => ({
|
||||
pid: product.pid,
|
||||
title: product.title,
|
||||
sku: product.sku,
|
||||
barcode: product.barcode,
|
||||
brand: product.brand,
|
||||
vendor: product.vendor
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error performing HTS lookup:', error);
|
||||
res.status(500).json({ error: 'Failed to lookup HTS codes' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -21,6 +21,7 @@ const reusableImagesRouter = require('./routes/reusable-images');
|
||||
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
||||
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||
const htsLookupRouter = require('./routes/hts-lookup');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = '/var/www/html/inventory/.env';
|
||||
@@ -126,6 +127,7 @@ async function startServer() {
|
||||
app.use('/api/templates', templatesRouter);
|
||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||
app.use('/api/reusable-images', reusableImagesRouter);
|
||||
app.use('/api/hts-lookup', htsLookupRouter);
|
||||
|
||||
// Basic health check route
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
8
inventory/package-lock.json
generated
8
inventory/package-lock.json
generated
@@ -84,6 +84,7 @@
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
@@ -3153,6 +3154,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
|
||||
@@ -22,10 +22,12 @@ const Products = lazy(() => import('./pages/Products').then(module => ({ default
|
||||
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||
const Categories = lazy(() => import('./pages/Categories'));
|
||||
const Brands = lazy(() => import('./pages/Brands'));
|
||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||
|
||||
// 2. Dashboard app - separate chunk
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -161,6 +163,13 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/hts-lookup" element={
|
||||
<Protected page="hts_lookup">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<HtsLookup />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
@@ -202,6 +211,13 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/dashboard/black-friday" element={
|
||||
<Protected page="black_friday_dashboard">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<BlackFridayDashboard />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthContext } from "@/contexts/AuthContext";
|
||||
// Dashboard is first so users with dashboard access default to it
|
||||
const PAGES = [
|
||||
{ path: "/dashboard", permission: "access:dashboard" },
|
||||
{ path: "/dashboard/black-friday", permission: "access:black_friday_dashboard" },
|
||||
{ path: "/overview", permission: "access:overview" },
|
||||
{ path: "/products", permission: "access:products" },
|
||||
{ path: "/categories", permission: "access:categories" },
|
||||
@@ -13,6 +14,7 @@ const PAGES = [
|
||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||
{ path: "/analytics", permission: "access:analytics" },
|
||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||
{ path: "/hts-lookup", permission: "access:hts_lookup" },
|
||||
{ path: "/forecasting", permission: "access:forecasting" },
|
||||
{ path: "/import", permission: "access:import" },
|
||||
{ path: "/settings", permission: "access:settings" },
|
||||
|
||||
@@ -134,6 +134,7 @@ Admin users automatically have all permissions.
|
||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||
| `access:analytics` | Access to Analytics page |
|
||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||
| `access:hts_lookup` | Access to HTS Lookup page |
|
||||
| `access:forecasting` | Access to Forecasting page |
|
||||
| `access:import` | Access to Import page |
|
||||
| `access:settings` | Access to Settings page |
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
MessageCircle,
|
||||
LayoutDashboard,
|
||||
Percent,
|
||||
FileSearch,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -39,6 +41,12 @@ const dashboardItems = [
|
||||
icon: LayoutDashboard,
|
||||
url: "/dashboard",
|
||||
permission: "access:dashboard"
|
||||
},
|
||||
{
|
||||
title: "Black Friday",
|
||||
icon: ShoppingCart,
|
||||
url: "/dashboard/black-friday",
|
||||
permission: "access:black_friday_dashboard"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -94,6 +102,12 @@ const toolsItems = [
|
||||
url: "/discount-simulator",
|
||||
permission: "access:discount_simulator"
|
||||
},
|
||||
{
|
||||
title: "HTS Lookup",
|
||||
icon: FileSearch,
|
||||
url: "/hts-lookup",
|
||||
permission: "access:hts_lookup"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import config from "@/config";
|
||||
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CreatedCategoryInfo = {
|
||||
id?: string;
|
||||
name: string;
|
||||
parentId: string;
|
||||
type: "line" | "subline";
|
||||
response: CreateProductCategoryResponse;
|
||||
};
|
||||
|
||||
interface CreateProductCategoryDialogProps {
|
||||
trigger: React.ReactNode;
|
||||
companies: Option[];
|
||||
defaultCompanyId?: string | null;
|
||||
defaultLineId?: string | null;
|
||||
environment?: "dev" | "prod";
|
||||
onCreated?: (info: CreatedCategoryInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const normalizeOptions = (items: Array<Option | Record<string, unknown>>): Option[] =>
|
||||
items
|
||||
.map((item) => {
|
||||
if ("label" in item && "value" in item) {
|
||||
const casted = item as Option;
|
||||
return {
|
||||
label: String(casted.label),
|
||||
value: String(casted.value),
|
||||
};
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const label = record.label ?? record.name ?? record.display_name;
|
||||
const value = record.value ?? record.id ?? record.cat_id;
|
||||
|
||||
if (!label || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { label: String(label), value: String(value) };
|
||||
})
|
||||
.filter((item): item is Option => Boolean(item));
|
||||
|
||||
export function CreateProductCategoryDialog({
|
||||
trigger,
|
||||
companies,
|
||||
defaultCompanyId,
|
||||
defaultLineId,
|
||||
environment = "prod",
|
||||
onCreated,
|
||||
}: CreateProductCategoryDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [companyId, setCompanyId] = useState<string>(defaultCompanyId ?? "");
|
||||
const [lineId, setLineId] = useState<string>(defaultLineId ?? "");
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
||||
const [lines, setLines] = useState<Option[]>([]);
|
||||
const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({});
|
||||
|
||||
const companyOptions = useMemo(() => normalizeOptions(companies), [companies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCompanyId(defaultCompanyId ?? "");
|
||||
setLineId(defaultLineId ?? "");
|
||||
setCategoryName("");
|
||||
}
|
||||
}, [isOpen, defaultCompanyId, defaultLineId]);
|
||||
|
||||
const fetchLines = useCallback(
|
||||
async (targetCompanyId: string) => {
|
||||
const cached = linesCache[targetCompanyId];
|
||||
if (cached) {
|
||||
setLines(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
setIsLoadingLines(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/import/product-lines/${targetCompanyId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load product lines");
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const normalized = normalizeOptions(Array.isArray(payload) ? payload : []);
|
||||
setLinesCache((prev) => ({ ...prev, [targetCompanyId]: normalized }));
|
||||
setLines(normalized);
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch product lines:", error);
|
||||
toast.error("Could not load product lines");
|
||||
setLines([]);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingLines(false);
|
||||
}
|
||||
},
|
||||
[linesCache],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) {
|
||||
setLines([]);
|
||||
setLineId("");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLines(companyId).catch(() => {
|
||||
/* errors surfaced via toast */
|
||||
});
|
||||
}, [companyId, fetchLines]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!companyId) {
|
||||
toast.error("Select a company before creating a category");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = categoryName.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
toast.error("Enter a name for the new category");
|
||||
return;
|
||||
}
|
||||
|
||||
const parentId = lineId || companyId;
|
||||
const creationType: "line" | "subline" = lineId ? "subline" : "line";
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await createProductCategory({
|
||||
masterCatId: parentId,
|
||||
name: trimmedName,
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const message =
|
||||
result.message ||
|
||||
(typeof result.error === "string" ? result.error : null) ||
|
||||
`Failed to create ${creationType === "line" ? "product line" : "subline"}.`;
|
||||
throw new Error(message ?? "Request failed");
|
||||
}
|
||||
|
||||
const potentialData = result.category ?? result.data;
|
||||
let newId: string | undefined;
|
||||
|
||||
if (potentialData && typeof potentialData === "object") {
|
||||
const record = potentialData as Record<string, unknown>;
|
||||
const candidateId = record.cat_id ?? record.id ?? record.value;
|
||||
if (candidateId !== undefined && candidateId !== null) {
|
||||
newId = String(candidateId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!lineId) {
|
||||
const nextOption: Option = { label: trimmedName, value: newId ?? trimmedName };
|
||||
setLinesCache((prev) => {
|
||||
const existing = prev[companyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[companyId]: [...existing, nextOption],
|
||||
};
|
||||
});
|
||||
setLines((prev) => [...prev, nextOption]);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
creationType === "line"
|
||||
? "Product line created successfully."
|
||||
: "Subline created successfully.",
|
||||
);
|
||||
|
||||
await onCreated?.({
|
||||
id: newId,
|
||||
name: trimmedName,
|
||||
parentId,
|
||||
type: creationType,
|
||||
response: result,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to create product category:", error);
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to create ${lineId ? "subline" : "product line"}.`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[categoryName, companyId, environment, lineId, onCreated],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Product Line or Subline</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new product line beneath a company or create a subline beneath an existing line.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-category-company">Company</Label>
|
||||
<Select
|
||||
value={companyId}
|
||||
onValueChange={(value) => {
|
||||
setCompanyId(value);
|
||||
setLineId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="create-category-company">
|
||||
<SelectValue placeholder="Select a company" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companyOptions.map((company) => (
|
||||
<SelectItem key={company.value} value={company.value}>
|
||||
{company.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-category-line">
|
||||
Parent Line <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={lineId}
|
||||
onValueChange={setLineId}
|
||||
disabled={!companyId || isLoadingLines || !lines.length}
|
||||
>
|
||||
<SelectTrigger id="create-category-line">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!companyId
|
||||
? "Select a company first"
|
||||
: isLoadingLines
|
||||
? "Loading product lines..."
|
||||
: "Leave empty to create a new line"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lines.map((line) => (
|
||||
<SelectItem key={line.value} value={line.value}>
|
||||
{line.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{companyId && !isLoadingLines && !lines.length && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No existing lines found for this company. A new line will be created.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-category-name">Name</Label>
|
||||
<Input
|
||||
id="create-category-name"
|
||||
value={categoryName}
|
||||
onChange={(event) => setCategoryName(event.target.value)}
|
||||
placeholder="Enter the new line or subline name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !companyId}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateProductCategoryDialog;
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import config from "@/config"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react"
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from "@/components/product-import/CreateProductCategoryDialog"
|
||||
|
||||
// Extract components to reduce re-renders
|
||||
const ColumnActions = memo(function ColumnActions({
|
||||
@@ -608,6 +609,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
initialGlobalSelections
|
||||
}: MatchColumnsProps<T>): JSX.Element => {
|
||||
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [columns, setColumns] = useState<Columns<T>>(() => {
|
||||
@@ -800,6 +802,50 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
|
||||
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
|
||||
|
||||
const handleCategoryCreated = useCallback(
|
||||
async ({ type, parentId, id }: CreatedCategoryInfo) => {
|
||||
const refreshTasks: Array<Promise<unknown>> = [];
|
||||
|
||||
if (type === "line") {
|
||||
refreshTasks.push(
|
||||
queryClient.invalidateQueries({ queryKey: ["product-lines", parentId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["product-lines-mapped", parentId] }),
|
||||
);
|
||||
} else {
|
||||
refreshTasks.push(
|
||||
queryClient.invalidateQueries({ queryKey: ["sublines", parentId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sublines-mapped", parentId] }),
|
||||
);
|
||||
}
|
||||
|
||||
if (refreshTasks.length) {
|
||||
try {
|
||||
await Promise.all(refreshTasks);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh category lists:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalSelections((prev) => {
|
||||
if (type === "line") {
|
||||
return {
|
||||
...prev,
|
||||
company: parentId,
|
||||
line: id ?? prev.line,
|
||||
subline: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
line: parentId,
|
||||
subline: id ?? prev.subline,
|
||||
};
|
||||
});
|
||||
},
|
||||
[queryClient, setGlobalSelections],
|
||||
);
|
||||
|
||||
// Check if a field is covered by global selections
|
||||
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
||||
return (key === 'supplier' && !!globalSelections.supplier) ||
|
||||
@@ -1021,7 +1067,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
|
||||
setColumns(
|
||||
columns.map<Column<T>>((column, index) => {
|
||||
if (columnIndex === index) {
|
||||
if (column.index === columnIndex) {
|
||||
// Set the new column value
|
||||
const updatedColumn = setColumn(column, field as Field<T>, data, autoMapSelectValues);
|
||||
|
||||
@@ -1143,15 +1189,15 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
|
||||
const onIgnore = useCallback(
|
||||
(columnIndex: number) => {
|
||||
setColumns(columns.map((column, index) => (columnIndex === index ? setIgnoreColumn<T>(column) : column)))
|
||||
setColumns(columns.map((column) => (column.index === columnIndex ? setIgnoreColumn<T>(column) : column)))
|
||||
},
|
||||
[columns, setColumns],
|
||||
)
|
||||
|
||||
const onToggleAiSupplemental = useCallback(
|
||||
(columnIndex: number) => {
|
||||
setColumns(columns.map((column, index) => {
|
||||
if (columnIndex !== index) return column;
|
||||
setColumns(columns.map((column) => {
|
||||
if (column.index !== columnIndex) return column;
|
||||
|
||||
if (column.type === ColumnType.aiSupplemental) {
|
||||
return { type: ColumnType.empty, index: column.index, header: column.header } as Column<T>;
|
||||
@@ -1168,7 +1214,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
|
||||
const onRevertIgnore = useCallback(
|
||||
(columnIndex: number) => {
|
||||
setColumns(columns.map((column, index) => (columnIndex === index ? setColumn(column) : column)))
|
||||
setColumns(columns.map((column) => (column.index === columnIndex ? setColumn(column) : column)))
|
||||
},
|
||||
[columns, setColumns],
|
||||
)
|
||||
@@ -1176,8 +1222,8 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
const onSubChange = useCallback(
|
||||
(value: string, columnIndex: number, entry: string) => {
|
||||
setColumns(
|
||||
columns.map((column, index) =>
|
||||
columnIndex === index && "matchedOptions" in column
|
||||
columns.map((column) =>
|
||||
column.index === columnIndex && "matchedOptions" in column
|
||||
? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value)
|
||||
: column,
|
||||
),
|
||||
@@ -1768,6 +1814,20 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<CreateProductCategoryDialog
|
||||
trigger={
|
||||
<Button variant="link" className="h-auto px-0 text-sm font-medium">
|
||||
+ New line or subline
|
||||
</Button>
|
||||
}
|
||||
companies={fieldOptions?.companies || []}
|
||||
defaultCompanyId={globalSelections.company}
|
||||
defaultLineId={globalSelections.line}
|
||||
onCreated={handleCategoryCreated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required Fields Section - Updated to show source column */}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { normalizeCheckboxValue } from "./normalizeCheckboxValue"
|
||||
|
||||
export const normalizeTableData = <T extends string>(columns: Columns<T>, data: RawData[], fields: Fields<T>) =>
|
||||
data.map((row) =>
|
||||
columns.reduce((acc, column, index) => {
|
||||
const curr = row[index]
|
||||
columns.reduce((acc, column) => {
|
||||
const curr = row[column.index]
|
||||
switch (column.type) {
|
||||
case ColumnType.matchedCheckbox: {
|
||||
const field = fields.find((field) => field.key === column.value)!
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AiValidationDialogs } from './AiValidationDialogs'
|
||||
import { Fields } from '../../../types'
|
||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm'
|
||||
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '@/components/product-import/CreateProductCategoryDialog'
|
||||
import axios from 'axios'
|
||||
import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||
@@ -94,6 +95,20 @@ const ValidationContainer = <T extends string>({
|
||||
fetchProductLines,
|
||||
fetchSublines
|
||||
} = useProductLinesFetching(data);
|
||||
const handleValidationCategoryCreated = useCallback(
|
||||
async ({ type, parentId }: CreatedCategoryInfo) => {
|
||||
try {
|
||||
if (type === "line") {
|
||||
await fetchProductLines(null, parentId);
|
||||
} else {
|
||||
await fetchSublines(null, parentId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh product categories:", error);
|
||||
}
|
||||
},
|
||||
[fetchProductLines, fetchSublines],
|
||||
);
|
||||
|
||||
// Function to check if a specific row is being validated - memoized
|
||||
const isRowValidatingUpc = upcValidation.isRowValidatingUpc;
|
||||
@@ -135,6 +150,57 @@ const ValidationContainer = <T extends string>({
|
||||
|
||||
const [fieldOptions, setFieldOptions] = useState<any>(null)
|
||||
|
||||
const selectedRowCategoryDefaults = useMemo(() => {
|
||||
const selectedEntries = Object.entries(rowSelection).filter(([, selected]) => selected);
|
||||
|
||||
const resolveRowByKey = (key: string): Record<string, any> | undefined => {
|
||||
const numericIndex = Number(key);
|
||||
if (!Number.isNaN(numericIndex) && numericIndex >= 0 && numericIndex < filteredData.length) {
|
||||
return filteredData[numericIndex] as Record<string, any>;
|
||||
}
|
||||
|
||||
return data.find((row) => {
|
||||
if (row.__index === undefined || row.__index === null) {
|
||||
return false;
|
||||
}
|
||||
return String(row.__index) === key;
|
||||
}) as Record<string, any> | undefined;
|
||||
};
|
||||
|
||||
const targetRows: Record<string, any>[] = selectedEntries.length
|
||||
? selectedEntries
|
||||
.map(([key]) => resolveRowByKey(key))
|
||||
.filter((row): row is Record<string, any> => Boolean(row))
|
||||
: (filteredData.length ? filteredData : data) as Record<string, any>[];
|
||||
|
||||
if (!targetRows.length) {
|
||||
return { company: undefined as string | undefined, line: undefined as string | undefined };
|
||||
}
|
||||
|
||||
const uniqueCompanyValues = new Set<string>();
|
||||
const uniqueLineValues = new Set<string>();
|
||||
|
||||
targetRows.forEach((row) => {
|
||||
const companyValue = row.company;
|
||||
if (companyValue !== undefined && companyValue !== null && String(companyValue).trim() !== "") {
|
||||
uniqueCompanyValues.add(String(companyValue));
|
||||
}
|
||||
|
||||
const lineValue = row.line;
|
||||
if (lineValue !== undefined && lineValue !== null && String(lineValue).trim() !== "") {
|
||||
uniqueLineValues.add(String(lineValue));
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedCompany = uniqueCompanyValues.size === 1 ? Array.from(uniqueCompanyValues)[0] : undefined;
|
||||
const resolvedLine = uniqueLineValues.size === 1 ? Array.from(uniqueLineValues)[0] : undefined;
|
||||
|
||||
return {
|
||||
company: resolvedCompany,
|
||||
line: resolvedLine,
|
||||
};
|
||||
}, [rowSelection, filteredData, data]);
|
||||
|
||||
// Track fields that need revalidation due to value changes
|
||||
// Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields
|
||||
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Map<number, string[]>>(new Map());
|
||||
@@ -209,6 +275,12 @@ const ValidationContainer = <T extends string>({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fieldOptions) {
|
||||
fetchFieldOptions();
|
||||
}
|
||||
}, [fieldOptions, fetchFieldOptions]);
|
||||
|
||||
// Function to prepare row data for the template form
|
||||
const prepareRowDataForTemplateForm = useCallback(() => {
|
||||
// Get the selected row key (should be only one)
|
||||
@@ -792,6 +864,18 @@ const ValidationContainer = <T extends string>({
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Create New Template
|
||||
</Button>
|
||||
<CreateProductCategoryDialog
|
||||
trigger={
|
||||
<Button variant="outline" className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Line/Subline
|
||||
</Button>
|
||||
}
|
||||
companies={fieldOptions?.companies || []}
|
||||
defaultCompanyId={selectedRowCategoryDefaults.company}
|
||||
defaultLineId={selectedRowCategoryDefaults.line}
|
||||
onCreated={handleValidationCategoryCreated}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={filters.showErrorsOnly}
|
||||
|
||||
@@ -1,16 +1,153 @@
|
||||
import * as XLSX from "xlsx"
|
||||
import type { CellObject } from "xlsx"
|
||||
import type { RawData } from "../types"
|
||||
|
||||
const SCIENTIFIC_NOTATION_REGEX = /^[+-]?(?:\d+\.?\d*|\d*\.?\d+)e[+-]?\d+$/i
|
||||
|
||||
const convertScientificToDecimalString = (input: string): string => {
|
||||
const match = input.toLowerCase().match(/^([+-]?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/)
|
||||
if (!match) return input
|
||||
|
||||
const [, sign, integerPart, fractionalPart = "", exponentPart] = match
|
||||
const exponent = parseInt(exponentPart, 10)
|
||||
|
||||
if (Number.isNaN(exponent)) return input
|
||||
|
||||
const digits = `${integerPart}${fractionalPart}`
|
||||
|
||||
if (exponent >= 0) {
|
||||
const decimalIndex = integerPart.length + exponent
|
||||
if (decimalIndex >= digits.length) {
|
||||
const zerosToAppend = decimalIndex - digits.length
|
||||
return `${sign}${digits}${"0".repeat(zerosToAppend)}`
|
||||
}
|
||||
|
||||
const whole = digits.slice(0, decimalIndex) || "0"
|
||||
const fraction = digits.slice(decimalIndex).replace(/0+$/, "")
|
||||
return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`
|
||||
}
|
||||
|
||||
const decimalIndex = integerPart.length + exponent
|
||||
if (decimalIndex <= 0) {
|
||||
const zerosToPrepend = Math.abs(decimalIndex)
|
||||
const fractionDigits = `${"0".repeat(zerosToPrepend)}${digits}`.replace(/0+$/, "")
|
||||
return fractionDigits ? `${sign}0.${fractionDigits}` : "0"
|
||||
}
|
||||
|
||||
const whole = digits.slice(0, decimalIndex) || "0"
|
||||
const fractionDigits = digits.slice(decimalIndex).replace(/0+$/, "")
|
||||
return fractionDigits ? `${sign}${whole}.${fractionDigits}` : `${sign}${whole}`
|
||||
}
|
||||
|
||||
const numberToPlainString = (value: number): string => {
|
||||
if (!Number.isFinite(value)) return ""
|
||||
const stringified = value.toString()
|
||||
return SCIENTIFIC_NOTATION_REGEX.test(stringified) ? convertScientificToDecimalString(stringified) : stringified
|
||||
}
|
||||
|
||||
const normalizeFromCell = (cell: CellObject | undefined): string | undefined => {
|
||||
if (!cell) return undefined
|
||||
|
||||
const { v, w } = cell
|
||||
const cellType = (cell.t as string) || ""
|
||||
|
||||
if (typeof w === "string" && w.trim() !== "") {
|
||||
const trimmed = w.trim()
|
||||
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : trimmed
|
||||
}
|
||||
|
||||
switch (cellType) {
|
||||
case "n":
|
||||
if (typeof v === "number") return numberToPlainString(v)
|
||||
if (typeof v === "string") {
|
||||
const trimmed = v.trim()
|
||||
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : trimmed
|
||||
}
|
||||
return v === undefined || v === null ? "" : String(v)
|
||||
case "s":
|
||||
case "str":
|
||||
return typeof v === "string" ? v : v === undefined || v === null ? "" : String(v)
|
||||
case "b":
|
||||
return v ? "TRUE" : "FALSE"
|
||||
case "d":
|
||||
if (v instanceof Date) return v.toISOString()
|
||||
if (typeof v === "number") {
|
||||
const date = XLSX.SSF.parse_date_code(v)
|
||||
if (date) {
|
||||
const year = date.y.toString().padStart(4, "0")
|
||||
const month = date.m.toString().padStart(2, "0")
|
||||
const day = date.d.toString().padStart(2, "0")
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
}
|
||||
return v === undefined || v === null ? "" : String(v)
|
||||
default:
|
||||
return v === undefined || v === null ? "" : String(v)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeCellValue = (value: unknown, cell: CellObject | undefined): string => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
const fallback = normalizeFromCell(cell)
|
||||
return fallback !== undefined ? fallback : ""
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return numberToPlainString(value)
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === "") {
|
||||
const fallback = normalizeFromCell(cell)
|
||||
return fallback !== undefined ? fallback : ""
|
||||
}
|
||||
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : value
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "TRUE" : "FALSE"
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
const fallback = normalizeFromCell(cell)
|
||||
return fallback !== undefined ? fallback : String(value)
|
||||
}
|
||||
|
||||
export const mapWorkbook = (workbook: XLSX.WorkBook, sheetName?: string): RawData[] => {
|
||||
// Use the provided sheetName or default to the first sheet
|
||||
const sheetToUse = sheetName || workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[sheetToUse]
|
||||
|
||||
const data = XLSX.utils.sheet_to_json<string[]>(worksheet, {
|
||||
const rangeRef = worksheet["!ref"] || "A1"
|
||||
const sheetRange = XLSX.utils.decode_range(rangeRef)
|
||||
|
||||
const sheetData = XLSX.utils.sheet_to_json<unknown[]>(worksheet, {
|
||||
header: 1,
|
||||
raw: false,
|
||||
raw: true,
|
||||
defval: "",
|
||||
blankrows: true,
|
||||
})
|
||||
|
||||
return data
|
||||
const columnCount = Math.max(
|
||||
sheetRange.e.c - sheetRange.s.c + 1,
|
||||
...sheetData.map((row) => row.length),
|
||||
)
|
||||
|
||||
return sheetData.map((row, rowIndex) => {
|
||||
const sheetRow = sheetRange.s.r + rowIndex
|
||||
const normalizedRow: string[] = []
|
||||
|
||||
for (let columnOffset = 0; columnOffset < columnCount; columnOffset++) {
|
||||
const sheetColumn = sheetRange.s.c + columnOffset
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: sheetRow, c: sheetColumn })
|
||||
const cell = worksheet[cellAddress] as CellObject | undefined
|
||||
const value = row[columnOffset]
|
||||
normalizedRow.push(normalizeCellValue(value, cell))
|
||||
}
|
||||
|
||||
return normalizedRow as RawData
|
||||
})
|
||||
}
|
||||
|
||||
1434
inventory/src/pages/BlackFridayDashboard.tsx
Normal file
1434
inventory/src/pages/BlackFridayDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
340
inventory/src/pages/HtsLookup.tsx
Normal file
340
inventory/src/pages/HtsLookup.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search, Loader2, PackageOpen, Copy, Check } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HtsProduct = {
|
||||
pid: number;
|
||||
title: string;
|
||||
sku: string;
|
||||
brand?: string | null;
|
||||
vendor?: string | null;
|
||||
barcode?: string | null;
|
||||
};
|
||||
|
||||
type HtsDetail = {
|
||||
hts_number: string | null;
|
||||
indent?: string | null;
|
||||
description?: string | null;
|
||||
unit_of_quantity?: string | null;
|
||||
general_rate_of_duty?: string | null;
|
||||
special_rate_of_duty?: string | null;
|
||||
column2_rate_of_duty?: string | null;
|
||||
quota_quantity?: string | null;
|
||||
additional_duties?: string | null;
|
||||
};
|
||||
|
||||
type HtsGroup = {
|
||||
harmonized_tariff_code: string;
|
||||
product_count: number;
|
||||
products: HtsProduct[];
|
||||
hts_details?: HtsDetail[] | null;
|
||||
};
|
||||
|
||||
type HtsLookupResponse = {
|
||||
search: string;
|
||||
total: number;
|
||||
results: HtsGroup[];
|
||||
};
|
||||
|
||||
export default function HtsLookup() {
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [submittedTerm, setSubmittedTerm] = useState("");
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
const copyTimerRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
refetch,
|
||||
} = useQuery<HtsLookupResponse>({
|
||||
queryKey: ["hts-lookup", submittedTerm],
|
||||
enabled: false,
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ search: submittedTerm });
|
||||
const response = await fetch(`/api/hts-lookup?${params.toString()}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof payload.error === "string" ? payload.error : "Failed to fetch HTS data";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as HtsLookupResponse;
|
||||
},
|
||||
staleTime: 2 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (submittedTerm) {
|
||||
void refetch();
|
||||
}
|
||||
}, [submittedTerm, 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 totalMatches = data?.total ?? 0;
|
||||
const groupedResults = useMemo(() => data?.results ?? [], [data]);
|
||||
|
||||
const handleCopyClick = async (event: MouseEvent<HTMLButtonElement>, code: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const valueToCopy = code === "Unspecified" ? "" : code;
|
||||
|
||||
if (!navigator?.clipboard) {
|
||||
toast({
|
||||
title: "Clipboard unavailable",
|
||||
description: "Your browser did not allow copying the code.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(valueToCopy);
|
||||
if (copyTimerRef.current) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
setCopiedCode(code);
|
||||
copyTimerRef.current = window.setTimeout(() => setCopiedCode(null), 1200);
|
||||
toast({
|
||||
title: "Copied HTS code",
|
||||
description: valueToCopy || "Empty code copied",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Copy failed",
|
||||
description: err instanceof Error ? err.message : "Unable to copy code",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const trimmed = searchTerm.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
toast({
|
||||
title: "Enter a search term",
|
||||
description: "Search by title, SKU, vendor, barcode, or HTS code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === submittedTerm) {
|
||||
void refetch();
|
||||
} else {
|
||||
setSubmittedTerm(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSummary = () => {
|
||||
if (!isFetched || !data) return null;
|
||||
|
||||
if (!groupedResults.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground space-y-3">
|
||||
<PackageOpen className="mx-auto h-10 w-10" />
|
||||
<div>No products found for "{data.search}".</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Search term</CardDescription>
|
||||
<CardTitle className="text-lg break-all">{data.search}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Matched products</CardDescription>
|
||||
<CardTitle className="text-3xl">{totalMatches}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Unique HTS codes</CardDescription>
|
||||
<CardTitle className="text-3xl">{groupedResults.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">HTS Lookup</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Search products</CardTitle>
|
||||
<CardDescription>Search by product title, item number, company name, UPC, or HTS code.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSearch} className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="md:max-w-xl"
|
||||
/>
|
||||
<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" onClick={() => setSearchTerm("")} disabled={isFetching}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderSummary()}
|
||||
|
||||
{groupedResults.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HTS codes by frequency</CardTitle>
|
||||
<CardDescription>Most-used codes appear first. Expand a code to see the matching products.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{groupedResults.map((group) => {
|
||||
const percentage = totalMatches > 0 ? Math.round((group.product_count / totalMatches) * 100) : 0;
|
||||
const codeLabel = group.harmonized_tariff_code === "Unspecified" ? "Not set" : group.harmonized_tariff_code;
|
||||
const htsDetails = group.hts_details || [];
|
||||
|
||||
return (
|
||||
<AccordionItem key={group.harmonized_tariff_code} value={group.harmonized_tariff_code}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={copiedCode === group.harmonized_tariff_code ? "secondary" : "ghost"}
|
||||
aria-label={`Copy HTS code ${codeLabel}`}
|
||||
onClick={(event) => handleCopyClick(event, group.harmonized_tariff_code)}
|
||||
className="mr-1"
|
||||
>
|
||||
{copiedCode === group.harmonized_tariff_code ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Badge variant={group.harmonized_tariff_code === "Unspecified" ? "secondary" : "outline"}>
|
||||
{codeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{group.product_count} product{group.product_count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{htsDetails.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{htsDetails.map((detail, idx) => (
|
||||
<div
|
||||
key={`${detail.hts_number || "unknown"}-${idx}`}
|
||||
className="flex items-center gap-2"
|
||||
style={{ paddingLeft: Math.max(0, (Number(detail.indent) || 0) * 10) }}
|
||||
>
|
||||
<Badge variant="outline" className="bg-muted/60">
|
||||
{detail.hts_number || "—"}
|
||||
</Badge>
|
||||
<span className="leading-tight">{detail.description || "No description"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No HTS reference found</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{percentage}% of matches</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[45%]">Product</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Barcode</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{product.sku}</TableCell>
|
||||
<TableCell>{product.brand || "—"}</TableCell>
|
||||
<TableCell>{product.vendor || "—"}</TableCell>
|
||||
<TableCell className={cn("text-muted-foreground", !product.barcode && "italic")}>
|
||||
{product.barcode || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,27 @@ export interface SubmitNewProductsResponse {
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateProductCategoryArgs {
|
||||
masterCatId: string | number;
|
||||
name: string;
|
||||
environment?: "dev" | "prod";
|
||||
image?: string;
|
||||
nameForCustoms?: string;
|
||||
taxCodeId?: string | number;
|
||||
}
|
||||
|
||||
export interface CreateProductCategoryResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
category?: unknown;
|
||||
}
|
||||
|
||||
const DEV_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/product/setup_new";
|
||||
const PROD_ENDPOINT = "https://backend.acherryontop.com/apiv2/product/setup_new";
|
||||
const DEV_CREATE_CATEGORY_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/prod_cat/new";
|
||||
const PROD_CREATE_CATEGORY_ENDPOINT = "https://backend.acherryontop.com/apiv2/prod_cat/new";
|
||||
|
||||
const isHtmlResponse = (payload: string) => {
|
||||
const trimmed = payload.trim();
|
||||
@@ -87,3 +106,84 @@ export async function submitNewProducts({
|
||||
|
||||
return normalizedResponse;
|
||||
}
|
||||
|
||||
export async function createProductCategory({
|
||||
masterCatId,
|
||||
name,
|
||||
environment = "prod",
|
||||
image,
|
||||
nameForCustoms,
|
||||
taxCodeId,
|
||||
}: CreateProductCategoryArgs): Promise<CreateProductCategoryResponse> {
|
||||
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error("VITE_APIV2_AUTH_TOKEN is not configured");
|
||||
}
|
||||
|
||||
const targetUrl = environment === "dev" ? DEV_CREATE_CATEGORY_ENDPOINT : PROD_CREATE_CATEGORY_ENDPOINT;
|
||||
|
||||
const payload = new URLSearchParams();
|
||||
payload.append("auth", authToken);
|
||||
payload.append("master_cat_id", masterCatId.toString());
|
||||
payload.append("name", name);
|
||||
|
||||
if (nameForCustoms) {
|
||||
payload.append("name_for_customs", nameForCustoms);
|
||||
}
|
||||
|
||||
if (image) {
|
||||
payload.append("image", image);
|
||||
}
|
||||
|
||||
if (typeof taxCodeId !== "undefined" && taxCodeId !== null) {
|
||||
payload.append("tax_code_id", taxCodeId.toString());
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
} 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 {
|
||||
const message = `Unexpected response from backend (${response.status}).`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Empty response from backend");
|
||||
}
|
||||
|
||||
const parsedRecord = parsed as Record<string, unknown>;
|
||||
const normalizedResponse: CreateProductCategoryResponse = {
|
||||
success: Boolean(parsedRecord.success ?? parsedRecord.status ?? parsedRecord.result),
|
||||
message: typeof parsedRecord.message === "string" ? parsedRecord.message : undefined,
|
||||
data: parsedRecord.data ?? parsedRecord.category ?? parsedRecord.result,
|
||||
error: parsedRecord.error ?? parsedRecord.errors ?? parsedRecord.error_msg,
|
||||
category: parsedRecord.category,
|
||||
};
|
||||
|
||||
if (!response.ok || !normalizedResponse.success) {
|
||||
return normalizedResponse;
|
||||
}
|
||||
|
||||
return normalizedResponse;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user