Compare commits
3 Commits
fe70b56d24
...
24e2d01ccc
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e2d01ccc | |||
| 43d7775d08 | |||
| 527dec4d49 |
270
inventory-server/src/routes/import.js
Normal file
270
inventory-server/src/routes/import.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Client } = require('ssh2');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
// Helper function to setup SSH tunnel
|
||||||
|
async function setupSshTunnel() {
|
||||||
|
const sshConfig = {
|
||||||
|
host: process.env.PROD_SSH_HOST,
|
||||||
|
port: process.env.PROD_SSH_PORT || 22,
|
||||||
|
username: process.env.PROD_SSH_USER,
|
||||||
|
privateKey: process.env.PROD_SSH_KEY_PATH
|
||||||
|
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
|
||||||
|
: undefined,
|
||||||
|
compress: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.PROD_DB_HOST || 'localhost',
|
||||||
|
user: process.env.PROD_DB_USER,
|
||||||
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
|
database: process.env.PROD_DB_NAME,
|
||||||
|
port: process.env.PROD_DB_PORT || 3306,
|
||||||
|
timezone: 'Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ssh = new Client();
|
||||||
|
|
||||||
|
ssh.on('error', (err) => {
|
||||||
|
console.error('SSH connection error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ssh.on('ready', () => {
|
||||||
|
ssh.forwardOut(
|
||||||
|
'127.0.0.1',
|
||||||
|
0,
|
||||||
|
dbConfig.host,
|
||||||
|
dbConfig.port,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve({ ssh, stream, dbConfig });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).connect(sshConfig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all options for import fields
|
||||||
|
router.get('/field-options', async (req, res) => {
|
||||||
|
let ssh;
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Setup SSH tunnel and get database connection
|
||||||
|
const tunnel = await setupSshTunnel();
|
||||||
|
ssh = tunnel.ssh;
|
||||||
|
|
||||||
|
// Create MySQL connection over SSH tunnel
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
...tunnel.dbConfig,
|
||||||
|
stream: tunnel.stream
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch companies (type 1)
|
||||||
|
const [companies] = await connection.query(`
|
||||||
|
SELECT cat_id, name
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type = 1
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch artists (type 40)
|
||||||
|
const [artists] = await connection.query(`
|
||||||
|
SELECT cat_id, name
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type = 40
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch sizes (type 50)
|
||||||
|
const [sizes] = await connection.query(`
|
||||||
|
SELECT cat_id, name
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type = 50
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch themes with subthemes
|
||||||
|
const [themes] = await connection.query(`
|
||||||
|
SELECT t.cat_id, t.name AS display_name, t.type, t.name AS sort_theme,
|
||||||
|
'' AS sort_subtheme, 1 AS level_order
|
||||||
|
FROM product_categories t
|
||||||
|
WHERE t.type = 20
|
||||||
|
UNION ALL
|
||||||
|
SELECT ts.cat_id, CONCAT(t.name,' - ',ts.name) AS display_name, ts.type,
|
||||||
|
t.name AS sort_theme, ts.name AS sort_subtheme, 2 AS level_order
|
||||||
|
FROM product_categories ts
|
||||||
|
JOIN product_categories t ON ts.master_cat_id = t.cat_id
|
||||||
|
WHERE ts.type = 21 AND t.type = 20
|
||||||
|
ORDER BY sort_theme, sort_subtheme
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch categories with all levels
|
||||||
|
const [categories] = await connection.query(`
|
||||||
|
SELECT s.cat_id, s.name AS display_name, s.type, s.name AS sort_section,
|
||||||
|
'' AS sort_category, '' AS sort_subcategory, '' AS sort_subsubcategory,
|
||||||
|
1 AS level_order
|
||||||
|
FROM product_categories s
|
||||||
|
WHERE s.type = 10
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.cat_id, CONCAT(s.name,' - ',c.name) AS display_name, c.type,
|
||||||
|
s.name AS sort_section, c.name AS sort_category, '' AS sort_subcategory,
|
||||||
|
'' AS sort_subsubcategory, 2 AS level_order
|
||||||
|
FROM product_categories c
|
||||||
|
JOIN product_categories s ON c.master_cat_id = s.cat_id
|
||||||
|
WHERE c.type = 11 AND s.type = 10
|
||||||
|
UNION ALL
|
||||||
|
SELECT sc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name) AS display_name,
|
||||||
|
sc.type, s.name AS sort_section, c.name AS sort_category,
|
||||||
|
sc.name AS sort_subcategory, '' AS sort_subsubcategory, 3 AS level_order
|
||||||
|
FROM product_categories sc
|
||||||
|
JOIN product_categories c ON sc.master_cat_id = c.cat_id
|
||||||
|
JOIN product_categories s ON c.master_cat_id = s.cat_id
|
||||||
|
WHERE sc.type = 12 AND c.type = 11 AND s.type = 10
|
||||||
|
UNION ALL
|
||||||
|
SELECT ssc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name,' - ',ssc.name) AS display_name,
|
||||||
|
ssc.type, s.name AS sort_section, c.name AS sort_category,
|
||||||
|
sc.name AS sort_subcategory, ssc.name AS sort_subsubcategory, 4 AS level_order
|
||||||
|
FROM product_categories ssc
|
||||||
|
JOIN product_categories sc ON ssc.master_cat_id = sc.cat_id
|
||||||
|
JOIN product_categories c ON sc.master_cat_id = c.cat_id
|
||||||
|
JOIN product_categories s ON c.master_cat_id = s.cat_id
|
||||||
|
WHERE ssc.type = 13 AND sc.type = 12 AND c.type = 11 AND s.type = 10
|
||||||
|
ORDER BY sort_section, sort_category, sort_subcategory, sort_subsubcategory
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch colors
|
||||||
|
const [colors] = await connection.query(`
|
||||||
|
SELECT color, name, hex_color
|
||||||
|
FROM product_color_list
|
||||||
|
ORDER BY \`order\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch suppliers
|
||||||
|
const [suppliers] = await connection.query(`
|
||||||
|
SELECT supplierid as value, companyname as label
|
||||||
|
FROM suppliers
|
||||||
|
WHERE companyname <> ''
|
||||||
|
ORDER BY companyname
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fetch tax categories
|
||||||
|
const [taxCategories] = await connection.query(`
|
||||||
|
SELECT tax_code_id as value, name as label
|
||||||
|
FROM product_tax_codes
|
||||||
|
ORDER BY tax_code_id = 0 DESC, name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
companies: companies.map(c => ({ label: c.name, value: c.cat_id.toString() })),
|
||||||
|
artists: artists.map(a => ({ label: a.name, value: a.cat_id.toString() })),
|
||||||
|
sizes: sizes.map(s => ({ label: s.name, value: s.cat_id.toString() })),
|
||||||
|
themes: themes.map(t => ({
|
||||||
|
label: t.display_name,
|
||||||
|
value: t.cat_id.toString(),
|
||||||
|
type: t.type,
|
||||||
|
level: t.level_order
|
||||||
|
})),
|
||||||
|
categories: categories.map(c => ({
|
||||||
|
label: c.display_name,
|
||||||
|
value: c.cat_id.toString(),
|
||||||
|
type: c.type,
|
||||||
|
level: c.level_order
|
||||||
|
})),
|
||||||
|
colors: colors.map(c => ({
|
||||||
|
label: c.name,
|
||||||
|
value: c.color,
|
||||||
|
hexColor: c.hex_color
|
||||||
|
})),
|
||||||
|
suppliers: suppliers,
|
||||||
|
taxCategories: taxCategories,
|
||||||
|
shippingRestrictions: [
|
||||||
|
{ label: "None", value: "0" },
|
||||||
|
{ label: "US Only", value: "1" },
|
||||||
|
{ label: "Limited Quantity", value: "2" },
|
||||||
|
{ label: "US/CA Only", value: "3" },
|
||||||
|
{ label: "No FedEx 2 Day", value: "4" },
|
||||||
|
{ label: "North America Only", value: "5" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching import field options:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch import field options' });
|
||||||
|
} finally {
|
||||||
|
if (connection) await connection.end();
|
||||||
|
if (ssh) ssh.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get product lines for a specific company
|
||||||
|
router.get('/product-lines/:companyId', async (req, res) => {
|
||||||
|
let ssh;
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Setup SSH tunnel and get database connection
|
||||||
|
const tunnel = await setupSshTunnel();
|
||||||
|
ssh = tunnel.ssh;
|
||||||
|
|
||||||
|
// Create MySQL connection over SSH tunnel
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
...tunnel.dbConfig,
|
||||||
|
stream: tunnel.stream
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lines] = await connection.query(`
|
||||||
|
SELECT cat_id as value, name as label
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type = 2
|
||||||
|
AND master_cat_id = ?
|
||||||
|
ORDER BY name
|
||||||
|
`, [req.params.companyId]);
|
||||||
|
|
||||||
|
res.json(lines.map(l => ({ label: l.label, value: l.value.toString() })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching product lines:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch product lines' });
|
||||||
|
} finally {
|
||||||
|
if (connection) await connection.end();
|
||||||
|
if (ssh) ssh.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sublines for a specific product line
|
||||||
|
router.get('/sublines/:lineId', async (req, res) => {
|
||||||
|
let ssh;
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Setup SSH tunnel and get database connection
|
||||||
|
const tunnel = await setupSshTunnel();
|
||||||
|
ssh = tunnel.ssh;
|
||||||
|
|
||||||
|
// Create MySQL connection over SSH tunnel
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
...tunnel.dbConfig,
|
||||||
|
stream: tunnel.stream
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sublines] = await connection.query(`
|
||||||
|
SELECT cat_id as value, name as label
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type = 3
|
||||||
|
AND master_cat_id = ?
|
||||||
|
ORDER BY name
|
||||||
|
`, [req.params.lineId]);
|
||||||
|
|
||||||
|
res.json(sublines.map(s => ({ label: s.label, value: s.value.toString() })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sublines:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sublines' });
|
||||||
|
} finally {
|
||||||
|
if (connection) await connection.end();
|
||||||
|
if (ssh) ssh.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -17,6 +17,7 @@ const metricsRouter = require('./routes/metrics');
|
|||||||
const vendorsRouter = require('./routes/vendors');
|
const vendorsRouter = require('./routes/vendors');
|
||||||
const categoriesRouter = require('./routes/categories');
|
const categoriesRouter = require('./routes/categories');
|
||||||
const testConnectionRouter = require('./routes/test-connection');
|
const testConnectionRouter = require('./routes/test-connection');
|
||||||
|
const importRouter = require('./routes/import');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = path.resolve(process.cwd(), '.env');
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
@@ -65,58 +66,68 @@ app.use(corsMiddleware);
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Initialize database pool
|
// Initialize database pool and start server
|
||||||
const pool = initPool({
|
async function startServer() {
|
||||||
host: process.env.DB_HOST,
|
try {
|
||||||
user: process.env.DB_USER,
|
// Initialize database pool
|
||||||
password: process.env.DB_PASSWORD,
|
const pool = await initPool({
|
||||||
database: process.env.DB_NAME,
|
waitForConnections: true,
|
||||||
waitForConnections: true,
|
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
|
||||||
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
|
queueLimit: 0,
|
||||||
queueLimit: 0,
|
enableKeepAlive: true,
|
||||||
enableKeepAlive: true,
|
keepAliveInitialDelay: 0
|
||||||
keepAliveInitialDelay: 0
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Make pool available to routes
|
// Make pool available to routes
|
||||||
app.locals.pool = pool;
|
app.locals.pool = pool;
|
||||||
|
|
||||||
// Routes
|
// Set up routes after pool is initialized
|
||||||
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);
|
app.use('/api/orders', ordersRouter);
|
||||||
app.use('/api/csv', csvRouter);
|
app.use('/api/csv', csvRouter);
|
||||||
app.use('/api/analytics', analyticsRouter);
|
app.use('/api/analytics', analyticsRouter);
|
||||||
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
||||||
app.use('/api/config', configRouter);
|
app.use('/api/config', configRouter);
|
||||||
app.use('/api/metrics', metricsRouter);
|
app.use('/api/metrics', metricsRouter);
|
||||||
app.use('/api/vendors', vendorsRouter);
|
app.use('/api/vendors', vendorsRouter);
|
||||||
app.use('/api/categories', categoriesRouter);
|
app.use('/api/categories', categoriesRouter);
|
||||||
app.use('/api', testConnectionRouter);
|
app.use('/api/import', importRouter);
|
||||||
|
app.use('/api', testConnectionRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: process.env.NODE_ENV
|
environment: process.env.NODE_ENV
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// CORS error handler - must be before other error handlers
|
// CORS error handler - must be before other error handlers
|
||||||
app.use(corsErrorHandler);
|
app.use(corsErrorHandler);
|
||||||
|
|
||||||
// Error handling middleware - MUST be after routes and CORS error handler
|
// Error handling middleware - MUST be after routes and CORS error handler
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(`[${new Date().toISOString()}] Error:`, err);
|
console.error(`[${new Date().toISOString()}] Error:`, err);
|
||||||
|
|
||||||
// Send detailed error in development, generic in production
|
// Send detailed error in development, generic in production
|
||||||
const error = process.env.NODE_ENV === 'production'
|
const error = process.env.NODE_ENV === 'production'
|
||||||
? 'An internal server error occurred'
|
? 'An internal server error occurred'
|
||||||
: err.message || err;
|
: err.message || err;
|
||||||
|
|
||||||
res.status(err.status || 500).json({ error });
|
res.status(err.status || 500).json({ error });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
// Handle uncaught exceptions
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
@@ -128,17 +139,6 @@ process.on('unhandledRejection', (reason, promise) => {
|
|||||||
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
|
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test database connection
|
|
||||||
pool.getConnection()
|
|
||||||
.then(connection => {
|
|
||||||
console.log('[Database] Connected successfully');
|
|
||||||
connection.release();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[Database] Error connecting:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize client sets for SSE
|
// Initialize client sets for SSE
|
||||||
const importClients = new Set();
|
const importClients = new Set();
|
||||||
const updateClients = new Set();
|
const updateClients = new Set();
|
||||||
@@ -189,62 +189,5 @@ const setupSSE = (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the status endpoint to include reset-metrics
|
// Start the server
|
||||||
app.get('/csv/status', (req, res) => {
|
startServer();
|
||||||
res.json({
|
|
||||||
active: !!currentOperation,
|
|
||||||
type: currentOperation?.type || null,
|
|
||||||
progress: currentOperation ? {
|
|
||||||
status: currentOperation.status,
|
|
||||||
operation: currentOperation.operation,
|
|
||||||
current: currentOperation.current,
|
|
||||||
total: currentOperation.total,
|
|
||||||
percentage: currentOperation.percentage
|
|
||||||
} : null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update progress endpoint mapping
|
|
||||||
app.get('/csv/:type/progress', (req, res) => {
|
|
||||||
const { type } = req.params;
|
|
||||||
if (!['import', 'update', 'reset', 'reset-metrics'].includes(type)) {
|
|
||||||
res.status(400).json({ error: 'Invalid operation type' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSSE(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the cancel endpoint to handle reset-metrics
|
|
||||||
app.post('/csv/cancel', (req, res) => {
|
|
||||||
const { operation } = req.query;
|
|
||||||
|
|
||||||
if (!currentOperation) {
|
|
||||||
res.status(400).json({ error: 'No operation in progress' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation && operation.toLowerCase() !== currentOperation.type) {
|
|
||||||
res.status(400).json({ error: 'Operation type mismatch' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Handle cancellation based on operation type
|
|
||||||
if (currentOperation.type === 'reset-metrics') {
|
|
||||||
// Reset metrics doesn't need special cleanup
|
|
||||||
currentOperation = null;
|
|
||||||
res.json({ message: 'Reset metrics cancelled' });
|
|
||||||
} else {
|
|
||||||
// ... existing cancellation logic for other operations ...
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during cancellation:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to cancel operation' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,66 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
|
const { Client } = require('ssh2');
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
function initPool(config) {
|
async function setupSshTunnel() {
|
||||||
pool = mysql.createPool(config);
|
const sshConfig = {
|
||||||
return pool;
|
host: process.env.PROD_SSH_HOST,
|
||||||
|
port: process.env.PROD_SSH_PORT || 22,
|
||||||
|
username: process.env.PROD_SSH_USER,
|
||||||
|
privateKey: process.env.PROD_SSH_KEY_PATH
|
||||||
|
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
|
||||||
|
: undefined,
|
||||||
|
compress: true
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ssh = new Client();
|
||||||
|
|
||||||
|
ssh.on('error', (err) => {
|
||||||
|
console.error('SSH connection error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ssh.on('ready', () => {
|
||||||
|
ssh.forwardOut(
|
||||||
|
'127.0.0.1',
|
||||||
|
0,
|
||||||
|
process.env.PROD_DB_HOST || 'localhost',
|
||||||
|
process.env.PROD_DB_PORT || 3306,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve({ ssh, stream });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).connect(sshConfig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPool(config) {
|
||||||
|
try {
|
||||||
|
const tunnel = await setupSshTunnel();
|
||||||
|
|
||||||
|
pool = mysql.createPool({
|
||||||
|
...config,
|
||||||
|
stream: tunnel.stream,
|
||||||
|
host: process.env.PROD_DB_HOST || 'localhost',
|
||||||
|
user: process.env.PROD_DB_USER,
|
||||||
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
|
database: process.env.PROD_DB_NAME,
|
||||||
|
port: process.env.PROD_DB_PORT || 3306
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
console.log('[Database] Connected successfully through SSH tunnel');
|
||||||
|
connection.release();
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] Error initializing pool:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConnection() {
|
async function getConnection() {
|
||||||
|
|||||||
447
inventory/package-lock.json
generated
447
inventory/package-lock.json
generated
@@ -33,10 +33,10 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@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-popover": "^1.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
@@ -5720,19 +5720,435 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cmdk": {
|
"node_modules/cmdk": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz",
|
||||||
"integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==",
|
"integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "1.0.5",
|
||||||
"@radix-ui/react-id": "^1.1.0",
|
"@radix-ui/react-primitive": "1.0.3"
|
||||||
"@radix-ui/react-primitive": "^2.0.0",
|
|
||||||
"use-sync-external-store": "^1.2.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-context": "1.0.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.0.5",
|
||||||
|
"@radix-ui/react-focus-guards": "1.0.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.0.4",
|
||||||
|
"@radix-ui/react-id": "1.0.1",
|
||||||
|
"@radix-ui/react-portal": "1.0.4",
|
||||||
|
"@radix-ui/react-presence": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-slot": "1.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.0.1",
|
||||||
|
"aria-hidden": "^1.1.1",
|
||||||
|
"react-remove-scroll": "2.5.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/primitive": "1.0.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-slot": "1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cmdk/node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
|
||||||
|
"integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.3",
|
||||||
|
"react-style-singleton": "^2.2.1",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.0",
|
||||||
|
"use-sidecar": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/codepage": {
|
"node_modules/codepage": {
|
||||||
@@ -9240,15 +9656,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -35,10 +35,10 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@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-popover": "^1.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export enum ColumnType {
|
|||||||
matchedCheckbox,
|
matchedCheckbox,
|
||||||
matchedSelect,
|
matchedSelect,
|
||||||
matchedSelectOptions,
|
matchedSelectOptions,
|
||||||
|
matchedMultiInput,
|
||||||
|
matchedMultiSelect,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MatchedOptions<T> = {
|
export type MatchedOptions<T> = {
|
||||||
@@ -63,6 +65,19 @@ export type MatchedSelectOptionsColumn<T> = {
|
|||||||
value: T
|
value: T
|
||||||
matchedOptions: MatchedOptions<T>[]
|
matchedOptions: MatchedOptions<T>[]
|
||||||
}
|
}
|
||||||
|
export type MatchedMultiInputColumn<T> = {
|
||||||
|
type: ColumnType.matchedMultiInput
|
||||||
|
index: number
|
||||||
|
header: string
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
export type MatchedMultiSelectColumn<T> = {
|
||||||
|
type: ColumnType.matchedMultiSelect
|
||||||
|
index: number
|
||||||
|
header: string
|
||||||
|
value: T
|
||||||
|
matchedOptions: MatchedOptions<T>[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Column<T extends string> =
|
export type Column<T extends string> =
|
||||||
| EmptyColumn
|
| EmptyColumn
|
||||||
@@ -71,6 +86,8 @@ export type Column<T extends string> =
|
|||||||
| MatchedSwitchColumn<T>
|
| MatchedSwitchColumn<T>
|
||||||
| MatchedSelectColumn<T>
|
| MatchedSelectColumn<T>
|
||||||
| MatchedSelectOptionsColumn<T>
|
| MatchedSelectOptionsColumn<T>
|
||||||
|
| MatchedMultiInputColumn<T>
|
||||||
|
| MatchedMultiSelectColumn<T>
|
||||||
|
|
||||||
export type Columns<T extends string> = Column<T>[]
|
export type Columns<T extends string> = Column<T>[]
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,37 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
|
|||||||
acc[column.value] = curr === "" ? undefined : curr
|
acc[column.value] = curr === "" ? undefined : curr
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
case ColumnType.matchedMultiInput: {
|
||||||
|
const field = fields.find((field) => field.key === column.value)!
|
||||||
|
if (curr) {
|
||||||
|
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
|
||||||
|
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
acc[column.value] = undefined
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
case ColumnType.matchedSelect:
|
case ColumnType.matchedSelect:
|
||||||
case ColumnType.matchedSelectOptions: {
|
case ColumnType.matchedSelectOptions: {
|
||||||
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
|
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
|
||||||
acc[column.value] = matchedOption?.value || undefined
|
acc[column.value] = matchedOption?.value || undefined
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
case ColumnType.matchedMultiSelect: {
|
||||||
|
const field = fields.find((field) => field.key === column.value)!
|
||||||
|
if (curr) {
|
||||||
|
const separator = field.fieldType.type === "multi-select" ? field.fieldType.separator || "," : ","
|
||||||
|
const entries = curr.split(separator).map(v => v.trim()).filter(Boolean)
|
||||||
|
const values = entries.map(entry => {
|
||||||
|
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
|
||||||
|
return matchedOption?.value
|
||||||
|
}).filter(Boolean) as string[]
|
||||||
|
acc[column.value] = values.length ? values : undefined
|
||||||
|
} else {
|
||||||
|
acc[column.value] = undefined
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
case ColumnType.empty:
|
case ColumnType.empty:
|
||||||
case ColumnType.ignored: {
|
case ColumnType.ignored: {
|
||||||
return acc
|
return acc
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Field } from "../../../types"
|
import type { Field, MultiSelect } from "../../../types"
|
||||||
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
|
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
|
||||||
import { uniqueEntries } from "./uniqueEntries"
|
import { uniqueEntries } from "./uniqueEntries"
|
||||||
|
|
||||||
@@ -28,10 +28,37 @@ export const setColumn = <T extends string>(
|
|||||||
value: field.key,
|
value: field.key,
|
||||||
matchedOptions,
|
matchedOptions,
|
||||||
}
|
}
|
||||||
|
case "multi-select":
|
||||||
|
const multiSelectFieldType = field.fieldType as MultiSelect
|
||||||
|
const multiSelectFieldOptions = multiSelectFieldType.options
|
||||||
|
const multiSelectUniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
|
||||||
|
const multiSelectMatchedOptions = autoMapSelectValues
|
||||||
|
? multiSelectUniqueData.map((record) => {
|
||||||
|
// Split the entry by the separator (default to comma)
|
||||||
|
const entries = record.entry.split(multiSelectFieldType.separator || ",").map(e => e.trim())
|
||||||
|
// Try to match each entry to an option
|
||||||
|
const values = entries.map(entry => {
|
||||||
|
const value = multiSelectFieldOptions.find(
|
||||||
|
(fieldOption) => fieldOption.value === entry || fieldOption.label === entry,
|
||||||
|
)?.value
|
||||||
|
return value
|
||||||
|
}).filter(Boolean) as T[]
|
||||||
|
return { ...record, value: values.length ? values[0] : undefined } as MatchedOptions<T>
|
||||||
|
})
|
||||||
|
: multiSelectUniqueData
|
||||||
|
|
||||||
|
return {
|
||||||
|
...oldColumn,
|
||||||
|
type: ColumnType.matchedMultiSelect,
|
||||||
|
value: field.key,
|
||||||
|
matchedOptions: multiSelectMatchedOptions,
|
||||||
|
}
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }
|
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }
|
||||||
case "input":
|
case "input":
|
||||||
return { index: oldColumn.index, type: ColumnType.matched, value: field.key, header: oldColumn.header }
|
return { index: oldColumn.index, type: ColumnType.matched, value: field.key, header: oldColumn.header }
|
||||||
|
case "multi-input":
|
||||||
|
return { index: oldColumn.index, type: ColumnType.matchedMultiInput, value: field.key, header: oldColumn.header }
|
||||||
default:
|
default:
|
||||||
return { index: oldColumn.index, header: oldColumn.header, type: ColumnType.empty }
|
return { index: oldColumn.index, header: oldColumn.header, type: ColumnType.empty }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { useCallback, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState, useEffect } from "react"
|
||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { Meta } from "./types"
|
import type { Meta } from "./types"
|
||||||
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
||||||
import type { Data, Field, SelectOption } from "../../types"
|
import type { Data, Field, SelectOption, MultiInput } from "../../types"
|
||||||
|
import { Check, ChevronsUpDown, ArrowDown } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -20,6 +35,9 @@ import {
|
|||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -32,16 +50,6 @@ import {
|
|||||||
AlertDialogPortal,
|
AlertDialogPortal,
|
||||||
AlertDialogOverlay,
|
AlertDialogOverlay,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
|
|
||||||
type Props<T extends string> = {
|
type Props<T extends string> = {
|
||||||
initialData: (Data<T> & Meta)[]
|
initialData: (Data<T> & Meta)[]
|
||||||
@@ -59,59 +67,212 @@ type CellProps = {
|
|||||||
const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value ?? "")
|
const [inputValue, setInputValue] = useState(value ?? "")
|
||||||
|
const [validationError, setValidationError] = useState<{level: string, message: string} | undefined>(error)
|
||||||
|
|
||||||
|
const validateRegex = (val: string) => {
|
||||||
|
const regexValidation = field.validations?.find(v => v.rule === "regex")
|
||||||
|
if (regexValidation && val) {
|
||||||
|
const regex = new RegExp(regexValidation.value, regexValidation.flags)
|
||||||
|
if (!regex.test(val)) {
|
||||||
|
return { level: regexValidation.level || "error", message: regexValidation.errorMessage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
||||||
if (fieldType.type === "select") {
|
if (fieldType.type === "select") {
|
||||||
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
|
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
|
||||||
}
|
}
|
||||||
|
if (fieldType.type === "multi-select") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
if (fieldType.type === "checkbox") {
|
if (fieldType.type === "checkbox") {
|
||||||
if (typeof value === "boolean") return value ? "Yes" : "No"
|
if (typeof value === "boolean") return value ? "Yes" : "No"
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
if (fieldType.type === "multi-input" && Array.isArray(value)) {
|
||||||
|
return value.join(", ")
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRequiredAndEmpty = field.validations?.some(v => v.rule === "required") && !value
|
const isRequired = field.validations?.some(v => v.rule === "required")
|
||||||
|
|
||||||
// Show editing UI for:
|
// Determine the current validation state
|
||||||
// 1. Error cells
|
const getValidationState = () => {
|
||||||
// 2. When actively editing
|
// Never show validation during editing
|
||||||
// 3. Required select fields that are empty
|
if (isEditing) return undefined
|
||||||
// 4. Checkbox fields (always show the checkbox)
|
|
||||||
const shouldShowEditUI = error?.level === "error" ||
|
// Only show validation errors if there's a value
|
||||||
isEditing ||
|
if (value) {
|
||||||
(field.fieldType.type === "select" && isRequiredAndEmpty) ||
|
if (error) return error
|
||||||
field.fieldType.type === "checkbox"
|
if (validationError) return validationError
|
||||||
|
} else if (isRequired && !isEditing) {
|
||||||
|
// Only show required validation when not editing and empty
|
||||||
|
return { level: "error", message: "Required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentError = getValidationState()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update validation state when value changes externally (e.g. from copy down)
|
||||||
|
if (!isEditing) {
|
||||||
|
const newValidationError = value ? validateRegex(value) : undefined
|
||||||
|
setValidationError(newValidationError)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const validateAndCommit = (newValue: string) => {
|
||||||
|
const regexError = newValue ? validateRegex(newValue) : undefined
|
||||||
|
setValidationError(regexError)
|
||||||
|
|
||||||
|
// Always commit the value
|
||||||
|
onChange(newValue)
|
||||||
|
|
||||||
|
// Only exit edit mode if there are no errors (except required field errors)
|
||||||
|
if (!error && !regexError) {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle blur for all input types
|
||||||
|
const handleBlur = () => {
|
||||||
|
validateAndCommit(inputValue)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show editing UI only when actually editing
|
||||||
|
const shouldShowEditUI = isEditing
|
||||||
|
|
||||||
if (shouldShowEditUI) {
|
if (shouldShowEditUI) {
|
||||||
switch (field.fieldType.type) {
|
switch (field.fieldType.type) {
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<Select
|
<div className="space-y-1">
|
||||||
defaultOpen={isEditing}
|
<Popover open={isEditing} onOpenChange={(open) => {
|
||||||
value={value as string || ""}
|
if (!open) handleBlur()
|
||||||
onValueChange={(newValue) => {
|
setIsEditing(open)
|
||||||
onChange(newValue)
|
}}>
|
||||||
setIsEditing(false)
|
<PopoverTrigger asChild>
|
||||||
}}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<SelectTrigger
|
role="combobox"
|
||||||
className={`w-full ${
|
aria-expanded={isEditing}
|
||||||
(error?.level === "error" || isRequiredAndEmpty)
|
className={cn(
|
||||||
? "border-destructive text-destructive"
|
"w-full justify-between",
|
||||||
: ""
|
currentError ? "border-destructive text-destructive" : "border-input"
|
||||||
}`}
|
)}
|
||||||
>
|
disabled={field.disabled}
|
||||||
<SelectValue placeholder="Select..." />
|
>
|
||||||
</SelectTrigger>
|
{value
|
||||||
<SelectContent>
|
? field.fieldType.options.find((option) => option.value === value)?.label
|
||||||
{field.fieldType.options.map((option: SelectOption) => (
|
: "Select..."}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
{option.label}
|
</Button>
|
||||||
</SelectItem>
|
</PopoverTrigger>
|
||||||
))}
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
</SelectContent>
|
<Command>
|
||||||
</Select>
|
<CommandInput placeholder="Search options..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{field.fieldType.options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onChange(currentValue)
|
||||||
|
if (field.onChange) {
|
||||||
|
field.onChange(currentValue)
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{currentError && (
|
||||||
|
<p className="text-xs text-destructive">{currentError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "multi-select":
|
||||||
|
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
|
||||||
|
return (
|
||||||
|
<Popover open={isEditing} onOpenChange={(open) => {
|
||||||
|
if (!open) handleBlur()
|
||||||
|
setIsEditing(open)
|
||||||
|
}}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isEditing}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
currentError ? "border-destructive text-destructive" : "border-input"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedValues.length > 0
|
||||||
|
? `${selectedValues.length} selected`
|
||||||
|
: "Select multiple..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search options..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{field.fieldType.options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
const valueIndex = selectedValues.indexOf(currentValue)
|
||||||
|
let newValues
|
||||||
|
if (valueIndex === -1) {
|
||||||
|
newValues = [...selectedValues, currentValue]
|
||||||
|
} else {
|
||||||
|
newValues = selectedValues.filter((_, i) => i !== valueIndex)
|
||||||
|
}
|
||||||
|
onChange(newValues)
|
||||||
|
// Don't close on selection for multi-select
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
className="pointer-events-none"
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
)
|
)
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
return (
|
return (
|
||||||
@@ -124,33 +285,52 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
default:
|
case "multi-input":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
onChange(inputValue)
|
handleBlur()
|
||||||
if (!error?.level) {
|
|
||||||
setIsEditing(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={handleBlur}
|
||||||
onChange(inputValue)
|
className={cn(
|
||||||
if (!error?.level) {
|
"w-full bg-transparent",
|
||||||
setIsEditing(false)
|
currentError ? "border-destructive text-destructive" : ""
|
||||||
}
|
)}
|
||||||
}}
|
autoFocus={!error}
|
||||||
className={`w-full bg-transparent ${
|
placeholder={`Enter values separated by ${(field.fieldType as MultiInput).separator || ","}`}
|
||||||
error?.level === "error"
|
|
||||||
? "border-destructive text-destructive"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
autoFocus={!error?.level}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleBlur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-transparent",
|
||||||
|
currentError ? "border-destructive text-destructive" : ""
|
||||||
|
)}
|
||||||
|
autoFocus={!error}
|
||||||
|
/>
|
||||||
|
{currentError && (
|
||||||
|
<p className="text-xs text-destructive">{currentError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,20 +338,108 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (field.fieldType.type !== "checkbox") {
|
if (field.fieldType.type !== "checkbox" && !field.disabled) {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue(value ?? "")
|
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`cursor-text py-2 ${
|
className={cn(
|
||||||
error?.level === "error" ? "text-destructive" : ""
|
"min-h-[36px] cursor-text p-2 rounded-md border bg-background",
|
||||||
}`}
|
currentError ? "border-destructive" : "border-input",
|
||||||
|
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
|
||||||
|
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{getDisplayValue(value, field.fieldType)}
|
<div className={cn(!value && "text-muted-foreground")}>
|
||||||
|
{value ? getDisplayValue(value, field.fieldType) : ""}
|
||||||
|
</div>
|
||||||
|
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
|
||||||
|
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
|
||||||
|
)}
|
||||||
|
{currentError && (
|
||||||
|
<div className="absolute left-0 -bottom-5 text-xs text-destructive">
|
||||||
|
{currentError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this component for the column header with copy down functionality
|
||||||
|
const ColumnHeader = <T extends string>({
|
||||||
|
field,
|
||||||
|
data,
|
||||||
|
onCopyDown
|
||||||
|
}: {
|
||||||
|
field: Field<T>,
|
||||||
|
data: (Data<T> & Meta)[],
|
||||||
|
onCopyDown: (key: T) => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 overflow-hidden text-ellipsis">
|
||||||
|
{field.label}
|
||||||
|
</div>
|
||||||
|
{data.length > 1 && !field.disabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCopyDown(field.key as T)
|
||||||
|
}}
|
||||||
|
title="Copy first row's value down"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this component for the copy down confirmation dialog
|
||||||
|
const CopyDownDialog = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
fieldLabel
|
||||||
|
}: {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
fieldLabel: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay className="z-[1400]" />
|
||||||
|
<AlertDialogContent className="z-[1500]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Confirm Copy Down
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to copy the value from the first row's "{fieldLabel}" to all rows below? This will overwrite any existing values.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => {
|
||||||
|
onConfirm()
|
||||||
|
onClose()
|
||||||
|
}}>
|
||||||
|
Copy Down
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
|
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
|
||||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
|
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -181,6 +449,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
const [filterByErrors, setFilterByErrors] = useState(false)
|
const [filterByErrors, setFilterByErrors] = useState(false)
|
||||||
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
const [isSubmitting, setSubmitting] = useState(false)
|
||||||
|
const [copyDownField, setCopyDownField] = useState<{key: T, label: string} | null>(null)
|
||||||
|
|
||||||
// Memoize filtered data to prevent recalculation on every render
|
// Memoize filtered data to prevent recalculation on every render
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
@@ -220,6 +489,25 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
[data, filteredData, updateData],
|
[data, filteredData, updateData],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const copyValueDown = useCallback((key: T, label: string) => {
|
||||||
|
setCopyDownField({ key, label })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const executeCopyDown = useCallback(() => {
|
||||||
|
if (!copyDownField || data.length <= 1) return
|
||||||
|
|
||||||
|
const firstRowValue = data[0][copyDownField.key]
|
||||||
|
const newData = data.map((row, index) => {
|
||||||
|
if (index === 0) return row
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
[copyDownField.key]: firstRowValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
updateData(newData)
|
||||||
|
setCopyDownField(null)
|
||||||
|
}, [data, updateData, copyDownField])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Data<T> & Meta>[]>(() => {
|
const columns = useMemo<ColumnDef<Data<T> & Meta>[]>(() => {
|
||||||
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
|
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
|
||||||
{
|
{
|
||||||
@@ -244,7 +532,15 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
},
|
},
|
||||||
...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
|
...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
|
||||||
accessorKey: field.key,
|
accessorKey: field.key,
|
||||||
header: field.label,
|
header: () => (
|
||||||
|
<div className="group">
|
||||||
|
<ColumnHeader
|
||||||
|
field={field}
|
||||||
|
data={data}
|
||||||
|
onCopyDown={(key) => copyValueDown(key, field.label)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
cell: ({ row, column }) => {
|
cell: ({ row, column }) => {
|
||||||
const value = row.getValue(column.id)
|
const value = row.getValue(column.id)
|
||||||
const error = row.original.__errors?.[column.id]
|
const error = row.original.__errors?.[column.id]
|
||||||
@@ -259,7 +555,6 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Use configured width or fallback to sensible defaults
|
|
||||||
size: (field as any).width || (
|
size: (field as any).width || (
|
||||||
field.fieldType.type === "checkbox" ? 80 :
|
field.fieldType.type === "checkbox" ? 80 :
|
||||||
field.fieldType.type === "select" ? 150 :
|
field.fieldType.type === "select" ? 150 :
|
||||||
@@ -268,7 +563,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
return baseColumns
|
return baseColumns
|
||||||
}, [fields, updateRows])
|
}, [fields, updateRows, data, copyValueDown])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
@@ -383,6 +678,12 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
|
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
|
||||||
|
<CopyDownDialog
|
||||||
|
isOpen={!!copyDownField}
|
||||||
|
onClose={() => setCopyDownField(null)}
|
||||||
|
onConfirm={executeCopyDown}
|
||||||
|
fieldLabel={copyDownField?.label || ""}
|
||||||
|
/>
|
||||||
<AlertDialog open={showSubmitAlert} onOpenChange={setShowSubmitAlert}>
|
<AlertDialog open={showSubmitAlert} onOpenChange={setShowSubmitAlert}>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay className="z-[1400]" />
|
<AlertDialogOverlay className="z-[1400]" />
|
||||||
@@ -411,93 +712,97 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="px-8 py-6">
|
<div className="h-full flex flex-col">
|
||||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-2">
|
<div className="px-8 pt-6">
|
||||||
<h2 className="text-3xl font-semibold text-foreground">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||||
{translations.validationStep.title}
|
<h2 className="text-3xl font-semibold text-foreground">
|
||||||
</h2>
|
{translations.validationStep.title}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
</h2>
|
||||||
<Button
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={deleteSelectedRows}
|
size="sm"
|
||||||
>
|
onClick={deleteSelectedRows}
|
||||||
{translations.validationStep.discardButtonTitle}
|
>
|
||||||
</Button>
|
{translations.validationStep.discardButtonTitle}
|
||||||
<div className="flex items-center gap-2">
|
</Button>
|
||||||
<Switch
|
<div className="flex items-center gap-2">
|
||||||
checked={filterByErrors}
|
<Switch
|
||||||
onCheckedChange={setFilterByErrors}
|
checked={filterByErrors}
|
||||||
id="filter-errors"
|
onCheckedChange={setFilterByErrors}
|
||||||
/>
|
id="filter-errors"
|
||||||
<label htmlFor="filter-errors" className="text-sm text-muted-foreground">
|
/>
|
||||||
{translations.validationStep.filterSwitchTitle}
|
<label htmlFor="filter-errors" className="text-sm text-muted-foreground">
|
||||||
</label>
|
{translations.validationStep.filterSwitchTitle}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="px-8 pb-6 flex-1 min-h-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="rounded-md border h-full flex flex-col overflow-hidden">
|
||||||
<Table>
|
<div className="flex-1 overflow-auto">
|
||||||
<TableHeader>
|
<Table>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<TableHeader className="sticky top-0 bg-muted z-10">
|
||||||
<TableRow key={headerGroup.id}>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{headerGroup.headers.map((header) => (
|
<TableRow key={headerGroup.id}>
|
||||||
<TableHead
|
{headerGroup.headers.map((header) => (
|
||||||
key={header.id}
|
<TableHead
|
||||||
style={{
|
key={header.id}
|
||||||
width: header.getSize(),
|
|
||||||
minWidth: header.getSize(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className="p-2"
|
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.getSize(),
|
width: header.getSize(),
|
||||||
minWidth: cell.column.getSize(),
|
minWidth: header.getSize(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{header.isPlaceholder
|
||||||
</TableCell>
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
{table.getRowModel().rows?.length ? (
|
||||||
{filterByErrors
|
table.getRowModel().rows.map((row) => (
|
||||||
? translations.validationStep.noRowsMessageWhenFiltered
|
<TableRow
|
||||||
: translations.validationStep.noRowsMessage}
|
key={row.id}
|
||||||
</TableCell>
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableRow>
|
>
|
||||||
)}
|
{row.getVisibleCells().map((cell) => (
|
||||||
</TableBody>
|
<TableCell
|
||||||
</Table>
|
key={cell.id}
|
||||||
|
className="p-2"
|
||||||
|
style={{
|
||||||
|
width: cell.column.getSize(),
|
||||||
|
minWidth: cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
{filterByErrors
|
||||||
|
? translations.validationStep.noRowsMessageWhenFiltered
|
||||||
|
: translations.validationStep.noRowsMessage}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t bg-muted px-8 py-4 mt-5">
|
<div className="border-t bg-muted px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import type { Column as RDGColumn, RenderEditCellProps, FormatterProps } from "react-data-grid"
|
|
||||||
import { useRowSelection } from "react-data-grid"
|
|
||||||
import { Checkbox, Input, Switch } from "@chakra-ui/react"
|
|
||||||
import type { Data, Fields, Field, SelectOption } from "../../../types"
|
|
||||||
import type { ChangeEvent } from "react"
|
|
||||||
import type { Meta } from "../types"
|
|
||||||
import { CgInfo } from "react-icons/cg"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
|
|
||||||
const SELECT_COLUMN_KEY = "select-row"
|
|
||||||
|
|
||||||
function autoFocusAndSelect(input: HTMLInputElement | null) {
|
|
||||||
input?.focus()
|
|
||||||
input?.select()
|
|
||||||
}
|
|
||||||
|
|
||||||
type RowType<T extends string> = Data<T> & Meta
|
|
||||||
|
|
||||||
export const generateColumns = <T extends string>(fields: Fields<T>): RDGColumn<RowType<T>>[] => [
|
|
||||||
{
|
|
||||||
key: SELECT_COLUMN_KEY,
|
|
||||||
name: "",
|
|
||||||
width: 35,
|
|
||||||
minWidth: 35,
|
|
||||||
maxWidth: 35,
|
|
||||||
resizable: false,
|
|
||||||
sortable: false,
|
|
||||||
frozen: true,
|
|
||||||
cellClass: "rdg-checkbox",
|
|
||||||
formatter: (props: FormatterProps<RowType<T>>) => {
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const [isRowSelected, onRowSelectionChange] = useRowSelection()
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
bg="white"
|
|
||||||
aria-label="Select"
|
|
||||||
isChecked={isRowSelected}
|
|
||||||
onChange={(event) => {
|
|
||||||
onRowSelectionChange({
|
|
||||||
row: props.row,
|
|
||||||
checked: Boolean(event.target.checked),
|
|
||||||
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...fields.map(
|
|
||||||
(column: Field<T>): RDGColumn<RowType<T>> => ({
|
|
||||||
key: column.key,
|
|
||||||
name: column.label,
|
|
||||||
minWidth: 150,
|
|
||||||
resizable: true,
|
|
||||||
headerRenderer: () => (
|
|
||||||
<div className="flex gap-1 items-center relative">
|
|
||||||
<div className="flex-1 overflow-hidden text-ellipsis">
|
|
||||||
{column.label}
|
|
||||||
</div>
|
|
||||||
{column.description && (
|
|
||||||
<div className="flex-none">
|
|
||||||
<CgInfo className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
editable: column.fieldType.type !== "checkbox",
|
|
||||||
editor: ({ row, onRowChange, onClose }: RenderEditCellProps<RowType<T>>) => {
|
|
||||||
let component
|
|
||||||
|
|
||||||
switch (column.fieldType.type) {
|
|
||||||
case "select":
|
|
||||||
component = (
|
|
||||||
<Select
|
|
||||||
defaultOpen
|
|
||||||
value={row[column.key] as string}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
onRowChange({ ...row, [column.key]: value }, true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full border-0 focus:ring-0">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
position="popper"
|
|
||||||
className="z-[1000]"
|
|
||||||
align="start"
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
{column.fieldType.options.map((option: SelectOption) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
component = (
|
|
||||||
<div className="pl-2">
|
|
||||||
<Input
|
|
||||||
ref={autoFocusAndSelect}
|
|
||||||
variant="unstyled"
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
value={row[column.key] as string}
|
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onRowChange({ ...row, [column.key]: event.target.value })
|
|
||||||
}}
|
|
||||||
onBlur={() => onClose(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return component
|
|
||||||
},
|
|
||||||
editorOptions: {
|
|
||||||
editOnClick: true,
|
|
||||||
},
|
|
||||||
formatter: ({ row, onRowChange }: FormatterProps<RowType<T>>) => {
|
|
||||||
let component
|
|
||||||
|
|
||||||
switch (column.fieldType.type) {
|
|
||||||
case "checkbox":
|
|
||||||
component = (
|
|
||||||
<div
|
|
||||||
className="flex items-center h-full"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
isChecked={row[column.key] as boolean}
|
|
||||||
onChange={() => {
|
|
||||||
onRowChange({ ...row, [column.key]: !row[column.key as T] })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "select":
|
|
||||||
component = (
|
|
||||||
<div className="min-w-full min-h-full overflow-hidden text-ellipsis">
|
|
||||||
{column.fieldType.options.find((option: SelectOption) => option.value === row[column.key as T])?.label || null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
component = (
|
|
||||||
<div className="min-w-full min-h-full overflow-hidden text-ellipsis">
|
|
||||||
{row[column.key as T]}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.__errors?.[column.key]) {
|
|
||||||
return (
|
|
||||||
<div className="relative group">
|
|
||||||
{component}
|
|
||||||
<div className="absolute left-0 -top-8 z-50 hidden group-hover:block bg-popover text-popover-foreground text-sm p-2 rounded shadow">
|
|
||||||
{row.__errors?.[column.key]?.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return component
|
|
||||||
},
|
|
||||||
cellClass: (row: Meta) => {
|
|
||||||
switch (row.__errors?.[column.key]?.level) {
|
|
||||||
case "error":
|
|
||||||
return "rdg-cell-error"
|
|
||||||
case "warning":
|
|
||||||
return "rdg-cell-warning"
|
|
||||||
case "info":
|
|
||||||
return "rdg-cell-info"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -58,7 +58,7 @@ export type Data<T extends string> = { [key in T]: string | boolean | undefined
|
|||||||
// Data model RSI uses for spreadsheet imports
|
// Data model RSI uses for spreadsheet imports
|
||||||
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
|
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
|
||||||
|
|
||||||
export type Field<T extends string> = {
|
export type Field<T extends string = string> = {
|
||||||
// UI-facing field label
|
// UI-facing field label
|
||||||
label: string
|
label: string
|
||||||
// Field's unique identifier
|
// Field's unique identifier
|
||||||
@@ -69,10 +69,13 @@ export type Field<T extends string> = {
|
|||||||
alternateMatches?: string[]
|
alternateMatches?: string[]
|
||||||
// Validations used for field entries
|
// Validations used for field entries
|
||||||
validations?: Validation[]
|
validations?: Validation[]
|
||||||
// Field entry component, default: Input
|
// Field entry component
|
||||||
fieldType: Checkbox | Select | Input
|
fieldType: Checkbox | Select | Input | MultiInput | MultiSelect
|
||||||
// UI-facing values shown to user as field examples pre-upload phase
|
// UI-facing values shown to user as field examples pre-upload phase
|
||||||
example?: string
|
example?: string
|
||||||
|
width?: number
|
||||||
|
disabled?: boolean
|
||||||
|
onChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Checkbox = {
|
export type Checkbox = {
|
||||||
@@ -98,6 +101,17 @@ export type Input = {
|
|||||||
type: "input"
|
type: "input"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MultiInput = {
|
||||||
|
type: "multi-input"
|
||||||
|
separator?: string // Optional separator for parsing multiple values, defaults to comma
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MultiSelect = {
|
||||||
|
type: "multi-select"
|
||||||
|
options: SelectOption[]
|
||||||
|
separator?: string // Optional separator for parsing multiple values, defaults to comma
|
||||||
|
}
|
||||||
|
|
||||||
export type Validation = RequiredValidation | UniqueValidation | RegexValidation
|
export type Validation = RequiredValidation | UniqueValidation | RegexValidation
|
||||||
|
|
||||||
export type RequiredValidation = {
|
export type RequiredValidation = {
|
||||||
|
|||||||
@@ -1,186 +1,506 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ReactSpreadsheetImport } from "@/lib/react-spreadsheet-import/src";
|
import { ReactSpreadsheetImport } from "@/lib/react-spreadsheet-import/src";
|
||||||
|
import type { Field, Fields, Validation, ErrorLevel } from "@/lib/react-spreadsheet-import/src/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Code } from "@/components/ui/code";
|
import { Code } from "@/components/ui/code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import config from "@/config";
|
||||||
|
import { StepType } from "@/lib/react-spreadsheet-import/src/steps/UploadFlow";
|
||||||
|
|
||||||
const IMPORT_FIELDS = [
|
// Define base fields without dynamic options
|
||||||
|
const BASE_IMPORT_FIELDS = [
|
||||||
|
{
|
||||||
|
label: "Supplier",
|
||||||
|
key: "supplier",
|
||||||
|
description: "Primary supplier/manufacturer of the product",
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "UPC",
|
||||||
|
key: "upc",
|
||||||
|
description: "Universal Product Code/Barcode",
|
||||||
|
alternateMatches: ["barcode", "bar code", "JAN", "EAN"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 150,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Supplier #",
|
||||||
|
key: "supplier_no",
|
||||||
|
description: "Supplier's product identifier",
|
||||||
|
alternateMatches: ["sku", "item#", "mfg item #", "item"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 120,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Notions #",
|
||||||
|
key: "notions_no",
|
||||||
|
description: "Internal notions number",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 120,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Name",
|
label: "Name",
|
||||||
key: "name",
|
key: "name",
|
||||||
alternateMatches: ["product", "product name", "item name", "title"],
|
description: "Product name/title",
|
||||||
fieldType: {
|
alternateMatches: ["sku description"],
|
||||||
type: "input",
|
fieldType: { type: "input" },
|
||||||
},
|
|
||||||
example: "Widget X",
|
|
||||||
description: "The name or title of the product",
|
|
||||||
width: 300,
|
width: 300,
|
||||||
validations: [
|
validations: [
|
||||||
{
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
rule: "required",
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
errorMessage: "Name is required",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "SKU",
|
label: "Item Number",
|
||||||
key: "sku",
|
key: "item_number",
|
||||||
alternateMatches: ["item number", "product code", "product id", "item id"],
|
description: "Internal item reference number",
|
||||||
fieldType: {
|
fieldType: { type: "input" },
|
||||||
type: "input",
|
|
||||||
},
|
|
||||||
example: "WX-123",
|
|
||||||
description: "Unique product identifier",
|
|
||||||
width: 120,
|
width: 120,
|
||||||
validations: [
|
validations: [
|
||||||
{
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
rule: "required",
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
errorMessage: "SKU is required",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rule: "unique",
|
|
||||||
errorMessage: "SKU must be unique",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Category",
|
label: "Image URL",
|
||||||
key: "category",
|
key: "image_url",
|
||||||
alternateMatches: ["product category", "type", "product type"],
|
description: "Product image URL(s)",
|
||||||
|
fieldType: { type: "multi-input" },
|
||||||
|
width: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "MSRP",
|
||||||
|
key: "msrp",
|
||||||
|
description: "Manufacturer's Suggested Retail Price",
|
||||||
|
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. Retail"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Qty Per Unit",
|
||||||
|
key: "qty_per_unit",
|
||||||
|
description: "Quantity of items per individual unit",
|
||||||
|
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cost Each",
|
||||||
|
key: "cost_each",
|
||||||
|
description: "Wholesale cost per unit",
|
||||||
|
alternateMatches: ["wholesale", "wholesale price"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Case Pack",
|
||||||
|
key: "case_qty",
|
||||||
|
description: "Number of units per case",
|
||||||
|
alternateMatches: ["mc qty"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tax Category",
|
||||||
|
key: "tax_cat",
|
||||||
|
description: "Product tax category",
|
||||||
fieldType: {
|
fieldType: {
|
||||||
type: "select",
|
type: "multi-select",
|
||||||
options: [
|
options: [], // Will be populated from API
|
||||||
{ label: "Electronics", value: "electronics" },
|
|
||||||
{ label: "Clothing", value: "clothing" },
|
|
||||||
{ label: "Food & Beverage", value: "food_beverage" },
|
|
||||||
{ label: "Office Supplies", value: "office_supplies" },
|
|
||||||
{ label: "Other", value: "other" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
width: 150,
|
width: 150,
|
||||||
validations: [
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
{
|
|
||||||
rule: "required",
|
|
||||||
errorMessage: "Category is required",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
example: "Electronics",
|
|
||||||
description: "Product category",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Quantity",
|
label: "Company",
|
||||||
key: "quantity",
|
key: "company",
|
||||||
alternateMatches: ["qty", "stock", "amount", "inventory", "stock level"],
|
description: "Company/Brand name",
|
||||||
fieldType: {
|
|
||||||
type: "input",
|
|
||||||
},
|
|
||||||
example: "100",
|
|
||||||
description: "Current stock quantity",
|
|
||||||
width: 100,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: "required",
|
|
||||||
errorMessage: "Quantity is required",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rule: "regex",
|
|
||||||
value: "^[0-9]+$",
|
|
||||||
errorMessage: "Quantity must be a positive number",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Price",
|
|
||||||
key: "price",
|
|
||||||
alternateMatches: ["unit price", "cost", "selling price", "retail price"],
|
|
||||||
fieldType: {
|
|
||||||
type: "input",
|
|
||||||
},
|
|
||||||
example: "29.99",
|
|
||||||
description: "Selling price per unit",
|
|
||||||
width: 100,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: "required",
|
|
||||||
errorMessage: "Price is required",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rule: "regex",
|
|
||||||
value: "^\\d*\\.?\\d+$",
|
|
||||||
errorMessage: "Price must be a valid number",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "In Stock",
|
|
||||||
key: "inStock",
|
|
||||||
alternateMatches: ["available", "active", "status"],
|
|
||||||
fieldType: {
|
|
||||||
type: "checkbox",
|
|
||||||
booleanMatches: {
|
|
||||||
yes: true,
|
|
||||||
no: false,
|
|
||||||
"in stock": true,
|
|
||||||
"out of stock": false,
|
|
||||||
available: true,
|
|
||||||
unavailable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
width: 80,
|
|
||||||
example: "Yes",
|
|
||||||
description: "Whether the item is currently in stock",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Minimum Stock",
|
|
||||||
key: "minStock",
|
|
||||||
alternateMatches: ["min qty", "reorder point", "low stock level"],
|
|
||||||
fieldType: {
|
|
||||||
type: "input",
|
|
||||||
},
|
|
||||||
example: "10",
|
|
||||||
description: "Minimum stock level before reorder",
|
|
||||||
width: 100,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: "regex",
|
|
||||||
value: "^[0-9]+$",
|
|
||||||
errorMessage: "Minimum stock must be a positive number",
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Location",
|
|
||||||
key: "location",
|
|
||||||
alternateMatches: ["storage location", "warehouse", "shelf", "bin"],
|
|
||||||
fieldType: {
|
fieldType: {
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [
|
options: [], // Will be populated from API
|
||||||
{ label: "Warehouse A", value: "warehouse_a" },
|
},
|
||||||
{ label: "Warehouse B", value: "warehouse_b" },
|
width: 200,
|
||||||
{ label: "Store Front", value: "store_front" },
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
{ label: "External Storage", value: "external" },
|
},
|
||||||
],
|
{
|
||||||
|
label: "Line",
|
||||||
|
key: "line",
|
||||||
|
description: "Product line",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated dynamically based on company selection
|
||||||
},
|
},
|
||||||
width: 150,
|
width: 150,
|
||||||
example: "Warehouse A",
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
description: "Storage location of the product",
|
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
label: "Sub Line",
|
||||||
|
key: "subline",
|
||||||
|
description: "Product sub-line",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated dynamically based on line selection
|
||||||
|
},
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Artist",
|
||||||
|
key: "artist",
|
||||||
|
description: "Artist/Designer name",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "ETA Date",
|
||||||
|
key: "eta",
|
||||||
|
description: "Estimated arrival date",
|
||||||
|
alternateMatches: ["shipping month"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Weight",
|
||||||
|
key: "weight",
|
||||||
|
description: "Product weight (in lbs)",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Length",
|
||||||
|
key: "length",
|
||||||
|
description: "Product length (in inches)",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Width",
|
||||||
|
key: "width",
|
||||||
|
description: "Product width (in inches)",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Height",
|
||||||
|
key: "height",
|
||||||
|
description: "Product height (in inches)",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Shipping Restrictions",
|
||||||
|
key: "ship_restrictions",
|
||||||
|
description: "Product shipping restrictions",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 150,
|
||||||
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Country Of Origin",
|
||||||
|
key: "coo",
|
||||||
|
description: "2-letter country code (ISO)",
|
||||||
|
alternateMatches: ["coo"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "regex", value: "^[A-Z]{2}$", errorMessage: "Must be 2 letters", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "HTS Code",
|
||||||
|
key: "hts_code",
|
||||||
|
description: "Harmonized Tariff Schedule code",
|
||||||
|
alternateMatches: ["taric"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 120,
|
||||||
|
validations: [
|
||||||
|
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Size Category",
|
||||||
|
key: "size_cat",
|
||||||
|
description: "Product size category",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Description",
|
||||||
|
key: "description",
|
||||||
|
description: "Detailed product description",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 400,
|
||||||
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Private Notes",
|
||||||
|
key: "priv_notes",
|
||||||
|
description: "Internal notes about the product",
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Categories",
|
||||||
|
key: "categories",
|
||||||
|
description: "Product categories",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Themes",
|
||||||
|
key: "themes",
|
||||||
|
description: "Product themes/styles",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Colors",
|
||||||
|
key: "colors",
|
||||||
|
description: "Product colors",
|
||||||
|
fieldType: {
|
||||||
|
type: "select",
|
||||||
|
options: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ImportField = typeof BASE_IMPORT_FIELDS[number];
|
||||||
|
type ImportFieldKey = ImportField["key"];
|
||||||
|
|
||||||
export function Import() {
|
export function Import() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [importedData, setImportedData] = useState<any[] | null>(null);
|
const [importedData, setImportedData] = useState<any[] | null>(null);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||||
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
|
|
||||||
|
// Fetch initial field options from the API
|
||||||
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
|
queryKey: ["import-field-options"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/field-options`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch field options");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch product lines when company is selected
|
||||||
|
const { data: productLines } = useQuery({
|
||||||
|
queryKey: ["product-lines", selectedCompany],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedCompany) return [];
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/product-lines/${selectedCompany}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch product lines");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedCompany,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch sublines when line is selected
|
||||||
|
const { data: sublines } = useQuery({
|
||||||
|
queryKey: ["sublines", selectedLine],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedLine) return [];
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/sublines/${selectedLine}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch sublines");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle field value changes
|
||||||
|
const handleFieldChange = (field: string, value: any) => {
|
||||||
|
console.log('Field change:', field, value);
|
||||||
|
if (field === "company") {
|
||||||
|
setSelectedCompany(value);
|
||||||
|
setSelectedLine(null); // Reset line when company changes
|
||||||
|
} else if (field === "line") {
|
||||||
|
setSelectedLine(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge base fields with dynamic options
|
||||||
|
const importFields = BASE_IMPORT_FIELDS.map(field => {
|
||||||
|
if (!fieldOptions) return field;
|
||||||
|
|
||||||
|
switch (field.key) {
|
||||||
|
case "company":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.companies || [],
|
||||||
|
},
|
||||||
|
onChange: (value: string) => {
|
||||||
|
console.log('Company selected:', value);
|
||||||
|
handleFieldChange("company", value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "line":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: productLines || [],
|
||||||
|
},
|
||||||
|
onChange: (value: string) => {
|
||||||
|
console.log('Line selected:', value);
|
||||||
|
handleFieldChange("line", value);
|
||||||
|
},
|
||||||
|
disabled: !selectedCompany,
|
||||||
|
};
|
||||||
|
case "subline":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: sublines || [],
|
||||||
|
},
|
||||||
|
disabled: !selectedLine,
|
||||||
|
};
|
||||||
|
case "colors":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.colors || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "tax_cat":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "multi-select" as const,
|
||||||
|
options: fieldOptions.taxCategories || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "ship_restrictions":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.shippingRestrictions || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "supplier":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.suppliers || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "artist":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.artists || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "categories":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.categories || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "themes":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.themes || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "size_cat":
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: "select" as const,
|
||||||
|
options: fieldOptions.sizes || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleData = async (data: any, file: File) => {
|
const handleData = async (data: any, file: File) => {
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +515,14 @@ export function Import() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoadingOptions) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Loading import options...</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
@@ -218,10 +546,20 @@ export function Import() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Import Data</CardTitle>
|
<CardTitle>Import Data</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<Button onClick={() => setIsOpen(true)} className="w-full">
|
<Button onClick={() => setIsOpen(true)} className="w-full">
|
||||||
Upload Spreadsheet
|
Upload Spreadsheet
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setStartFromScratch(true);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Start From Scratch
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -240,9 +578,13 @@ export function Import() {
|
|||||||
|
|
||||||
<ReactSpreadsheetImport
|
<ReactSpreadsheetImport
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setStartFromScratch(false);
|
||||||
|
}}
|
||||||
onSubmit={handleData}
|
onSubmit={handleData}
|
||||||
fields={IMPORT_FIELDS}
|
fields={importFields}
|
||||||
|
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -4,9 +4,11 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "inventory",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shadcn": "^1.0.0"
|
"shadcn": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ts-essentials": "^10.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shadcn": {
|
"node_modules/shadcn": {
|
||||||
@@ -14,6 +16,21 @@
|
|||||||
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz",
|
||||||
"integrity": "sha512-kCxBIBiPS83WxrWkOQHamWpr9XlLtOtOlJM6QX90h9A5xZCBMhxu4ibcNT2ZnzZLdexkYbQrnijfPKdOsZxOpA==",
|
"integrity": "sha512-kCxBIBiPS83WxrWkOQHamWpr9XlLtOtOlJM6QX90h9A5xZCBMhxu4ibcNT2ZnzZLdexkYbQrnijfPKdOsZxOpA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ts-essentials": {
|
||||||
|
"version": "10.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.4.tgz",
|
||||||
|
"integrity": "sha512-lwYdz28+S4nicm+jFi6V58LaAIpxzhg9rLdgNC1VsdP/xiFBseGhF1M/shwCk6zMmwahBZdXcl34LVHrEang3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.5.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shadcn": "^1.0.0"
|
"shadcn": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ts-essentials": "^10.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user