3 Commits

12 changed files with 2084 additions and 40 deletions

View File

@@ -13,6 +13,13 @@ const {
const TIMEZONE = 'America/New_York';
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
// Cherry Box order types to exclude when excludeCherryBox=true is passed
// 3 = cherrybox_subscription, 4 = cherrybox_sending, 5 = cherrybox_subscription_renew, 7 = cherrybox_refund
const EXCLUDED_ORDER_TYPES = [3, 4, 5, 7];
const getCherryBoxClause = (exclude) => exclude ? `order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
const getCherryBoxClauseAliased = (alias, exclude) => exclude ? `${alias}.order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
const parseBoolParam = (value) => value === 'true' || value === '1';
// Image URL generation utility
const getImageUrls = (pid, iid = 1) => {
const imageUrlBase = 'https://sbing.com/i/products/0000/';
@@ -39,14 +46,15 @@ router.get('/stats', async (req, res) => {
try {
const mainOperation = async () => {
const { timeRange, startDate, endDate } = req.query;
console.log(`[STATS] Getting DB connection...`);
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
const { connection, release } = await getDbConnection();
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
// Main order stats query
// Main order stats query (optionally excludes Cherry Box orders)
const mainStatsQuery = `
SELECT
COUNT(*) as orderCount,
@@ -61,32 +69,32 @@ router.get('/stats', async (req, res) => {
SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount,
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [mainStats] = await connection.execute(mainStatsQuery, params);
const stats = mainStats[0];
// Refunds query
// Refunds query (optionally excludes Cherry Box orders)
const refundsQuery = `
SELECT
COUNT(*) as refundCount,
ABS(SUM(payment_amount)) as refundTotal
FROM order_payment op
JOIN _order o ON op.order_id = o.order_id
WHERE payment_amount < 0 AND o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
`;
const [refundStats] = await connection.execute(refundsQuery, params);
// Best revenue day query
// Best revenue day query (optionally excludes Cherry Box orders)
const bestDayQuery = `
SELECT
DATE(date_placed) as date,
SUM(summary_total) as revenue,
COUNT(*) as orders
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY DATE(date_placed)
ORDER BY revenue DESC
LIMIT 1
@@ -94,7 +102,7 @@ router.get('/stats', async (req, res) => {
const [bestDayResult] = await connection.execute(bestDayQuery, params);
// Peak hour query (for single day periods)
// Peak hour query (for single day periods, optionally excludes Cherry Box orders)
let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = `
@@ -102,7 +110,7 @@ router.get('/stats', async (req, res) => {
HOUR(date_placed) as hour,
COUNT(*) as count
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY HOUR(date_placed)
ORDER BY count DESC
LIMIT 1
@@ -122,7 +130,7 @@ router.get('/stats', async (req, res) => {
}
// Brands and categories query - simplified for now since we don't have the category tables
// We'll use a simple approach without company table for now
// We'll use a simple approach without company table for now (optionally excludes Cherry Box orders)
const brandsQuery = `
SELECT
'Various Brands' as brandName,
@@ -132,13 +140,13 @@ router.get('/stats', async (req, res) => {
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
HAVING revenue > 0
`;
const [brandsResult] = await connection.execute(brandsQuery, params);
// For categories, we'll use a simplified approach
// For categories, we'll use a simplified approach (optionally excludes Cherry Box orders)
const categoriesQuery = `
SELECT
'General' as categoryName,
@@ -148,13 +156,13 @@ router.get('/stats', async (req, res) => {
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
HAVING revenue > 0
`;
const [categoriesResult] = await connection.execute(categoriesQuery, params);
// Shipping locations query
// Shipping locations query (optionally excludes Cherry Box orders)
const shippingQuery = `
SELECT
ship_country,
@@ -162,7 +170,7 @@ router.get('/stats', async (req, res) => {
ship_method_selected,
COUNT(*) as count
FROM _order
WHERE order_status IN (100, 92) AND ${whereClause}
WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY ship_country, ship_state, ship_method_selected
`;
@@ -171,13 +179,13 @@ router.get('/stats', async (req, res) => {
// Process shipping data
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
// Order value range query
// Order value range query (optionally excludes Cherry Box orders)
const orderRangeQuery = `
SELECT
MIN(summary_total) as smallest,
MAX(summary_total) as largest
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
@@ -189,7 +197,7 @@ router.get('/stats', async (req, res) => {
}
// Previous period comparison data
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate);
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
const response = {
timeRange: dateRange,
@@ -316,13 +324,14 @@ router.get('/stats', async (req, res) => {
router.get('/stats/details', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate, metric, daily } = req.query;
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Daily breakdown query
// Daily breakdown query (optionally excludes Cherry Box orders)
const dailyQuery = `
SELECT
DATE(date_placed) as date,
@@ -331,7 +340,7 @@ router.get('/stats/details', async (req, res) => {
AVG(summary_total) as averageOrderValue,
SUM(stats_prod_pieces) as itemCount
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY DATE(date_placed)
ORDER BY DATE(date_placed)
`;
@@ -359,7 +368,7 @@ router.get('/stats/details', async (req, res) => {
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
}
// Get previous period daily data
// Get previous period daily data (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT
DATE(date_placed) as date,
@@ -367,7 +376,7 @@ router.get('/stats/details', async (req, res) => {
SUM(summary_total) as prevRevenue,
AVG(summary_total) as prevAvgOrderValue
FROM _order
WHERE order_status > 15 AND ${prevWhereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
GROUP BY DATE(date_placed)
`;
@@ -424,7 +433,8 @@ router.get('/stats/details', async (req, res) => {
router.get('/financials', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate } = req.query;
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
@@ -450,7 +460,7 @@ router.get('/financials', async (req, res) => {
});
const [totalsRows] = await connection.execute(
buildFinancialTotalsQuery(financialWhere),
buildFinancialTotalsQuery(financialWhere, excludeCB),
params
);
@@ -462,7 +472,7 @@ router.get('/financials', async (req, res) => {
});
const [trendRows] = await connection.execute(
buildFinancialTrendQuery(financialWhere),
buildFinancialTrendQuery(financialWhere, excludeCB),
params
);
@@ -489,7 +499,7 @@ router.get('/financials', async (req, res) => {
});
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
const [previousRows] = await connection.execute(
buildFinancialTotalsQuery(prevWhere),
buildFinancialTotalsQuery(prevWhere, excludeCB),
previousRange.params
);
previousTotals = normalizeFinancialTotals(previousRows[0]);
@@ -549,12 +559,14 @@ router.get('/financials', async (req, res) => {
router.get('/products', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate } = req.query;
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Products query (optionally excludes Cherry Box orders)
const productsQuery = `
SELECT
p.pid,
@@ -566,7 +578,7 @@ router.get('/products', async (req, res) => {
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
GROUP BY p.pid, p.description
ORDER BY totalRevenue DESC
LIMIT 500
@@ -609,7 +621,8 @@ router.get('/products', async (req, res) => {
router.get('/projection', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate } = req.query;
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
// Only provide projections for incomplete periods
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
@@ -622,19 +635,20 @@ router.get('/projection', async (req, res) => {
// Get current period data
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Current period query (optionally excludes Cherry Box orders)
const currentQuery = `
SELECT
SUM(summary_total) as currentRevenue,
COUNT(*) as currentOrders
FROM _order
WHERE order_status > 15 AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [currentResult] = await connection.execute(currentQuery, params);
const current = currentResult[0];
// Get historical data for the same period type
const historicalQuery = await getHistoricalProjectionData(connection, timeRange);
const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB);
// Calculate projection based on current progress and historical patterns
const periodProgress = calculatePeriodProgress(timeRange);
@@ -765,7 +779,24 @@ function calculatePeriodProgress(timeRange) {
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
function buildFinancialTotalsQuery(whereClause) {
function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
// Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) {
return `
SELECT
COALESCE(SUM(r.sale_amount), 0) as grossSales,
COALESCE(SUM(r.refund_amount), 0) as refunds,
COALESCE(SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount), 0) as shippingFees,
COALESCE(SUM(r.tax_collected_amount), 0) as taxCollected,
COALESCE(SUM(r.discount_total_amount), 0) as discounts,
COALESCE(SUM(r.cogs_amount), 0) as cogs
FROM report_sales_data r
JOIN _order o ON r.order_id = o.order_id
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
AND r.action IN (1, 2, 3)
AND ${getCherryBoxClauseAliased('o', true)}
`;
}
return `
SELECT
COALESCE(SUM(sale_amount), 0) as grossSales,
@@ -780,8 +811,31 @@ function buildFinancialTotalsQuery(whereClause) {
`;
}
function buildFinancialTrendQuery(whereClause) {
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
const businessDayOffset = BUSINESS_DAY_START_HOUR;
// Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) {
return `
SELECT
DATE_FORMAT(
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SUM(r.sale_amount) as grossSales,
SUM(r.refund_amount) as refunds,
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
SUM(r.tax_collected_amount) as taxCollected,
SUM(r.discount_total_amount) as discounts,
SUM(r.cogs_amount) as cogs
FROM report_sales_data r
JOIN _order o ON r.order_id = o.order_id
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
AND r.action IN (1, 2, 3)
AND ${getCherryBoxClauseAliased('o', true)}
GROUP BY businessDate
ORDER BY businessDate ASC
`;
}
return `
SELECT
DATE_FORMAT(
@@ -940,7 +994,7 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
}
async function getPreviousPeriodData(connection, timeRange, startDate, endDate) {
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
// Calculate previous period dates
let prevWhereClause, prevParams;
@@ -962,13 +1016,14 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate)
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
}
// Previous period query (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT
COUNT(*) as orderCount,
SUM(summary_total) as revenue,
AVG(summary_total) as averageOrderValue
FROM _order
WHERE order_status > 15 AND ${prevWhereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} AND ${prevWhereClause}
`;
const [prevResult] = await connection.execute(prevQuery, prevParams);
@@ -994,8 +1049,8 @@ function getPreviousTimeRange(timeRange) {
return map[timeRange] || timeRange;
}
async function getHistoricalProjectionData(connection, timeRange) {
// Get historical data for projection calculations
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
// This is a simplified version - you could make this more sophisticated
const historicalQuery = `
SELECT
@@ -1003,6 +1058,7 @@ async function getHistoricalProjectionData(connection, timeRange) {
COUNT(*) as orders
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
`;

View 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;

View File

@@ -21,6 +21,7 @@ const reusableImagesRouter = require('./routes/reusable-images');
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -126,6 +127,7 @@ async function startServer() {
app.use('/api/templates', templatesRouter);
app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter);
// Basic health check route
app.get('/health', (req, res) => {

View File

@@ -84,6 +84,7 @@
"@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.7.1",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
@@ -3153,6 +3154,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",

View File

@@ -87,6 +87,7 @@
"@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.7.1",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View File

@@ -22,10 +22,12 @@ const Products = lazy(() => import('./pages/Products').then(module => ({ default
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
const Forecasting = lazy(() => import('./pages/Forecasting'));
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
const Vendors = lazy(() => import('./pages/Vendors'));
const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -161,6 +163,13 @@ function App() {
</Suspense>
</Protected>
} />
<Route path="/hts-lookup" element={
<Protected page="hts_lookup">
<Suspense fallback={<PageLoading />}>
<HtsLookup />
</Suspense>
</Protected>
} />
<Route path="/forecasting" element={
<Protected page="forecasting">
<Suspense fallback={<PageLoading />}>
@@ -202,6 +211,13 @@ function App() {
</Suspense>
</Protected>
} />
<Route path="/dashboard/black-friday" element={
<Protected page="black_friday_dashboard">
<Suspense fallback={<PageLoading />}>
<BlackFridayDashboard />
</Suspense>
</Protected>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View File

@@ -6,6 +6,7 @@ import { AuthContext } from "@/contexts/AuthContext";
// Dashboard is first so users with dashboard access default to it
const PAGES = [
{ path: "/dashboard", permission: "access:dashboard" },
{ path: "/dashboard/black-friday", permission: "access:black_friday_dashboard" },
{ path: "/overview", permission: "access:overview" },
{ path: "/products", permission: "access:products" },
{ path: "/categories", permission: "access:categories" },
@@ -13,6 +14,7 @@ const PAGES = [
{ path: "/purchase-orders", permission: "access:purchase_orders" },
{ path: "/analytics", permission: "access:analytics" },
{ path: "/discount-simulator", permission: "access:discount_simulator" },
{ path: "/hts-lookup", permission: "access:hts_lookup" },
{ path: "/forecasting", permission: "access:forecasting" },
{ path: "/import", permission: "access:import" },
{ path: "/settings", permission: "access:settings" },

View File

@@ -134,6 +134,7 @@ Admin users automatically have all permissions.
| `access:purchase_orders` | Access to Purchase Orders page |
| `access:analytics` | Access to Analytics page |
| `access:discount_simulator` | Access to Discount Simulator page |
| `access:hts_lookup` | Access to HTS Lookup page |
| `access:forecasting` | Access to Forecasting page |
| `access:import` | Access to Import page |
| `access:settings` | Access to Settings page |

View File

@@ -11,6 +11,8 @@ import {
MessageCircle,
LayoutDashboard,
Percent,
FileSearch,
ShoppingCart,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -39,6 +41,12 @@ const dashboardItems = [
icon: LayoutDashboard,
url: "/dashboard",
permission: "access:dashboard"
},
{
title: "Black Friday",
icon: ShoppingCart,
url: "/dashboard/black-friday",
permission: "access:black_friday_dashboard"
}
];
@@ -94,6 +102,12 @@ const toolsItems = [
url: "/discount-simulator",
permission: "access:discount_simulator"
},
{
title: "HTS Lookup",
icon: FileSearch,
url: "/hts-lookup",
permission: "access:hts_lookup"
},
{
title: "Forecasting",
icon: IconCrystalBall,

File diff suppressed because it is too large Load Diff

View 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>
);
}

File diff suppressed because one or more lines are too long