Add/update inital try of order components and add csv update script + update import script
This commit is contained in:
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
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
97
inventory-server/scripts/update-csv.js
Normal file
97
inventory-server/scripts/update-csv.js
Normal 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);
|
||||||
|
});
|
||||||
35
inventory-server/src/app.js
Normal file
35
inventory-server/src/app.js
Normal 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}`);
|
||||||
|
});
|
||||||
255
inventory-server/src/routes/orders.js
Normal file
255
inventory-server/src/routes/orders.js
Normal 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;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
64
inventory/package-lock.json
generated
64
inventory/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
74
inventory/src/components/ui/calendar.tsx
Normal file
74
inventory/src/components/ui/calendar.tsx
Normal 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 }
|
||||||
65
inventory/src/components/ui/date-range-picker.tsx
Normal file
65
inventory/src/components/ui/date-range-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
inventory/src/components/ui/popover.tsx
Normal file
31
inventory/src/components/ui/popover.tsx
Normal 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 }
|
||||||
318
inventory/src/pages/Orders.tsx
Normal file
318
inventory/src/pages/Orders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user