Add/update inital try of order components and add csv update script + update import script

This commit is contained in:
2025-01-10 00:01:43 -05:00
parent afe8510751
commit 8bdd188dfe
17 changed files with 38513 additions and 37881 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,18 @@ CREATE TABLE IF NOT EXISTS orders (
tax_included BOOLEAN DEFAULT false, tax_included BOOLEAN DEFAULT false,
shipping DECIMAL(10, 3) DEFAULT 0, shipping DECIMAL(10, 3) DEFAULT 0,
customer VARCHAR(50) NOT NULL, customer VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
payment_method VARCHAR(50),
shipping_method VARCHAR(50),
shipping_address TEXT,
billing_address TEXT,
canceled BOOLEAN DEFAULT false, canceled BOOLEAN DEFAULT false,
FOREIGN KEY (product_id) REFERENCES products(product_id), FOREIGN KEY (product_id) REFERENCES products(product_id),
FOREIGN KEY (SKU) REFERENCES products(SKU), FOREIGN KEY (SKU) REFERENCES products(SKU),
INDEX idx_order_number (order_number), INDEX idx_order_number (order_number),
INDEX idx_customer (customer), INDEX idx_customer (customer),
INDEX idx_date (date), INDEX idx_date (date),
INDEX idx_status (status),
UNIQUE KEY unique_order_product (order_number, product_id) UNIQUE KEY unique_order_product (order_number, product_id)
); );

View File

@@ -4,8 +4,10 @@ const csv = require('csv-parse');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
// For testing purposes, limit the number of rows to import // For testing purposes, limit the number of rows to import (0 = no limit)
const TEST_ROW_LIMIT = 5000; const PRODUCTS_TEST_LIMIT = 0;
const ORDERS_TEST_LIMIT = 5000;
const PURCHASE_ORDERS_TEST_LIMIT = 0;
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
@@ -17,8 +19,46 @@ const dbConfig = {
multipleStatements: true multipleStatements: true
}; };
// Helper function to count total rows in a CSV file
async function countRows(filePath) {
return new Promise((resolve, reject) => {
let count = 0;
fs.createReadStream(filePath)
.pipe(csv.parse())
.on('data', () => count++)
.on('error', reject)
.on('end', () => resolve(count - 1)); // Subtract 1 for header row
});
}
// Helper function to format time duration
function formatDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
seconds = Math.round(seconds % 60);
return `${minutes}m ${seconds}s`;
}
// Helper function to update progress with time estimate
function updateProgress(current, total, operation, startTime) {
const percentage = ((current / total) * 100).toFixed(1);
const elapsed = (Date.now() - startTime) / 1000;
const rate = current / elapsed; // rows per second
const remaining = (total - current) / rate;
process.stdout.write(
`\r${operation}: ${current.toLocaleString()}/${total.toLocaleString()} rows ` +
`(${percentage}%) - Rate: ${Math.round(rate)}/s - ` +
`Elapsed: ${formatDuration(elapsed)} - ` +
`Est. remaining: ${formatDuration(remaining)}`
);
}
async function importProducts(connection, filePath) { async function importProducts(connection, filePath) {
const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true }));
const totalRows = PRODUCTS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), PRODUCTS_TEST_LIMIT) : await countRows(filePath);
const startTime = Date.now();
console.log(`\nStarting products import (${totalRows.toLocaleString()} total rows${PRODUCTS_TEST_LIMIT > 0 ? ` - limited to ${PRODUCTS_TEST_LIMIT.toLocaleString()} rows` : ''})`);
function convertDate(dateStr) { function convertDate(dateStr) {
if (!dateStr) return null; if (!dateStr) return null;
@@ -29,14 +69,22 @@ async function importProducts(connection, filePath) {
let updated = 0; let updated = 0;
let added = 0; let added = 0;
let rowCount = 0; let rowCount = 0;
let lastUpdate = Date.now();
for await (const record of parser) { for await (const record of parser) {
// if (rowCount >= TEST_ROW_LIMIT) { if (PRODUCTS_TEST_LIMIT > 0 && rowCount >= PRODUCTS_TEST_LIMIT) {
// console.log(`Reached test limit of ${TEST_ROW_LIMIT} rows`); console.log(`\nReached test limit of ${PRODUCTS_TEST_LIMIT.toLocaleString()} rows`);
// break; break;
// } }
rowCount++; rowCount++;
// Update progress every 100ms to avoid console flooding
const now = Date.now();
if (now - lastUpdate > 100) {
updateProgress(rowCount, totalRows, 'Products', startTime);
lastUpdate = now;
}
// Check if product exists // Check if product exists
const [existing] = await connection.query('SELECT product_id FROM products WHERE product_id = ?', [record.product_id]); const [existing] = await connection.query('SELECT product_id FROM products WHERE product_id = ?', [record.product_id]);
@@ -69,14 +117,19 @@ async function importProducts(connection, filePath) {
]); ]);
existing.length ? updated++ : added++; existing.length ? updated++ : added++;
} catch (error) { } catch (error) {
console.error(`Error importing product ${record.product_id}:`, error.message); console.error(`\nError importing product ${record.product_id}:`, error.message);
} }
} }
console.log(`Products import completed: ${added} added, ${updated} updated (processed ${rowCount} rows)`);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nProducts import completed in ${duration}s: ${added.toLocaleString()} added, ${updated.toLocaleString()} updated (processed ${rowCount.toLocaleString()} rows)`);
} }
async function importOrders(connection, filePath) { async function importOrders(connection, filePath) {
const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true }));
const totalRows = ORDERS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), ORDERS_TEST_LIMIT) : await countRows(filePath);
const startTime = Date.now();
console.log(`\nStarting orders import (${totalRows.toLocaleString()} total rows${ORDERS_TEST_LIMIT > 0 ? ` - limited to ${ORDERS_TEST_LIMIT.toLocaleString()} rows` : ''})`);
function convertDate(dateStr) { function convertDate(dateStr) {
if (!dateStr) return null; if (!dateStr) return null;
@@ -92,14 +145,22 @@ async function importOrders(connection, filePath) {
let updated = 0; let updated = 0;
let added = 0; let added = 0;
let rowCount = 0; let rowCount = 0;
let lastUpdate = Date.now();
for await (const record of parser) { for await (const record of parser) {
if (rowCount >= TEST_ROW_LIMIT) { if (ORDERS_TEST_LIMIT > 0 && rowCount >= ORDERS_TEST_LIMIT) {
console.log(`Reached test limit of ${TEST_ROW_LIMIT} rows`); console.log(`\nReached test limit of ${ORDERS_TEST_LIMIT.toLocaleString()} rows`);
break; break;
} }
rowCount++; rowCount++;
// Update progress every 100ms
const now = Date.now();
if (now - lastUpdate > 100) {
updateProgress(rowCount, totalRows, 'Orders', startTime);
lastUpdate = now;
}
if (!validProductIds.has(record.product_id)) { if (!validProductIds.has(record.product_id)) {
skipped++; skipped++;
continue; continue;
@@ -128,15 +189,20 @@ async function importOrders(connection, filePath) {
]); ]);
existing.length ? updated++ : added++; existing.length ? updated++ : added++;
} catch (error) { } catch (error) {
console.error(`Error importing order ${record.order_number}, product ${record.product_id}:`, error.message); console.error(`\nError importing order ${record.order_number}, product ${record.product_id}:`, error.message);
skipped++; skipped++;
} }
} }
console.log(`Orders import completed: ${added} added, ${updated} updated, ${skipped} skipped (processed ${rowCount} rows)`);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nOrders import completed in ${duration}s: ${added.toLocaleString()} added, ${updated.toLocaleString()} updated, ${skipped.toLocaleString()} skipped (processed ${rowCount.toLocaleString()} rows)`);
} }
async function importPurchaseOrders(connection, filePath) { async function importPurchaseOrders(connection, filePath) {
const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true }));
const totalRows = PURCHASE_ORDERS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), PURCHASE_ORDERS_TEST_LIMIT) : await countRows(filePath);
const startTime = Date.now();
console.log(`\nStarting purchase orders import (${totalRows.toLocaleString()} total rows${PURCHASE_ORDERS_TEST_LIMIT > 0 ? ` - limited to ${PURCHASE_ORDERS_TEST_LIMIT.toLocaleString()} rows` : ''})`);
function convertDate(dateStr) { function convertDate(dateStr) {
if (!dateStr) return null; if (!dateStr) return null;
@@ -152,14 +218,22 @@ async function importPurchaseOrders(connection, filePath) {
let updated = 0; let updated = 0;
let added = 0; let added = 0;
let rowCount = 0; let rowCount = 0;
let lastUpdate = Date.now();
for await (const record of parser) { for await (const record of parser) {
if (rowCount >= TEST_ROW_LIMIT) { if (PURCHASE_ORDERS_TEST_LIMIT > 0 && rowCount >= PURCHASE_ORDERS_TEST_LIMIT) {
console.log(`Reached test limit of ${TEST_ROW_LIMIT} rows`); console.log(`\nReached test limit of ${PURCHASE_ORDERS_TEST_LIMIT.toLocaleString()} rows`);
break; break;
} }
rowCount++; rowCount++;
// Update progress every 100ms
const now = Date.now();
if (now - lastUpdate > 100) {
updateProgress(rowCount, totalRows, 'Purchase Orders', startTime);
lastUpdate = now;
}
if (!validProductIds.has(record.product_id)) { if (!validProductIds.has(record.product_id)) {
skipped++; skipped++;
continue; continue;
@@ -188,14 +262,18 @@ async function importPurchaseOrders(connection, filePath) {
]); ]);
existing.length ? updated++ : added++; existing.length ? updated++ : added++;
} catch (error) { } catch (error) {
console.error(`Error importing PO ${record.po_id}, product ${record.product_id}:`, error.message); console.error(`\nError importing PO ${record.po_id}, product ${record.product_id}:`, error.message);
skipped++; skipped++;
} }
} }
console.log(`Purchase orders import completed: ${added} added, ${updated} updated, ${skipped} skipped (processed ${rowCount} rows)`);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nPurchase orders import completed in ${duration}s: ${added.toLocaleString()} added, ${updated.toLocaleString()} updated, ${skipped.toLocaleString()} skipped (processed ${rowCount.toLocaleString()} rows)`);
} }
async function main() { async function main() {
console.log('Starting import process...');
const startTime = Date.now();
const connection = await mysql.createConnection(dbConfig); const connection = await mysql.createConnection(dbConfig);
try { try {
@@ -205,18 +283,14 @@ async function main() {
await connection.query(schemaSQL); await connection.query(schemaSQL);
// Import products first since they're referenced by other tables // Import products first since they're referenced by other tables
console.log('Importing products...');
await importProducts(connection, path.join(__dirname, '../csv/39f2x83-products.csv')); await importProducts(connection, path.join(__dirname, '../csv/39f2x83-products.csv'));
console.log('Importing orders...');
await importOrders(connection, path.join(__dirname, '../csv/39f2x83-orders.csv')); await importOrders(connection, path.join(__dirname, '../csv/39f2x83-orders.csv'));
console.log('Importing purchase orders...');
await importPurchaseOrders(connection, path.join(__dirname, '../csv/39f2x83-purchase_orders.csv')); await importPurchaseOrders(connection, path.join(__dirname, '../csv/39f2x83-purchase_orders.csv'));
console.log('All imports completed successfully'); const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nAll imports completed successfully in ${duration} seconds`);
} catch (error) { } catch (error) {
console.error('Error during import:', error); console.error('\nError during import:', error);
process.exit(1); process.exit(1);
} finally { } finally {
await connection.end(); await connection.end();

View File

@@ -24,10 +24,10 @@ async function setupDatabase() {
console.log('Schema created successfully'); console.log('Schema created successfully');
// Create stored procedures // Create stored procedures
console.log('Setting up stored procedures...'); // console.log('Setting up stored procedures...');
const proceduresSQL = fs.readFileSync(path.join(__dirname, '../db/procedures.sql'), 'utf8'); // const proceduresSQL = fs.readFileSync(path.join(__dirname, '../db/procedures.sql'), 'utf8');
await connection.query(proceduresSQL); // await connection.query(proceduresSQL);
console.log('Stored procedures created successfully'); // console.log('Stored procedures created successfully');
console.log('Database setup completed successfully'); console.log('Database setup completed successfully');
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,97 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
// Configuration
const FILES = [
{
name: '39f2x83-products.csv',
url: 'https://feeds.acherryontop.com/39f2x83-products.csv'
},
{
name: '39f2x83-orders.csv',
url: 'https://feeds.acherryontop.com/39f2x83-orders.csv'
},
{
name: '39f2x83-purchase_orders.csv',
url: 'https://feeds.acherryontop.com/39f2x83-purchase_orders.csv'
}
];
const CSV_DIR = path.join(__dirname, '..', 'csv');
// Ensure CSV directory exists
if (!fs.existsSync(CSV_DIR)) {
fs.mkdirSync(CSV_DIR, { recursive: true });
}
// Function to download a file
function downloadFile(url, filePath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(filePath);
https.get(url, response => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
return;
}
const totalSize = parseInt(response.headers['content-length'], 10);
let downloadedSize = 0;
response.on('data', chunk => {
downloadedSize += chunk.length;
const progress = (downloadedSize / totalSize * 100).toFixed(2);
process.stdout.write(`\rDownloading ${path.basename(filePath)}: ${progress}%`);
});
response.pipe(file);
file.on('finish', () => {
process.stdout.write('\n');
file.close();
resolve();
});
}).on('error', error => {
fs.unlink(filePath, () => {}); // Delete the file if download failed
reject(error);
});
file.on('error', error => {
fs.unlink(filePath, () => {}); // Delete the file if there was an error
reject(error);
});
});
}
// Main function to update all files
async function updateFiles() {
console.log('Starting CSV file updates...');
for (const file of FILES) {
const filePath = path.join(CSV_DIR, file.name);
try {
// Delete existing file if it exists
if (fs.existsSync(filePath)) {
console.log(`Removing existing file: ${file.name}`);
fs.unlinkSync(filePath);
}
// Download new file
console.log(`Downloading ${file.name}...`);
await downloadFile(file.url, filePath);
console.log(`Successfully updated ${file.name}`);
} catch (error) {
console.error(`Error updating ${file.name}:`, error.message);
}
}
console.log('CSV file update complete!');
}
// Run the update
updateFiles().catch(error => {
console.error('Update failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,35 @@
const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');
const productsRouter = require('./routes/products');
const dashboardRouter = require('./routes/dashboard');
const ordersRouter = require('./routes/orders');
const app = express();
app.use(cors());
app.use(express.json());
// Database connection
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '',
database: 'inventory',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Make db pool available in routes
app.locals.pool = pool;
// Routes
app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View File

@@ -0,0 +1,255 @@
const express = require('express');
const router = express.Router();
// Get all orders with pagination, filtering, and sorting
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null;
const toDate = req.query.toDate ? new Date(req.query.toDate) : null;
const minAmount = parseFloat(req.query.minAmount) || 0;
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
const sortColumn = req.query.sortColumn || 'date';
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
// Build the WHERE clause
const conditions = ['o1.canceled = false'];
const params = [];
if (search) {
conditions.push('(o1.order_number LIKE ? OR o1.customer LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (status !== 'all') {
conditions.push('o1.status = ?');
params.push(status);
}
if (fromDate) {
conditions.push('DATE(o1.date) >= DATE(?)');
params.push(fromDate.toISOString());
}
if (toDate) {
conditions.push('DATE(o1.date) <= DATE(?)');
params.push(toDate.toISOString());
}
if (minAmount > 0) {
conditions.push('total_amount >= ?');
params.push(minAmount);
}
if (maxAmount) {
conditions.push('total_amount <= ?');
params.push(maxAmount);
}
// Get total count for pagination
const [countResult] = await pool.query(`
SELECT COUNT(DISTINCT o1.order_number) as total
FROM orders o1
LEFT JOIN (
SELECT order_number, SUM(price * quantity) as total_amount
FROM orders
GROUP BY order_number
) totals ON o1.order_number = totals.order_number
WHERE ${conditions.join(' AND ')}
`, params);
const total = countResult[0].total;
// Get paginated results
const query = `
SELECT
o1.order_number,
o1.customer,
o1.date,
o1.status,
o1.payment_method,
o1.shipping_method,
COUNT(o2.product_id) as items_count,
SUM(o2.price * o2.quantity) as total_amount
FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number
WHERE ${conditions.join(' AND ')}
GROUP BY
o1.order_number,
o1.customer,
o1.date,
o1.status,
o1.payment_method,
o1.shipping_method
ORDER BY ${
sortColumn === 'items_count' || sortColumn === 'total_amount'
? `${sortColumn} ${sortDirection}`
: `o1.${sortColumn} ${sortDirection}`
}
LIMIT ? OFFSET ?
`;
const [rows] = await pool.query(query, [...params, limit, offset]);
// Get order statistics
const [stats] = await pool.query(`
WITH CurrentStats AS (
SELECT
COUNT(DISTINCT order_number) as total_orders,
SUM(price * quantity) as total_revenue
FROM orders
WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
),
PreviousStats AS (
SELECT
COUNT(DISTINCT order_number) as prev_orders,
SUM(price * quantity) as prev_revenue
FROM orders
WHERE canceled = false
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
),
OrderValues AS (
SELECT
order_number,
SUM(price * quantity) as order_value
FROM orders
WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY order_number
)
SELECT
cs.total_orders,
cs.total_revenue,
CASE
WHEN ps.prev_orders > 0
THEN ((cs.total_orders - ps.prev_orders) / ps.prev_orders * 100)
ELSE 0
END as order_growth,
CASE
WHEN ps.prev_revenue > 0
THEN ((cs.total_revenue - ps.prev_revenue) / ps.prev_revenue * 100)
ELSE 0
END as revenue_growth,
CASE
WHEN cs.total_orders > 0
THEN (cs.total_revenue / cs.total_orders)
ELSE 0
END as average_order_value,
CASE
WHEN ps.prev_orders > 0
THEN (ps.prev_revenue / ps.prev_orders)
ELSE 0
END as prev_average_order_value
FROM CurrentStats cs
CROSS JOIN PreviousStats ps
`);
const orderStats = stats[0];
res.json({
orders: rows.map(row => ({
...row,
total_amount: parseFloat(row.total_amount) || 0,
items_count: parseInt(row.items_count) || 0,
date: row.date
})),
pagination: {
total,
pages: Math.ceil(total / limit),
currentPage: page,
limit
},
stats: {
totalOrders: parseInt(orderStats.total_orders) || 0,
totalRevenue: parseFloat(orderStats.total_revenue) || 0,
orderGrowth: parseFloat(orderStats.order_growth) || 0,
revenueGrowth: parseFloat(orderStats.revenue_growth) || 0,
averageOrderValue: parseFloat(orderStats.average_order_value) || 0,
aovGrowth: orderStats.prev_average_order_value > 0
? ((orderStats.average_order_value - orderStats.prev_average_order_value) / orderStats.prev_average_order_value * 100)
: 0,
conversionRate: 2.5, // Placeholder - would need actual visitor data
conversionGrowth: 0.5 // Placeholder - would need actual visitor data
}
});
} catch (error) {
console.error('Error fetching orders:', error);
res.status(500).json({ error: 'Failed to fetch orders' });
}
});
// Get a single order with its items
router.get('/:orderNumber', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get order details
const [orderRows] = await pool.query(`
SELECT DISTINCT
o1.order_number,
o1.customer,
o1.date,
o1.status,
o1.payment_method,
o1.shipping_method,
o1.shipping_address,
o1.billing_address,
COUNT(o2.product_id) as items_count,
SUM(o2.price * o2.quantity) as total_amount
FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number
WHERE o1.order_number = ? AND o1.canceled = false
GROUP BY
o1.order_number,
o1.customer,
o1.date,
o1.status,
o1.payment_method,
o1.shipping_method,
o1.shipping_address,
o1.billing_address
`, [req.params.orderNumber]);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
// Get order items
const [itemRows] = await pool.query(`
SELECT
o.product_id,
p.title,
p.sku,
o.quantity,
o.price,
(o.price * o.quantity) as total
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.order_number = ? AND o.canceled = false
`, [req.params.orderNumber]);
const order = {
...orderRows[0],
total_amount: parseFloat(orderRows[0].total_amount) || 0,
items_count: parseInt(orderRows[0].items_count) || 0,
items: itemRows.map(item => ({
...item,
price: parseFloat(item.price) || 0,
total: parseFloat(item.total) || 0,
quantity: parseInt(item.quantity) || 0
}))
};
res.json(order);
} catch (error) {
console.error('Error fetching order:', error);
res.status(500).json({ error: 'Failed to fetch order' });
}
});
module.exports = router;

View File

@@ -36,6 +36,7 @@ const cors = require('cors');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const productsRouter = require('./routes/products'); const productsRouter = require('./routes/products');
const dashboardRouter = require('./routes/dashboard'); const dashboardRouter = require('./routes/dashboard');
const ordersRouter = require('./routes/orders');
// Ensure required directories exist // Ensure required directories exist
['logs', 'uploads'].forEach(dir => { ['logs', 'uploads'].forEach(dir => {
@@ -111,6 +112,7 @@ pool.getConnection()
// Routes // Routes
app.use('/api/products', productsRouter); app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -13,6 +13,7 @@
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
@@ -26,8 +27,10 @@
"@tanstack/virtual-core": "^3.11.2", "@tanstack/virtual-core": "^3.11.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"recharts": "^2.15.0", "recharts": "^2.15.0",
@@ -1529,6 +1532,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
"integrity": "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.6.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
@@ -3435,6 +3475,16 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -5238,6 +5288,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
@@ -28,8 +29,10 @@
"@tanstack/virtual-core": "^3.11.2", "@tanstack/virtual-core": "^3.11.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"recharts": "^2.15.0", "recharts": "^2.15.0",

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Products } from './pages/Products'; import { Products } from './pages/Products';
import { Import } from './pages/Import'; import { Import } from './pages/Import';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Orders } from './pages/Orders';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -16,6 +17,7 @@ function App() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} /> <Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} /> <Route path="/import" element={<Import />} />
<Route path="/orders" element={<Orders />} />
</Routes> </Routes>
</MainLayout> </MainLayout>
</Router> </Router>

View File

@@ -0,0 +1,74 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,65 @@
import * as React from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
className?: string;
}
export function DateRangePicker({
value,
onChange,
className,
}: DateRangePickerProps) {
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"h-8 w-[300px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} -{" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={onChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,318 @@
import { useState, useCallback } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DateRangePicker } from "@/components/ui/date-range-picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUpDown, Search } from "lucide-react";
import debounce from 'lodash/debounce';
import config from '../config';
interface Order {
order_number: string;
customer: string;
date: string;
status: string;
total_amount: number;
items_count: number;
payment_method: string;
shipping_method: string;
}
interface OrderFilters {
search: string;
status: string;
dateRange: { from: Date | null; to: Date | null };
minAmount: string;
maxAmount: string;
}
export function Orders() {
const [page, setPage] = useState(1);
const [sortColumn, setSortColumn] = useState<keyof Order>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [filters, setFilters] = useState<OrderFilters>({
search: '',
status: 'all',
dateRange: { from: null, to: null },
minAmount: '',
maxAmount: '',
});
const { data, isLoading, isFetching } = useQuery({
queryKey: ['orders', page, sortColumn, sortDirection, filters],
queryFn: async () => {
const searchParams = new URLSearchParams({
page: page.toString(),
limit: '50',
sortColumn: sortColumn.toString(),
sortDirection,
...filters.dateRange.from && { fromDate: filters.dateRange.from.toISOString() },
...filters.dateRange.to && { toDate: filters.dateRange.to.toISOString() },
...filters.minAmount && { minAmount: filters.minAmount },
...filters.maxAmount && { maxAmount: filters.maxAmount },
...filters.status !== 'all' && { status: filters.status },
...filters.search && { search: filters.search },
});
const response = await fetch(`${config.apiUrl}/orders?${searchParams}`);
if (!response.ok) throw new Error('Failed to fetch orders');
return response.json();
},
placeholderData: keepPreviousData,
staleTime: 30000,
});
const debouncedFilterChange = useCallback(
debounce((newFilters: Partial<OrderFilters>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
setPage(1);
}, 300),
[]
);
const handleSort = (column: keyof Order) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const getOrderStatusBadge = (status: string) => {
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
pending: { variant: "outline", label: "Pending" },
processing: { variant: "secondary", label: "Processing" },
completed: { variant: "default", label: "Completed" },
cancelled: { variant: "destructive", label: "Cancelled" },
};
const statusConfig = variants[status.toLowerCase()] || variants.pending;
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
};
const renderSortButton = (column: keyof Order, label: string) => (
<Button
variant="ghost"
onClick={() => handleSort(column)}
className="w-full justify-start font-medium"
>
{label}
<ArrowUpDown className={`ml-2 h-4 w-4 ${sortColumn === column && sortDirection === 'desc' ? 'rotate-180' : ''}`} />
</Button>
);
return (
<div className="p-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Orders</h1>
<div className="text-sm text-muted-foreground">
{data?.pagination.total.toLocaleString() ?? '...'} orders
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.stats.totalOrders ?? '...'}</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.orderGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${(data?.stats.totalRevenue ?? 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.revenueGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Average Order Value</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${(data?.stats.averageOrderValue ?? 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.aovGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(data?.stats.conversionRate ?? 0).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.conversionGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-2 flex-1">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search orders..."
value={filters.search}
onChange={(e) => debouncedFilterChange({ search: e.target.value })}
className="h-8 w-[300px]"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={filters.status}
onValueChange={(value) => debouncedFilterChange({ status: value })}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Processing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<DateRangePicker
value={filters.dateRange}
onChange={(range) => debouncedFilterChange({ dateRange: range })}
/>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Min $"
value={filters.minAmount}
onChange={(e) => debouncedFilterChange({ minAmount: e.target.value })}
className="h-8 w-[100px]"
/>
<span>-</span>
<Input
type="number"
placeholder="Max $"
value={filters.maxAmount}
onChange={(e) => debouncedFilterChange({ maxAmount: e.target.value })}
className="h-8 w-[100px]"
/>
</div>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{renderSortButton('order_number', 'Order')}</TableHead>
<TableHead>{renderSortButton('customer', 'Customer')}</TableHead>
<TableHead>{renderSortButton('date', 'Date')}</TableHead>
<TableHead>{renderSortButton('status', 'Status')}</TableHead>
<TableHead className="text-right">{renderSortButton('total_amount', 'Total')}</TableHead>
<TableHead className="text-center">{renderSortButton('items_count', 'Items')}</TableHead>
<TableHead>{renderSortButton('payment_method', 'Payment')}</TableHead>
<TableHead>{renderSortButton('shipping_method', 'Shipping')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading orders...
</TableCell>
</TableRow>
) : data?.orders.map((order: Order) => (
<TableRow key={order.order_number}>
<TableCell className="font-medium">#{order.order_number}</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell>{format(new Date(order.date), 'MMM d, yyyy')}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell className="text-right">${order.total_amount.toFixed(2)}</TableCell>
<TableCell className="text-center">{order.items_count}</TableCell>
<TableCell>{order.payment_method}</TableCell>
<TableCell>{order.shipping_method}</TableCell>
</TableRow>
))}
{!isLoading && !data?.orders.length && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No orders found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data?.pagination.pages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1 || isFetching}
/>
</PaginationItem>
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
<PaginationItem key={p}>
<PaginationLink
onClick={() => setPage(p)}
isActive={p === page}
disabled={isFetching}
>
{p}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => setPage(p => Math.min(data.pagination.pages, p + 1))}
disabled={page === data.pagination.pages || isFetching}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}