Compare commits
8 Commits
72930bbc73
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b2b4e421 | |||
| b81dfb9649 | |||
| 9be0f34f07 | |||
| ad5b797ce6 | |||
| 78932360d1 | |||
| 217abd41af | |||
| d56beb5143 | |||
| 0b5f3162c7 |
@@ -13,6 +13,13 @@ const {
|
|||||||
const TIMEZONE = 'America/New_York';
|
const TIMEZONE = 'America/New_York';
|
||||||
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
|
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
|
// Image URL generation utility
|
||||||
const getImageUrls = (pid, iid = 1) => {
|
const getImageUrls = (pid, iid = 1) => {
|
||||||
const imageUrlBase = 'https://sbing.com/i/products/0000/';
|
const imageUrlBase = 'https://sbing.com/i/products/0000/';
|
||||||
@@ -39,14 +46,15 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mainOperation = async () => {
|
const mainOperation = async () => {
|
||||||
const { timeRange, startDate, endDate } = req.query;
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
console.log(`[STATS] Getting DB connection...`);
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
||||||
const { connection, release } = await getDbConnection();
|
const { connection, release } = await getDbConnection();
|
||||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
// Main order stats query
|
// Main order stats query (optionally excludes Cherry Box orders)
|
||||||
const mainStatsQuery = `
|
const mainStatsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as orderCount,
|
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 1 ELSE 0 END) as cancelledCount,
|
||||||
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
|
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
|
||||||
FROM _order
|
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 [mainStats] = await connection.execute(mainStatsQuery, params);
|
||||||
const stats = mainStats[0];
|
const stats = mainStats[0];
|
||||||
|
|
||||||
// Refunds query
|
// Refunds query (optionally excludes Cherry Box orders)
|
||||||
const refundsQuery = `
|
const refundsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as refundCount,
|
COUNT(*) as refundCount,
|
||||||
ABS(SUM(payment_amount)) as refundTotal
|
ABS(SUM(payment_amount)) as refundTotal
|
||||||
FROM order_payment op
|
FROM order_payment op
|
||||||
JOIN _order o ON op.order_id = o.order_id
|
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);
|
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||||
|
|
||||||
// Best revenue day query
|
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||||
const bestDayQuery = `
|
const bestDayQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(date_placed) as date,
|
DATE(date_placed) as date,
|
||||||
SUM(summary_total) as revenue,
|
SUM(summary_total) as revenue,
|
||||||
COUNT(*) as orders
|
COUNT(*) as orders
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
GROUP BY DATE(date_placed)
|
GROUP BY DATE(date_placed)
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -94,7 +102,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
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;
|
let peakHour = null;
|
||||||
if (['today', 'yesterday'].includes(timeRange)) {
|
if (['today', 'yesterday'].includes(timeRange)) {
|
||||||
const peakHourQuery = `
|
const peakHourQuery = `
|
||||||
@@ -102,7 +110,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
HOUR(date_placed) as hour,
|
HOUR(date_placed) as hour,
|
||||||
COUNT(*) as count
|
COUNT(*) as count
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
GROUP BY HOUR(date_placed)
|
GROUP BY HOUR(date_placed)
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 1
|
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
|
// 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 = `
|
const brandsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
'Various Brands' as brandName,
|
'Various Brands' as brandName,
|
||||||
@@ -132,13 +140,13 @@ router.get('/stats', async (req, res) => {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
JOIN products p ON oi.prod_pid = p.pid
|
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
|
HAVING revenue > 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
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 = `
|
const categoriesQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
'General' as categoryName,
|
'General' as categoryName,
|
||||||
@@ -148,13 +156,13 @@ router.get('/stats', async (req, res) => {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
JOIN products p ON oi.prod_pid = p.pid
|
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
|
HAVING revenue > 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
||||||
|
|
||||||
// Shipping locations query
|
// Shipping locations query (optionally excludes Cherry Box orders)
|
||||||
const shippingQuery = `
|
const shippingQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
ship_country,
|
ship_country,
|
||||||
@@ -162,7 +170,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
ship_method_selected,
|
ship_method_selected,
|
||||||
COUNT(*) as count
|
COUNT(*) as count
|
||||||
FROM _order
|
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
|
GROUP BY ship_country, ship_state, ship_method_selected
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -171,13 +179,13 @@ router.get('/stats', async (req, res) => {
|
|||||||
// Process shipping data
|
// Process shipping data
|
||||||
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
||||||
|
|
||||||
// Order value range query
|
// Order value range query (optionally excludes Cherry Box orders)
|
||||||
const orderRangeQuery = `
|
const orderRangeQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
MIN(summary_total) as smallest,
|
MIN(summary_total) as smallest,
|
||||||
MAX(summary_total) as largest
|
MAX(summary_total) as largest
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
||||||
@@ -189,7 +197,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Previous period comparison data
|
// Previous period comparison data
|
||||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate);
|
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
timeRange: dateRange,
|
timeRange: dateRange,
|
||||||
@@ -316,13 +324,14 @@ router.get('/stats', async (req, res) => {
|
|||||||
router.get('/stats/details', async (req, res) => {
|
router.get('/stats/details', async (req, res) => {
|
||||||
let release;
|
let release;
|
||||||
try {
|
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();
|
const { connection, release: releaseConn } = await getDbConnection();
|
||||||
release = releaseConn;
|
release = releaseConn;
|
||||||
|
|
||||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
// Daily breakdown query
|
// Daily breakdown query (optionally excludes Cherry Box orders)
|
||||||
const dailyQuery = `
|
const dailyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(date_placed) as date,
|
DATE(date_placed) as date,
|
||||||
@@ -331,7 +340,7 @@ router.get('/stats/details', async (req, res) => {
|
|||||||
AVG(summary_total) as averageOrderValue,
|
AVG(summary_total) as averageOrderValue,
|
||||||
SUM(stats_prod_pieces) as itemCount
|
SUM(stats_prod_pieces) as itemCount
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
GROUP BY DATE(date_placed)
|
GROUP BY DATE(date_placed)
|
||||||
ORDER 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()];
|
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get previous period daily data
|
// Get previous period daily data (optionally excludes Cherry Box orders)
|
||||||
const prevQuery = `
|
const prevQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(date_placed) as date,
|
DATE(date_placed) as date,
|
||||||
@@ -367,7 +376,7 @@ router.get('/stats/details', async (req, res) => {
|
|||||||
SUM(summary_total) as prevRevenue,
|
SUM(summary_total) as prevRevenue,
|
||||||
AVG(summary_total) as prevAvgOrderValue
|
AVG(summary_total) as prevAvgOrderValue
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${prevWhereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
|
||||||
GROUP BY DATE(date_placed)
|
GROUP BY DATE(date_placed)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -424,7 +433,8 @@ router.get('/stats/details', async (req, res) => {
|
|||||||
router.get('/financials', async (req, res) => {
|
router.get('/financials', async (req, res) => {
|
||||||
let release;
|
let release;
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate } = req.query;
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
const { connection, release: releaseConn } = await getDbConnection();
|
const { connection, release: releaseConn } = await getDbConnection();
|
||||||
release = releaseConn;
|
release = releaseConn;
|
||||||
|
|
||||||
@@ -450,7 +460,7 @@ router.get('/financials', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [totalsRows] = await connection.execute(
|
const [totalsRows] = await connection.execute(
|
||||||
buildFinancialTotalsQuery(financialWhere),
|
buildFinancialTotalsQuery(financialWhere, excludeCB),
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -462,7 +472,7 @@ router.get('/financials', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [trendRows] = await connection.execute(
|
const [trendRows] = await connection.execute(
|
||||||
buildFinancialTrendQuery(financialWhere),
|
buildFinancialTrendQuery(financialWhere, excludeCB),
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -489,7 +499,7 @@ router.get('/financials', async (req, res) => {
|
|||||||
});
|
});
|
||||||
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
|
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
|
||||||
const [previousRows] = await connection.execute(
|
const [previousRows] = await connection.execute(
|
||||||
buildFinancialTotalsQuery(prevWhere),
|
buildFinancialTotalsQuery(prevWhere, excludeCB),
|
||||||
previousRange.params
|
previousRange.params
|
||||||
);
|
);
|
||||||
previousTotals = normalizeFinancialTotals(previousRows[0]);
|
previousTotals = normalizeFinancialTotals(previousRows[0]);
|
||||||
@@ -549,12 +559,14 @@ router.get('/financials', async (req, res) => {
|
|||||||
router.get('/products', async (req, res) => {
|
router.get('/products', async (req, res) => {
|
||||||
let release;
|
let release;
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate } = req.query;
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
const { connection, release: releaseConn } = await getDbConnection();
|
const { connection, release: releaseConn } = await getDbConnection();
|
||||||
release = releaseConn;
|
release = releaseConn;
|
||||||
|
|
||||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
|
// Products query (optionally excludes Cherry Box orders)
|
||||||
const productsQuery = `
|
const productsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
@@ -566,7 +578,7 @@ router.get('/products', async (req, res) => {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
JOIN products p ON oi.prod_pid = p.pid
|
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
|
GROUP BY p.pid, p.description
|
||||||
ORDER BY totalRevenue DESC
|
ORDER BY totalRevenue DESC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
@@ -609,7 +621,8 @@ router.get('/products', async (req, res) => {
|
|||||||
router.get('/projection', async (req, res) => {
|
router.get('/projection', async (req, res) => {
|
||||||
let release;
|
let release;
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate } = req.query;
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
|
||||||
// Only provide projections for incomplete periods
|
// Only provide projections for incomplete periods
|
||||||
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||||
@@ -622,19 +635,20 @@ router.get('/projection', async (req, res) => {
|
|||||||
// Get current period data
|
// Get current period data
|
||||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
|
// Current period query (optionally excludes Cherry Box orders)
|
||||||
const currentQuery = `
|
const currentQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
SUM(summary_total) as currentRevenue,
|
SUM(summary_total) as currentRevenue,
|
||||||
COUNT(*) as currentOrders
|
COUNT(*) as currentOrders
|
||||||
FROM _order
|
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 [currentResult] = await connection.execute(currentQuery, params);
|
||||||
const current = currentResult[0];
|
const current = currentResult[0];
|
||||||
|
|
||||||
// Get historical data for the same period type
|
// 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
|
// Calculate projection based on current progress and historical patterns
|
||||||
const periodProgress = calculatePeriodProgress(timeRange);
|
const periodProgress = calculatePeriodProgress(timeRange);
|
||||||
@@ -765,7 +779,24 @@ function calculatePeriodProgress(timeRange) {
|
|||||||
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
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 `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(sale_amount), 0) as grossSales,
|
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;
|
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 `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(
|
DATE_FORMAT(
|
||||||
@@ -940,7 +994,7 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
|||||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
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
|
// Calculate previous period dates
|
||||||
let prevWhereClause, prevParams;
|
let prevWhereClause, prevParams;
|
||||||
|
|
||||||
@@ -962,13 +1016,14 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate)
|
|||||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Previous period query (optionally excludes Cherry Box orders)
|
||||||
const prevQuery = `
|
const prevQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as orderCount,
|
COUNT(*) as orderCount,
|
||||||
SUM(summary_total) as revenue,
|
SUM(summary_total) as revenue,
|
||||||
AVG(summary_total) as averageOrderValue
|
AVG(summary_total) as averageOrderValue
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${prevWhereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} AND ${prevWhereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [prevResult] = await connection.execute(prevQuery, prevParams);
|
const [prevResult] = await connection.execute(prevQuery, prevParams);
|
||||||
@@ -994,8 +1049,8 @@ function getPreviousTimeRange(timeRange) {
|
|||||||
return map[timeRange] || timeRange;
|
return map[timeRange] || timeRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHistoricalProjectionData(connection, timeRange) {
|
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
|
||||||
// Get historical data for projection calculations
|
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
|
||||||
// This is a simplified version - you could make this more sophisticated
|
// This is a simplified version - you could make this more sophisticated
|
||||||
const historicalQuery = `
|
const historicalQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1003,6 +1058,7 @@ async function getHistoricalProjectionData(connection, timeRange) {
|
|||||||
COUNT(*) as orders
|
COUNT(*) as orders
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
||||||
`;
|
`;
|
||||||
|
|||||||
456
inventory-server/package-lock.json
generated
456
inventory-server/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"openai": "^6.0.0",
|
"openai": "^6.0.0",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"pm2": "^5.3.0",
|
"pm2": "^5.3.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@@ -30,6 +31,384 @@
|
|||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/node-pre-gyp": {
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
@@ -840,6 +1219,19 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1",
|
||||||
|
"color-string": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -858,6 +1250,16 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^1.0.0",
|
||||||
|
"simple-swizzle": "^0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -1853,6 +2255,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arrayish": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -3387,6 +3795,45 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"color": "^4.2.3",
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"semver": "^7.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.33.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.33.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||||
|
"@img/sharp-linux-arm": "0.33.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.33.5",
|
||||||
|
"@img/sharp-linux-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-wasm32": "0.33.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.33.5",
|
||||||
|
"@img/sharp-win32-x64": "0.33.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shimmer": {
|
"node_modules/shimmer": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
|
||||||
@@ -3471,6 +3918,15 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-swizzle": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"openai": "^6.0.0",
|
"openai": "^6.0.0",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"pm2": "^5.3.0",
|
"pm2": "^5.3.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
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;
|
||||||
@@ -5,6 +5,8 @@ const mysql = require('mysql2/promise');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
// 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/html/inventory/uploads/products');
|
||||||
@@ -35,6 +37,9 @@ const connectionCache = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_IMAGE_DIMENSION = 1000;
|
||||||
|
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
// Function to schedule image deletion after 24 hours
|
// Function to schedule image deletion after 24 hours
|
||||||
const scheduleImageDeletion = (filename, filePath) => {
|
const scheduleImageDeletion = (filename, filePath) => {
|
||||||
// Only schedule deletion for images in the products folder
|
// Only schedule deletion for images in the products folder
|
||||||
@@ -145,6 +150,255 @@ const cleanupImagesOnStartup = () => {
|
|||||||
// Run cleanup on server start
|
// Run cleanup on server start
|
||||||
cleanupImagesOnStartup();
|
cleanupImagesOnStartup();
|
||||||
|
|
||||||
|
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||||
|
|
||||||
|
const processUploadedImage = async (filePath, mimetype) => {
|
||||||
|
const notices = [];
|
||||||
|
const legacyWarnings = [];
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
const originalBuffer = await fsp.readFile(filePath);
|
||||||
|
let baseMetadata = await sharp(originalBuffer, { failOn: 'none' }).metadata();
|
||||||
|
|
||||||
|
metadata.width = baseMetadata.width || 0;
|
||||||
|
metadata.height = baseMetadata.height || 0;
|
||||||
|
metadata.size = originalBuffer.length;
|
||||||
|
metadata.colorSpace = baseMetadata.space || baseMetadata.colourspace || null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
baseMetadata.width &&
|
||||||
|
baseMetadata.height &&
|
||||||
|
(baseMetadata.width < MIN_IMAGE_DIMENSION || baseMetadata.height < MIN_IMAGE_DIMENSION)
|
||||||
|
) {
|
||||||
|
const message = `Image is ${baseMetadata.width}x${baseMetadata.height}. Recommended minimum is ${MIN_IMAGE_DIMENSION}x${MIN_IMAGE_DIMENSION}.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'dimensions_too_small',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorSpace = (baseMetadata.space || baseMetadata.colourspace || '').toLowerCase();
|
||||||
|
let shouldConvertToRgb = colorSpace === 'cmyk';
|
||||||
|
|
||||||
|
if (shouldConvertToRgb) {
|
||||||
|
const message = 'Converted image from CMYK to RGB.';
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'converted_to_rgb',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = (baseMetadata.format || '').toLowerCase();
|
||||||
|
if (format === 'gif') {
|
||||||
|
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'gif_size_limit',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
metadata.convertedToRgb = false;
|
||||||
|
metadata.resized = false;
|
||||||
|
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
|
||||||
|
let targetQuality = supportsQuality ? 90 : undefined;
|
||||||
|
let finalQuality = undefined;
|
||||||
|
|
||||||
|
let currentWidth = baseMetadata.width || null;
|
||||||
|
let currentHeight = baseMetadata.height || null;
|
||||||
|
|
||||||
|
let resized = false;
|
||||||
|
let mutated = false;
|
||||||
|
let finalBuffer = originalBuffer;
|
||||||
|
let finalInfo = baseMetadata;
|
||||||
|
|
||||||
|
const encode = async ({ width, height, quality }) => {
|
||||||
|
let pipeline = sharp(originalBuffer, { failOn: 'none' });
|
||||||
|
|
||||||
|
if (shouldConvertToRgb) {
|
||||||
|
pipeline = pipeline.toColorspace('srgb');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width || height) {
|
||||||
|
pipeline = pipeline.resize({
|
||||||
|
width: width ?? undefined,
|
||||||
|
height: height ?? undefined,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'png':
|
||||||
|
pipeline = pipeline.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'webp':
|
||||||
|
pipeline = pipeline.webp({ quality: quality ?? 90 });
|
||||||
|
break;
|
||||||
|
case 'jpeg':
|
||||||
|
case 'jpg':
|
||||||
|
default:
|
||||||
|
pipeline = pipeline.jpeg({ quality: quality ?? 90, mozjpeg: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pipeline.toBuffer({ resolveWithObject: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const canResize =
|
||||||
|
(currentWidth && currentWidth > MIN_IMAGE_DIMENSION) ||
|
||||||
|
(currentHeight && currentHeight > MIN_IMAGE_DIMENSION);
|
||||||
|
|
||||||
|
if (metadata.size > MAX_IMAGE_SIZE_BYTES && (supportsQuality || canResize)) {
|
||||||
|
const maxAttempts = 8;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
let targetWidth = currentWidth;
|
||||||
|
let targetHeight = currentHeight;
|
||||||
|
let resizedThisAttempt = false;
|
||||||
|
|
||||||
|
if (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) {
|
||||||
|
targetWidth = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentWidth * 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHeight && currentHeight > MIN_IMAGE_DIMENSION) {
|
||||||
|
targetHeight = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentHeight * 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(targetWidth && currentWidth && targetWidth < currentWidth) ||
|
||||||
|
(targetHeight && currentHeight && targetHeight < currentHeight)
|
||||||
|
) {
|
||||||
|
resized = true;
|
||||||
|
resizedThisAttempt = true;
|
||||||
|
currentWidth = targetWidth;
|
||||||
|
currentHeight = targetHeight;
|
||||||
|
} else if (!supportsQuality || (targetQuality && targetQuality <= 70)) {
|
||||||
|
// Cannot resize further and quality cannot be adjusted
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityForAttempt = supportsQuality ? targetQuality : undefined;
|
||||||
|
const { data, info } = await encode({
|
||||||
|
width: currentWidth,
|
||||||
|
height: currentHeight,
|
||||||
|
quality: qualityForAttempt,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutated = true;
|
||||||
|
finalBuffer = data;
|
||||||
|
finalInfo = info;
|
||||||
|
metadata.optimizedSize = data.length;
|
||||||
|
if (info.width) metadata.width = info.width;
|
||||||
|
if (info.height) metadata.height = info.height;
|
||||||
|
if (info.width) currentWidth = info.width;
|
||||||
|
if (info.height) currentHeight = info.height;
|
||||||
|
|
||||||
|
if (supportsQuality && qualityForAttempt) {
|
||||||
|
finalQuality = qualityForAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length <= MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizedThisAttempt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsQuality && targetQuality && targetQuality > 70) {
|
||||||
|
const nextQuality = Math.max(70, targetQuality - 10);
|
||||||
|
if (nextQuality === targetQuality) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
targetQuality = nextQuality;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalBuffer.length > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
const message = `Optimized image remains ${bytesToMegabytes(finalBuffer.length)}MB (target 5MB).`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'size_over_limit',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
} else if (shouldConvertToRgb) {
|
||||||
|
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
|
||||||
|
mutated = true;
|
||||||
|
finalBuffer = data;
|
||||||
|
finalInfo = info;
|
||||||
|
metadata.optimizedSize = data.length;
|
||||||
|
if (info.width) metadata.width = info.width;
|
||||||
|
if (info.height) metadata.height = info.height;
|
||||||
|
if (info.width) currentWidth = info.width;
|
||||||
|
if (info.height) currentHeight = info.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutated) {
|
||||||
|
await fsp.writeFile(filePath, finalBuffer);
|
||||||
|
metadata.optimizedSize = finalBuffer.length;
|
||||||
|
} else {
|
||||||
|
// No transformation occurred; still need to ensure we report original stats
|
||||||
|
metadata.optimizedSize = metadata.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.convertedToRgb = shouldConvertToRgb && mutated;
|
||||||
|
metadata.resized = resized;
|
||||||
|
if (finalQuality) {
|
||||||
|
metadata.quality = finalQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resized && metadata.width && metadata.height) {
|
||||||
|
const message = `Image resized to ${metadata.width}x${metadata.height} during optimization.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'resized',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalQuality && finalQuality < 90) {
|
||||||
|
const message = `Image quality adjusted to ${finalQuality} to reduce file size.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'quality_adjusted',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notices,
|
||||||
|
warnings: legacyWarnings,
|
||||||
|
metadata,
|
||||||
|
finalSize: finalBuffer.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -178,7 +432,7 @@ const storage = multer.diskStorage({
|
|||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB
|
||||||
},
|
},
|
||||||
fileFilter: function (req, file, cb) {
|
fileFilter: function (req, file, cb) {
|
||||||
// Accept only image files
|
// Accept only image files
|
||||||
@@ -345,7 +599,7 @@ async function setupSshTunnel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Image upload endpoint
|
// Image upload endpoint
|
||||||
router.post('/upload-image', upload.single('image'), (req, res) => {
|
router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No image file provided' });
|
return res.status(400).json({ error: 'No image file provided' });
|
||||||
@@ -375,6 +629,10 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process the image (resize/compress/color-space) before responding
|
||||||
|
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||||
|
req.file.size = processingResult.finalSize;
|
||||||
|
|
||||||
// Create URL for the uploaded file - using an absolute URL with domain
|
// Create URL for the uploaded file - using an absolute URL with domain
|
||||||
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://acot.site';
|
||||||
@@ -390,11 +648,24 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
|||||||
fileName: req.file.filename,
|
fileName: req.file.filename,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: req.file.mimetype,
|
||||||
fullPath: filePath,
|
fullPath: filePath,
|
||||||
|
notices: processingResult.notices,
|
||||||
|
warnings: processingResult.warnings,
|
||||||
|
metadata: processingResult.metadata,
|
||||||
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
|
if (req?.file?.filename) {
|
||||||
|
const cleanupPath = path.join(uploadsDir, req.file.filename);
|
||||||
|
if (fs.existsSync(cleanupPath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(cleanupPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to remove file after processing error:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
res.status(500).json({ error: error.message || 'Failed to upload image' });
|
res.status(500).json({ error: error.message || 'Failed to upload image' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const reusableImagesRouter = require('./routes/reusable-images');
|
|||||||
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
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');
|
||||||
|
|
||||||
// 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/html/inventory/.env';
|
||||||
@@ -126,6 +127,7 @@ async function startServer() {
|
|||||||
app.use('/api/templates', templatesRouter);
|
app.use('/api/templates', templatesRouter);
|
||||||
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);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
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",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
@@ -3153,6 +3154,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.4",
|
"version": "22.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@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 Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||||
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 Vendors = lazy(() => import('./pages/Vendors'));
|
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||||
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 PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||||
|
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||||
|
|
||||||
// 2. Dashboard app - separate chunk
|
// 2. Dashboard app - separate chunk
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -161,6 +163,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/hts-lookup" element={
|
||||||
|
<Protected page="hts_lookup">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<HtsLookup />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/forecasting" element={
|
<Route path="/forecasting" element={
|
||||||
<Protected page="forecasting">
|
<Protected page="forecasting">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
@@ -202,6 +211,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</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 path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AuthContext } from "@/contexts/AuthContext";
|
|||||||
// Dashboard is first so users with dashboard access default to it
|
// Dashboard is first so users with dashboard access default to it
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
{ path: "/dashboard", permission: "access:dashboard" },
|
{ path: "/dashboard", permission: "access:dashboard" },
|
||||||
|
{ path: "/dashboard/black-friday", permission: "access:black_friday_dashboard" },
|
||||||
{ path: "/overview", permission: "access:overview" },
|
{ path: "/overview", permission: "access:overview" },
|
||||||
{ path: "/products", permission: "access:products" },
|
{ path: "/products", permission: "access:products" },
|
||||||
{ path: "/categories", permission: "access:categories" },
|
{ path: "/categories", permission: "access:categories" },
|
||||||
@@ -13,6 +14,7 @@ const PAGES = [
|
|||||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||||
{ 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: "/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:purchase_orders` | Access to Purchase Orders page |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `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: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 |
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Percent,
|
Percent,
|
||||||
|
FileSearch,
|
||||||
|
ShoppingCart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +41,12 @@ const dashboardItems = [
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
url: "/dashboard",
|
url: "/dashboard",
|
||||||
permission: "access: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",
|
url: "/discount-simulator",
|
||||||
permission: "access:discount_simulator"
|
permission: "access:discount_simulator"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "HTS Lookup",
|
||||||
|
icon: FileSearch,
|
||||||
|
url: "/hts-lookup",
|
||||||
|
permission: "access:hts_lookup"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
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;
|
||||||
@@ -1,32 +1,16 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Loader2, Trash2, Maximize2, GripVertical, X } from "lucide-react";
|
import { Loader2, Trash2, Maximize2, GripVertical, X, Info } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import type { ProductImageSortable } from "../../types";
|
||||||
|
|
||||||
// Define the ProductImage interface
|
type SortableImage = ProductImageSortable & { url?: string };
|
||||||
interface ProductImage {
|
|
||||||
id: string;
|
|
||||||
url?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
fileName?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
// Optional fields from the full ProductImage type
|
|
||||||
productIndex?: number;
|
|
||||||
pid?: number;
|
|
||||||
iid?: number;
|
|
||||||
type?: number;
|
|
||||||
order?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
hidden?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the SortableImageProps interface
|
|
||||||
interface SortableImageProps {
|
interface SortableImageProps {
|
||||||
image: ProductImage;
|
image: SortableImage;
|
||||||
productIndex: number;
|
productIndex: number;
|
||||||
imgIndex: number;
|
imgIndex: number;
|
||||||
productName?: string; // Make this optional
|
productName?: string; // Make this optional
|
||||||
@@ -55,6 +39,11 @@ export const SortableImage = ({
|
|||||||
removeImage
|
removeImage
|
||||||
}: SortableImageProps) => {
|
}: SortableImageProps) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const notices = image.notices ?? [];
|
||||||
|
const attentionNotices = notices.filter((notice) => notice.level === 'warning');
|
||||||
|
const infoNotices = notices.filter((notice) => notice.level === 'info');
|
||||||
|
const hasAttention = attentionNotices.length > 0;
|
||||||
|
const metadata = image.metadata;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -145,6 +134,40 @@ export const SortableImage = ({
|
|||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{hasAttention && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="absolute bottom-1 right-1 z-10 flex items-center rounded-full bg-sky-500/90 p-1 text-white shadow-sm">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="end" className="max-w-[240px] text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{attentionNotices.map((notice, idx) => (
|
||||||
|
<p key={`warn-${idx}`}>{notice.message}</p>
|
||||||
|
))}
|
||||||
|
{infoNotices.map((notice, idx) => (
|
||||||
|
<p key={`info-${idx}`} className="text-muted-foreground">
|
||||||
|
{notice.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{metadata?.width && metadata?.height && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Detected size: {metadata.width}×{metadata.height}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metadata?.optimizedSize && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Optimized size: {(metadata.optimizedSize / (1024 * 1024)).toFixed(2)}MB
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,92 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Product, ProductImageSortable } from "../types";
|
import { Product, ProductImageSortable, ImageMetadata, ImageNotice } from "../types";
|
||||||
|
|
||||||
|
const MIN_DIMENSION = 1000;
|
||||||
|
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const readImageMetadata = async (file: File): Promise<ImageMetadata> => {
|
||||||
|
try {
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
return {
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
size: file.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("createImageBitmap failed, falling back to Image element", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
resolve({
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
resolve({ size: file.size });
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzeImage = async (file: File) => {
|
||||||
|
const metadata = await readImageMetadata(file);
|
||||||
|
const notices: ImageNotice[] = [];
|
||||||
|
|
||||||
|
if (metadata.width && metadata.height) {
|
||||||
|
if (metadata.width < MIN_DIMENSION || metadata.height < MIN_DIMENSION) {
|
||||||
|
notices.push({
|
||||||
|
message: `Image is ${metadata.width}x${metadata.height}. Recommended minimum is ${MIN_DIMENSION}x${MIN_DIMENSION}.`,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'dimensions_too_small',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notices.push({
|
||||||
|
message: "Unable to verify image dimensions.",
|
||||||
|
level: 'info',
|
||||||
|
code: 'dimensions_unknown',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
notices.push({
|
||||||
|
message: `Image is ${sizeMb}MB. Files above 5MB will be optimized automatically.`,
|
||||||
|
level: 'info',
|
||||||
|
code: 'client_large_file',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata, notices };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeNotices = (notices: ImageNotice[] = []) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return notices.filter((notice) => {
|
||||||
|
const key = `${notice.level}:${notice.code ?? ''}:${notice.message}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface UseProductImageOperationsProps {
|
interface UseProductImageOperationsProps {
|
||||||
data: Product[];
|
data: Product[];
|
||||||
@@ -72,10 +158,12 @@ export const useProductImageOperations = ({
|
|||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
|
const imageId = `image-${productIndex}-${Date.now()}-${i}`;
|
||||||
|
const productLabel = data[productIndex].name || `Product #${productIndex + 1}`;
|
||||||
|
|
||||||
// Add placeholder for this image
|
// Add placeholder for this image
|
||||||
const newImage: ProductImageSortable = {
|
const newImage: ProductImageSortable = {
|
||||||
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
|
id: imageId,
|
||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -92,6 +180,48 @@ export const useProductImageOperations = ({
|
|||||||
|
|
||||||
setProductImages(prev => [...prev, newImage]);
|
setProductImages(prev => [...prev, newImage]);
|
||||||
|
|
||||||
|
let analysisNotices: ImageNotice[] | undefined;
|
||||||
|
let metadata: ImageMetadata | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await analyzeImage(file);
|
||||||
|
metadata = analysis.metadata;
|
||||||
|
analysisNotices = analysis.notices?.length ? dedupeNotices(analysis.notices) : undefined;
|
||||||
|
|
||||||
|
if (analysisNotices?.length) {
|
||||||
|
const warningMessages = analysisNotices
|
||||||
|
.filter((notice) => notice.level === 'warning')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
const infoMessages = analysisNotices
|
||||||
|
.filter((notice) => notice.level === 'info')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
|
||||||
|
if (warningMessages.length) {
|
||||||
|
toast.warning(`${file.name}: ${warningMessages.join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoMessages.length) {
|
||||||
|
if (typeof toast.info === 'function') {
|
||||||
|
toast.info(`${file.name}: ${infoMessages.join(" ")}`);
|
||||||
|
} else {
|
||||||
|
toast(`${file.name}`, {
|
||||||
|
description: infoMessages.join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.id === imageId
|
||||||
|
? { ...img, metadata, notices: analysisNotices }
|
||||||
|
: img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to analyze image before upload', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Create form data for upload
|
// Create form data for upload
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
@@ -111,29 +241,77 @@ export const useProductImageOperations = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const serverNotices: ImageNotice[] = Array.isArray(result.notices)
|
||||||
|
? result.notices.map((notice: any) => ({
|
||||||
|
message: String(notice.message ?? notice),
|
||||||
|
level: notice.level === 'warning' ? 'warning' : 'info',
|
||||||
|
code: notice.code,
|
||||||
|
source: notice.source ?? 'server'
|
||||||
|
}))
|
||||||
|
: Array.isArray(result.warnings)
|
||||||
|
? result.warnings.map((message: any) => ({
|
||||||
|
message: String(message),
|
||||||
|
level: 'info',
|
||||||
|
source: 'server'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (serverNotices.length) {
|
||||||
|
const warningMessages = serverNotices
|
||||||
|
.filter((notice) => notice.level === 'warning')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
const infoMessages = serverNotices
|
||||||
|
.filter((notice) => notice.level === 'info')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
|
||||||
|
if (warningMessages.length) {
|
||||||
|
toast.warning(`${file.name}: ${warningMessages.join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoMessages.length) {
|
||||||
|
if (typeof toast.info === 'function') {
|
||||||
|
toast.info(`${file.name}: ${infoMessages.join(" ")}`);
|
||||||
|
} else {
|
||||||
|
toast(`${file.name}`, {
|
||||||
|
description: infoMessages.join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the image URL in our state
|
// Update the image URL in our state
|
||||||
setProductImages(prev =>
|
setProductImages(prev =>
|
||||||
prev.map(img =>
|
prev.map(img => {
|
||||||
(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
if (img.id !== imageId) return img;
|
||||||
? { ...img, imageUrl: result.imageUrl, loading: false }
|
|
||||||
: img
|
const combinedNotices = dedupeNotices([
|
||||||
)
|
...(analysisNotices || []),
|
||||||
|
...serverNotices
|
||||||
|
]);
|
||||||
|
|
||||||
|
const combinedMetadata: ImageMetadata | undefined = result.metadata
|
||||||
|
? { ...metadata, ...result.metadata }
|
||||||
|
: metadata;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...img,
|
||||||
|
imageUrl: result.imageUrl,
|
||||||
|
loading: false,
|
||||||
|
notices: combinedNotices.length ? combinedNotices : undefined,
|
||||||
|
metadata: combinedMetadata,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
// Update the product data with the new image URL
|
||||||
addImageToProduct(productIndex, result.imageUrl);
|
addImageToProduct(productIndex, result.imageUrl);
|
||||||
|
|
||||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
toast.success(`Image uploaded for ${productLabel}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
||||||
// Remove the failed image from our state
|
// Remove the failed image from our state
|
||||||
setProductImages(prev =>
|
setProductImages(prev => prev.filter(img => img.id !== imageId));
|
||||||
prev.filter(img =>
|
|
||||||
!(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type ProductImage = {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
notices?: ImageNotice[];
|
||||||
|
metadata?: ImageMetadata;
|
||||||
// Schema fields
|
// Schema fields
|
||||||
pid: number;
|
pid: number;
|
||||||
iid: number;
|
iid: number;
|
||||||
@@ -13,6 +15,26 @@ export type ProductImage = {
|
|||||||
hidden: number;
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageNoticeLevel = 'info' | 'warning';
|
||||||
|
|
||||||
|
export type ImageNotice = {
|
||||||
|
message: string;
|
||||||
|
level: ImageNoticeLevel;
|
||||||
|
code?: string;
|
||||||
|
source?: 'client' | 'server';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageMetadata = {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: number;
|
||||||
|
optimizedSize?: number;
|
||||||
|
colorSpace?: string;
|
||||||
|
quality?: number;
|
||||||
|
resized?: boolean;
|
||||||
|
convertedToRgb?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type UnassignedImage = {
|
export type UnassignedImage = {
|
||||||
file: File;
|
file: File;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react"
|
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react"
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from "@/components/product-import/CreateProductCategoryDialog"
|
||||||
|
|
||||||
// Extract components to reduce re-renders
|
// Extract components to reduce re-renders
|
||||||
const ColumnActions = memo(function ColumnActions({
|
const ColumnActions = memo(function ColumnActions({
|
||||||
@@ -608,6 +609,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
initialGlobalSelections
|
initialGlobalSelections
|
||||||
}: MatchColumnsProps<T>): JSX.Element => {
|
}: MatchColumnsProps<T>): JSX.Element => {
|
||||||
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
|
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const [columns, setColumns] = useState<Columns<T>>(() => {
|
const [columns, setColumns] = useState<Columns<T>>(() => {
|
||||||
@@ -800,6 +802,50 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
|
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
|
||||||
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
|
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
|
// Check if a field is covered by global selections
|
||||||
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
||||||
return (key === 'supplier' && !!globalSelections.supplier) ||
|
return (key === 'supplier' && !!globalSelections.supplier) ||
|
||||||
@@ -1021,7 +1067,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
|
|
||||||
setColumns(
|
setColumns(
|
||||||
columns.map<Column<T>>((column, index) => {
|
columns.map<Column<T>>((column, index) => {
|
||||||
if (columnIndex === index) {
|
if (column.index === columnIndex) {
|
||||||
// Set the new column value
|
// Set the new column value
|
||||||
const updatedColumn = setColumn(column, field as Field<T>, data, autoMapSelectValues);
|
const updatedColumn = setColumn(column, field as Field<T>, data, autoMapSelectValues);
|
||||||
|
|
||||||
@@ -1143,15 +1189,15 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
|
|
||||||
const onIgnore = useCallback(
|
const onIgnore = useCallback(
|
||||||
(columnIndex: number) => {
|
(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],
|
[columns, setColumns],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onToggleAiSupplemental = useCallback(
|
const onToggleAiSupplemental = useCallback(
|
||||||
(columnIndex: number) => {
|
(columnIndex: number) => {
|
||||||
setColumns(columns.map((column, index) => {
|
setColumns(columns.map((column) => {
|
||||||
if (columnIndex !== index) return column;
|
if (column.index !== columnIndex) return column;
|
||||||
|
|
||||||
if (column.type === ColumnType.aiSupplemental) {
|
if (column.type === ColumnType.aiSupplemental) {
|
||||||
return { type: ColumnType.empty, index: column.index, header: column.header } as Column<T>;
|
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(
|
const onRevertIgnore = useCallback(
|
||||||
(columnIndex: number) => {
|
(columnIndex: number) => {
|
||||||
setColumns(columns.map((column, index) => (columnIndex === index ? setColumn(column) : column)))
|
setColumns(columns.map((column) => (column.index === columnIndex ? setColumn(column) : column)))
|
||||||
},
|
},
|
||||||
[columns, setColumns],
|
[columns, setColumns],
|
||||||
)
|
)
|
||||||
@@ -1176,8 +1222,8 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
const onSubChange = useCallback(
|
const onSubChange = useCallback(
|
||||||
(value: string, columnIndex: number, entry: string) => {
|
(value: string, columnIndex: number, entry: string) => {
|
||||||
setColumns(
|
setColumns(
|
||||||
columns.map((column, index) =>
|
columns.map((column) =>
|
||||||
columnIndex === index && "matchedOptions" in column
|
column.index === columnIndex && "matchedOptions" in column
|
||||||
? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value)
|
? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value)
|
||||||
: column,
|
: column,
|
||||||
),
|
),
|
||||||
@@ -1768,6 +1814,20 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Required Fields Section - Updated to show source column */}
|
{/* 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>) =>
|
export const normalizeTableData = <T extends string>(columns: Columns<T>, data: RawData[], fields: Fields<T>) =>
|
||||||
data.map((row) =>
|
data.map((row) =>
|
||||||
columns.reduce((acc, column, index) => {
|
columns.reduce((acc, column) => {
|
||||||
const curr = row[index]
|
const curr = row[column.index]
|
||||||
switch (column.type) {
|
switch (column.type) {
|
||||||
case ColumnType.matchedCheckbox: {
|
case ColumnType.matchedCheckbox: {
|
||||||
const field = fields.find((field) => field.key === column.value)!
|
const field = fields.find((field) => field.key === column.value)!
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AiValidationDialogs } from './AiValidationDialogs'
|
|||||||
import { Fields } from '../../../types'
|
import { Fields } from '../../../types'
|
||||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||||
import { TemplateForm } from '@/components/templates/TemplateForm'
|
import { TemplateForm } from '@/components/templates/TemplateForm'
|
||||||
|
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '@/components/product-import/CreateProductCategoryDialog'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { RowSelectionState } from '@tanstack/react-table'
|
import { RowSelectionState } from '@tanstack/react-table'
|
||||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||||
@@ -94,6 +95,20 @@ const ValidationContainer = <T extends string>({
|
|||||||
fetchProductLines,
|
fetchProductLines,
|
||||||
fetchSublines
|
fetchSublines
|
||||||
} = useProductLinesFetching(data);
|
} = 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
|
// Function to check if a specific row is being validated - memoized
|
||||||
const isRowValidatingUpc = upcValidation.isRowValidatingUpc;
|
const isRowValidatingUpc = upcValidation.isRowValidatingUpc;
|
||||||
@@ -135,6 +150,57 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
const [fieldOptions, setFieldOptions] = useState<any>(null)
|
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
|
// Track fields that need revalidation due to value changes
|
||||||
// Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields
|
// Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields
|
||||||
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Map<number, string[]>>(new Map());
|
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
|
// Function to prepare row data for the template form
|
||||||
const prepareRowDataForTemplateForm = useCallback(() => {
|
const prepareRowDataForTemplateForm = useCallback(() => {
|
||||||
// Get the selected row key (should be only one)
|
// Get the selected row key (should be only one)
|
||||||
@@ -792,6 +864,18 @@ const ValidationContainer = <T extends string>({
|
|||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
Create New Template
|
Create New Template
|
||||||
</Button>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={filters.showErrorsOnly}
|
checked={filters.showErrorsOnly}
|
||||||
|
|||||||
@@ -1,16 +1,153 @@
|
|||||||
import * as XLSX from "xlsx"
|
import * as XLSX from "xlsx"
|
||||||
|
import type { CellObject } from "xlsx"
|
||||||
import type { RawData } from "../types"
|
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[] => {
|
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 sheetToUse = sheetName || workbook.SheetNames[0]
|
||||||
const worksheet = workbook.Sheets[sheetToUse]
|
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,
|
header: 1,
|
||||||
raw: false,
|
raw: true,
|
||||||
defval: "",
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--success: 142.1 76.2% 36.3%;
|
||||||
|
--success-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--warning: 45.4 93.4% 47.5%;
|
||||||
|
--warning-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
@@ -76,6 +85,15 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--success: 142.1 70.6% 45.3%;
|
||||||
|
--success-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--warning: 45.4 93.4% 47.5%;
|
||||||
|
--warning-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,16 @@ import { useState, useContext } from "react";
|
|||||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||||
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Code } from "@/components/ui/code";
|
import { Code } from "@/components/ui/code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle, ExternalLink } from "lucide-react";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||||
@@ -43,20 +46,110 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|||||||
|
|
||||||
const extractBackendPayload = (
|
const extractBackendPayload = (
|
||||||
data: SubmitNewProductsResponse["data"],
|
data: SubmitNewProductsResponse["data"],
|
||||||
): { created: BackendProductResult[]; errored: BackendProductResult[] } => {
|
): { created: BackendProductResult[]; errored: BackendProductResult[]; queryId: string | null } => {
|
||||||
if (!isRecord(data)) {
|
if (!isRecord(data)) {
|
||||||
return { created: [], errored: [] };
|
return { created: [], errored: [], queryId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payloadRecord = data as Record<string, unknown>;
|
||||||
const toList = (value: unknown): BackendProductResult[] =>
|
const toList = (value: unknown): BackendProductResult[] =>
|
||||||
Array.isArray(value) ? (value.filter(isRecord) as BackendProductResult[]) : [];
|
Array.isArray(value) ? (value.filter(isRecord) as BackendProductResult[]) : [];
|
||||||
|
const queryIdSource = payloadRecord.query_id ?? payloadRecord.queryId ?? payloadRecord.queryID;
|
||||||
|
let queryId: string | null = null;
|
||||||
|
|
||||||
|
if (typeof queryIdSource === "string") {
|
||||||
|
const trimmed = queryIdSource.trim();
|
||||||
|
queryId = trimmed.length ? trimmed : null;
|
||||||
|
} else if (typeof queryIdSource === "number") {
|
||||||
|
queryId = queryIdSource.toString();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created: toList((data as Record<string, unknown>).created),
|
created: toList(payloadRecord.created),
|
||||||
errored: toList((data as Record<string, unknown>).errored),
|
errored: toList(payloadRecord.errored),
|
||||||
|
queryId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractStringFromRecord = (record: Record<string, unknown>): string | null => {
|
||||||
|
const candidateKeys = ["error_msg", "message", "reason", "error", "detail"];
|
||||||
|
|
||||||
|
for (const key of candidateKeys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFailureMessageFromResponse = (response: SubmitNewProductsResponse): string | null => {
|
||||||
|
if (typeof response.message === "string") {
|
||||||
|
const trimmed = response.message.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = response;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
const trimmed = error.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
for (const entry of error) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
} else if (isRecord(entry)) {
|
||||||
|
const extracted = extractStringFromRecord(entry);
|
||||||
|
if (extracted) {
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error)) {
|
||||||
|
return extractStringFromRecord(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractErroredEntriesFromResponse = (
|
||||||
|
response: SubmitNewProductsResponse,
|
||||||
|
): BackendProductResult[] => {
|
||||||
|
const { error } = response;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
return error.filter(isRecord) as BackendProductResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error)) {
|
||||||
|
return [error as BackendProductResult];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
@@ -172,7 +265,201 @@ export function Import() {
|
|||||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
const { user } = useContext(AuthContext);
|
const { user } = useContext(AuthContext);
|
||||||
const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false;
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
|
|
||||||
|
// ========== TEMPORARY TEST DATA ==========
|
||||||
|
// Uncomment the useEffect below to test the results page without submitting actual data
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// // Test scenario: Mix of successful and failed products
|
||||||
|
// const testSubmittedProducts: NormalizedProduct[] = [
|
||||||
|
// {
|
||||||
|
// name: "Test Product 1",
|
||||||
|
// upc: "123456789012",
|
||||||
|
// item_number: "ITEM-001",
|
||||||
|
// company: "Test Company",
|
||||||
|
// line: "Test Line",
|
||||||
|
// subline: "Test Subline",
|
||||||
|
// product_images: ["https://picsum.photos/200/200?random=1"],
|
||||||
|
// short_description: "This is a test product",
|
||||||
|
// retail: "29.99",
|
||||||
|
// wholesale: "15.00",
|
||||||
|
// weight: "1.5",
|
||||||
|
// categories: ["Category 1", "Category 2"],
|
||||||
|
// colors: ["Red", "Blue"],
|
||||||
|
// size_cat: "Medium",
|
||||||
|
// tax_cat: "Taxable",
|
||||||
|
// ship_restrictions: "None",
|
||||||
|
// supplier: "Test Supplier",
|
||||||
|
// artist: null,
|
||||||
|
// themes: ["Theme 1"],
|
||||||
|
// vendor_sku: "VS-001",
|
||||||
|
// publish: true,
|
||||||
|
// list_on_marketplace: false,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "Test Product 2",
|
||||||
|
// upc: "234567890123",
|
||||||
|
// item_number: "ITEM-002",
|
||||||
|
// company: "Test Company",
|
||||||
|
// line: "Test Line",
|
||||||
|
// subline: "Test Subline",
|
||||||
|
// product_images: ["https://picsum.photos/200/200?random=2"],
|
||||||
|
// short_description: "Another test product",
|
||||||
|
// retail: "49.99",
|
||||||
|
// wholesale: "25.00",
|
||||||
|
// weight: "2.0",
|
||||||
|
// categories: ["Category 3"],
|
||||||
|
// colors: ["Green"],
|
||||||
|
// size_cat: "Large",
|
||||||
|
// tax_cat: "Taxable",
|
||||||
|
// ship_restrictions: "None",
|
||||||
|
// supplier: "Test Supplier",
|
||||||
|
// artist: "Test Artist",
|
||||||
|
// themes: [],
|
||||||
|
// vendor_sku: "VS-002",
|
||||||
|
// publish: true,
|
||||||
|
// list_on_marketplace: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "Failed Product 1",
|
||||||
|
// upc: "345678901234",
|
||||||
|
// item_number: "ITEM-003",
|
||||||
|
// company: "Test Company",
|
||||||
|
// line: "Test Line",
|
||||||
|
// subline: null,
|
||||||
|
// product_images: ["https://picsum.photos/200/200?random=3"],
|
||||||
|
// short_description: "This product will fail",
|
||||||
|
// retail: "19.99",
|
||||||
|
// wholesale: "10.00",
|
||||||
|
// weight: "0.5",
|
||||||
|
// categories: [],
|
||||||
|
// colors: [],
|
||||||
|
// size_cat: null,
|
||||||
|
// tax_cat: "Taxable",
|
||||||
|
// ship_restrictions: null,
|
||||||
|
// supplier: null,
|
||||||
|
// artist: null,
|
||||||
|
// themes: [],
|
||||||
|
// vendor_sku: "VS-003",
|
||||||
|
// publish: false,
|
||||||
|
// list_on_marketplace: false,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "Failed Product 2",
|
||||||
|
// upc: "456789012345",
|
||||||
|
// item_number: "ITEM-004",
|
||||||
|
// company: "Test Company",
|
||||||
|
// line: null,
|
||||||
|
// subline: null,
|
||||||
|
// product_images: null,
|
||||||
|
// description: "Another failed product",
|
||||||
|
// msrp: "99.99",
|
||||||
|
// cost_each: "50.00",
|
||||||
|
// weight: "5.0",
|
||||||
|
// categories: ["Category 1"],
|
||||||
|
// colors: ["Yellow"],
|
||||||
|
// size_cat: "Small",
|
||||||
|
// tax_cat: null,
|
||||||
|
// ship_restrictions: "Hazmat",
|
||||||
|
// supplier: "Test Supplier",
|
||||||
|
// artist: null,
|
||||||
|
// themes: [],
|
||||||
|
// vendor_sku: null,
|
||||||
|
// publish: true,
|
||||||
|
// list_on_marketplace: false,
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// const testSubmittedRows: Data<string>[] = testSubmittedProducts.map(product => ({ ...product } as Data<string>));
|
||||||
|
|
||||||
|
// //Scenario 1: All successful
|
||||||
|
// const testResponse: SubmitNewProductsResponse = {
|
||||||
|
// success: true,
|
||||||
|
// message: "Successfully created 4 products",
|
||||||
|
// data: {
|
||||||
|
// created: [
|
||||||
|
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
|
||||||
|
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
|
||||||
|
// { pid: 12347, upc: "345678901234", item_number: "ITEM-003" },
|
||||||
|
// { pid: 12348, upc: "456789012345", item_number: "ITEM-004" },
|
||||||
|
// ],
|
||||||
|
// errored: [],
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Scenario 2: Partial success (2 created, 2 failed)
|
||||||
|
// const testResponse: SubmitNewProductsResponse = {
|
||||||
|
// success: true,
|
||||||
|
// message: "Created 2 of 4 products. 2 products had errors.",
|
||||||
|
// data: {
|
||||||
|
// created: [
|
||||||
|
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
|
||||||
|
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
|
||||||
|
// ],
|
||||||
|
// errored: [
|
||||||
|
// {
|
||||||
|
// upc: "345678901234",
|
||||||
|
// item_number: "ITEM-003",
|
||||||
|
// error_msg: "Missing required field: supplier",
|
||||||
|
// errors: {
|
||||||
|
// supplier: ["Supplier is required for this product line"],
|
||||||
|
// categories: ["At least one category must be selected"],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// upc: "456789012345",
|
||||||
|
// item_number: "ITEM-004",
|
||||||
|
// error_msg: "Invalid product configuration",
|
||||||
|
// errors: {
|
||||||
|
// line: ["Product line is required"],
|
||||||
|
// tax_cat: ["Tax category must be specified"],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// query_id: "1234567890",
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Scenario 3: Complete failure
|
||||||
|
// const testResponse: SubmitNewProductsResponse = {
|
||||||
|
// success: false,
|
||||||
|
// message: "Failed to create products. Please check the errors below.",
|
||||||
|
// data: {
|
||||||
|
// created: [],
|
||||||
|
// errored: [
|
||||||
|
// {
|
||||||
|
// upc: "123456789012",
|
||||||
|
// item_number: "ITEM-001",
|
||||||
|
// error_msg: "UPC already exists in the system",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// upc: "234567890123",
|
||||||
|
// item_number: "ITEM-002",
|
||||||
|
// error_msg: "Invalid wholesale price",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// upc: "345678901234",
|
||||||
|
// item_number: "ITEM-003",
|
||||||
|
// error_msg: "Missing required field: supplier",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// upc: "456789012345",
|
||||||
|
// item_number: "ITEM-004",
|
||||||
|
// error_msg: "Invalid product configuration",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
// setImportOutcome({
|
||||||
|
// submittedProducts: testSubmittedProducts,
|
||||||
|
// submittedRows: testSubmittedRows,
|
||||||
|
// response: testResponse,
|
||||||
|
// });
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
// ========== END TEST DATA ==========
|
||||||
|
|
||||||
// Fetch initial field options from the API
|
// Fetch initial field options from the API
|
||||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
@@ -452,31 +739,69 @@ export function Import() {
|
|||||||
employeeId: user?.id ?? undefined,
|
employeeId: user?.id ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.success) {
|
const isSuccess = response.success;
|
||||||
throw new Error(response.message || "Failed to submit products");
|
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
|
||||||
|
const failureEntries = isSuccess ? [] : extractErroredEntriesFromResponse(response);
|
||||||
|
const resolvedFailureMessage = isSuccess
|
||||||
|
? null
|
||||||
|
: extractFailureMessageFromResponse(response) ?? defaultFailureMessage;
|
||||||
|
|
||||||
|
let normalizedResponse: SubmitNewProductsResponse = response;
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
const baseData: Record<string, unknown> = isRecord(response.data)
|
||||||
|
? { ...(response.data as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (failureEntries.length) {
|
||||||
|
const existingErrored = baseData["errored"];
|
||||||
|
if (!Array.isArray(existingErrored) || existingErrored.length === 0) {
|
||||||
|
baseData["errored"] = failureEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDataOverrides = Object.keys(baseData).length > 0;
|
||||||
|
|
||||||
|
normalizedResponse = {
|
||||||
|
...response,
|
||||||
|
success: false,
|
||||||
|
message: resolvedFailureMessage ?? defaultFailureMessage,
|
||||||
|
data: hasDataOverrides ? baseData : response.data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setResumeStepState(undefined);
|
setResumeStepState(undefined);
|
||||||
setImportOutcome({
|
setImportOutcome({
|
||||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||||
submittedRows: rows.map((row) => ({ ...row })),
|
submittedRows: rows.map((row) => ({ ...row })),
|
||||||
response,
|
response: normalizedResponse,
|
||||||
});
|
});
|
||||||
setIsDebugDataVisible(false);
|
setIsDebugDataVisible(false);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setStartFromScratch(false);
|
||||||
|
|
||||||
const successMessage = response.message
|
if (isSuccess) {
|
||||||
? response.message
|
const successMessage =
|
||||||
: `Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully`;
|
normalizedResponse.message ||
|
||||||
|
`Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully.`;
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
|
} else {
|
||||||
|
toast.error(resolvedFailureMessage ?? defaultFailureMessage);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
throw error instanceof Error ? error : new Error("Failed to import data");
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendPayload = importOutcome ? extractBackendPayload(importOutcome.response.data) : { created: [], errored: [] };
|
const backendPayload = importOutcome
|
||||||
|
? extractBackendPayload(importOutcome.response.data)
|
||||||
|
: { created: [] as BackendProductResult[], errored: [] as BackendProductResult[], queryId: null };
|
||||||
|
const productToolUrl = backendPayload.queryId
|
||||||
|
? `https://backend.acherryontop.com/product_tool/${backendPayload.queryId}`
|
||||||
|
: null;
|
||||||
|
|
||||||
const createdProducts = importOutcome
|
const createdProducts = importOutcome
|
||||||
? backendPayload.created.map((entry) => {
|
? backendPayload.created.map((entry) => {
|
||||||
@@ -643,13 +968,10 @@ export function Import() {
|
|||||||
{importOutcome && (
|
{importOutcome && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>Import Results</CardTitle>
|
<CardTitle>Import Results</CardTitle>
|
||||||
{summaryMessage && <CardDescription>{summaryMessage}</CardDescription>}
|
|
||||||
</div>
|
|
||||||
{hasDebugPermission && (
|
{hasDebugPermission && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsDebugDataVisible((prev) => !prev)}
|
onClick={() => setIsDebugDataVisible((prev) => !prev)}
|
||||||
>
|
>
|
||||||
@@ -658,21 +980,57 @@ export function Import() {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
{importOutcome.response.success === false ? (
|
||||||
Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}.
|
<Alert className="border-destructive bg-destructive/10">
|
||||||
{erroredProducts.length > 0
|
<AlertCircle className="h-4 w-4" style={{ color: 'hsl(var(--destructive))' }} />
|
||||||
? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} need attention.`
|
<AlertTitle className="text-destructive">Error</AlertTitle>
|
||||||
: ""}
|
<AlertDescription className="text-destructive">
|
||||||
</p>
|
{summaryMessage ?? "Products not created - please review details and fix."}
|
||||||
{erroredProducts.length > 0 && hasErroredRowsForEditing && (
|
</AlertDescription>
|
||||||
<Button size="sm" onClick={handleResumeErroredProducts}>
|
</Alert>
|
||||||
Fix errored products
|
) : createdProducts.length === totalSubmitted && totalSubmitted > 0 ? (
|
||||||
</Button>
|
<Alert className="border-success bg-success/10">
|
||||||
)}
|
<CheckCircle className="h-4 w-4" style={{ color: 'hsl(var(--success))' }} />
|
||||||
|
<AlertTitle className="text-success-foreground">Success</AlertTitle>
|
||||||
|
<AlertDescription className="text-success-foreground">All products created successfully.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : createdProducts.length > 0 && erroredProducts.length > 0 ? (
|
||||||
|
<Alert className="border-warning bg-warning/10">
|
||||||
|
<AlertTriangle className="h-4 w-4" style={{ color: 'hsl(var(--warning))' }} />
|
||||||
|
<AlertTitle className="text-warning-foreground">Partial Success</AlertTitle>
|
||||||
|
<AlertDescription className="text-warning-foreground">
|
||||||
|
{createdProducts.length} product{createdProducts.length === 1 ? "" : "s"} created successfully. {erroredProducts.length} product{erroredProducts.length === 1 ? "" : "s"} need attention.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : erroredProducts.length > 0 ? (
|
||||||
|
<Alert className="border-destructive bg-destructive/10">
|
||||||
|
<AlertCircle className="h-4 w-4" style={{ color: 'hsl(var(--destructive))' }} />
|
||||||
|
<AlertTitle className="text-destructive-foreground">Error</AlertTitle>
|
||||||
|
<AlertDescription className="text-destructive-foreground">
|
||||||
|
{summaryMessage ?? "Products not created - please review details and fix."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : summaryMessage ? (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Notice</AlertTitle>
|
||||||
|
<AlertDescription>{summaryMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{createdProducts.length > 0 && (
|
{createdProducts.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-lg font-semibold">Created Products</h3>
|
<h3 className="text-lg font-semibold">Created Products</h3>
|
||||||
|
{productToolUrl && (
|
||||||
|
<Button asChild size="sm" variant="default">
|
||||||
|
<a href={productToolUrl} target="_blank" rel="noreferrer">
|
||||||
|
View in Product Tool
|
||||||
|
<ExternalLink />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{createdProducts.map((product, index) => {
|
{createdProducts.map((product, index) => {
|
||||||
const key = product.pid ?? product.upc ?? product.itemNumber ?? index;
|
const key = product.pid ?? product.upc ?? product.itemNumber ?? index;
|
||||||
@@ -685,7 +1043,7 @@ export function Import() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-start gap-4 rounded-md border p-4">
|
<div key={key} className="flex items-start gap-4 rounded-md p-1 transition-colors">
|
||||||
{product.url ? (
|
{product.url ? (
|
||||||
<a
|
<a
|
||||||
href={product.url}
|
href={product.url}
|
||||||
@@ -700,21 +1058,34 @@ export function Import() {
|
|||||||
{imageContent}
|
{imageContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
{product.url ? (
|
{product.url ? (
|
||||||
<a
|
<a
|
||||||
href={product.url}
|
href={product.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
className="text-sm font-medium text-primary hover:underline flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>View in Backend</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium">{product.name}</span>
|
<span className="text-sm font-medium">{product.name}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{product.pid ? `PID: ${product.pid} · ` : ""}
|
|
||||||
UPC: {product.upc}
|
UPC: {product.upc}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||||
@@ -728,10 +1099,17 @@ export function Import() {
|
|||||||
|
|
||||||
{erroredProducts.length > 0 && (
|
{erroredProducts.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-lg font-semibold text-destructive">Errored Products</h3>
|
<div className="flex items-center justify-between">
|
||||||
<div className="grid gap-3">
|
<h3 className="text-lg font-semibold text-destructive">Products with Errors</h3>
|
||||||
|
{hasErroredRowsForEditing && (
|
||||||
|
<Button size="sm" onClick={handleResumeErroredProducts} variant="default">
|
||||||
|
Fix products with errors
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 border border-destructive rounded-md p-2">
|
||||||
{erroredProducts.map((product, index) => (
|
{erroredProducts.map((product, index) => (
|
||||||
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 rounded-md border border-destructive/40 p-4">
|
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 p-1">
|
||||||
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
|
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||||
{product.imageUrl ? (
|
{product.imageUrl ? (
|
||||||
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
|
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
|
||||||
@@ -743,10 +1121,15 @@ export function Import() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium">{product.name}</span>
|
<span className="text-sm font-medium">{product.name}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-muted-foreground">UPC: {product.upc}</span>
|
<span className="text-xs text-muted-foreground">UPC: {product.upc}</span>
|
||||||
|
<Separator orientation="vertical" className="h-3" />
|
||||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||||
|
</div>
|
||||||
{product.errorDetails && (
|
{product.errorDetails && (
|
||||||
|
<div className="px-2 pb-0.5 rounded-md bg-destructive/10 border border-destructive/20">
|
||||||
<span className="text-xs text-destructive">{product.errorDetails}</span>
|
<span className="text-xs text-destructive">{product.errorDetails}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -757,7 +1140,7 @@ export function Import() {
|
|||||||
|
|
||||||
{hasDebugPermission && isDebugDataVisible && (
|
{hasDebugPermission && isDebugDataVisible && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">Submitted Payload</h3>
|
<h3 className="text-sm font-semibold">Submitted Data</h3>
|
||||||
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
||||||
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
|
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
|
||||||
</Code>
|
</Code>
|
||||||
|
|||||||
@@ -12,8 +12,27 @@ export interface SubmitNewProductsResponse {
|
|||||||
error?: unknown;
|
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 DEV_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/product/setup_new";
|
||||||
const PROD_ENDPOINT = "https://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 isHtmlResponse = (payload: string) => {
|
||||||
const trimmed = payload.trim();
|
const trimmed = payload.trim();
|
||||||
@@ -60,7 +79,7 @@ export async function submitNewProducts({
|
|||||||
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: SubmitNewProductsResponse | null = null;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(rawBody);
|
parsed = JSON.parse(rawBody);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,17 +87,103 @@ export async function submitNewProducts({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!parsed || typeof parsed !== "object") {
|
||||||
throw new Error(parsed?.message || `Request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error("Empty response from backend");
|
throw new Error("Empty response from backend");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
const parsedResponse = parsed as SubmitNewProductsResponse & Record<string, unknown>;
|
||||||
throw new Error(parsed.message || "Backend rejected product submission");
|
const extraFields = parsedResponse as Record<string, unknown>;
|
||||||
|
const normalizedResponse: SubmitNewProductsResponse = {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? extraFields.errors ?? extraFields.error_msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !normalizedResponse.success) {
|
||||||
|
return normalizedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export default {
|
|||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))'
|
||||||
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user