Connect with database for dropdowns, more validate data step fixes
This commit is contained in:
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 categoriesRouter = require('./routes/categories');
|
||||
const testConnectionRouter = require('./routes/test-connection');
|
||||
const importRouter = require('./routes/import');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
@@ -65,12 +66,11 @@ app.use(corsMiddleware);
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Initialize database pool and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize database pool
|
||||
const pool = initPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
const pool = await initPool({
|
||||
waitForConnections: true,
|
||||
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
|
||||
queueLimit: 0,
|
||||
@@ -81,7 +81,7 @@ const pool = initPool({
|
||||
// Make pool available to routes
|
||||
app.locals.pool = pool;
|
||||
|
||||
// Routes
|
||||
// Set up routes after pool is initialized
|
||||
app.use('/api/products', productsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/orders', ordersRouter);
|
||||
@@ -92,6 +92,7 @@ app.use('/api/config', configRouter);
|
||||
app.use('/api/metrics', metricsRouter);
|
||||
app.use('/api/vendors', vendorsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
app.use('/api/import', importRouter);
|
||||
app.use('/api', testConnectionRouter);
|
||||
|
||||
// Basic health check route
|
||||
@@ -118,6 +119,16 @@ app.use((err, req, res, next) => {
|
||||
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
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error(`[${new Date().toISOString()}] Uncaught Exception:`, err);
|
||||
@@ -128,17 +139,6 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
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
|
||||
const importClients = new Set();
|
||||
const updateClients = new Set();
|
||||
@@ -189,62 +189,5 @@ const setupSSE = (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update the status endpoint to include reset-metrics
|
||||
app.get('/csv/status', (req, res) => {
|
||||
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}`);
|
||||
});
|
||||
// Start the server
|
||||
startServer();
|
||||
@@ -1,10 +1,66 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
let pool;
|
||||
|
||||
function initPool(config) {
|
||||
pool = mysql.createPool(config);
|
||||
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
|
||||
};
|
||||
|
||||
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() {
|
||||
|
||||
@@ -101,7 +101,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
}
|
||||
|
||||
const isRequired = field.validations?.some(v => v.rule === "required")
|
||||
const isRequiredAndEmpty = isRequired && !value
|
||||
|
||||
// Determine the current validation state
|
||||
const getValidationState = () => {
|
||||
@@ -170,6 +169,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
"w-full justify-between",
|
||||
currentError ? "border-destructive text-destructive" : "border-input"
|
||||
)}
|
||||
disabled={field.disabled}
|
||||
>
|
||||
{value
|
||||
? field.fieldType.options.find((option) => option.value === value)?.label
|
||||
@@ -189,6 +189,9 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue)
|
||||
if (field.onChange) {
|
||||
field.onChange(currentValue)
|
||||
}
|
||||
setIsEditing(false)
|
||||
}}
|
||||
>
|
||||
@@ -335,7 +338,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (field.fieldType.type !== "checkbox") {
|
||||
if (field.fieldType.type !== "checkbox" && !field.disabled) {
|
||||
setIsEditing(true)
|
||||
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
|
||||
}
|
||||
@@ -343,14 +346,15 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
className={cn(
|
||||
"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.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
|
||||
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className={cn(!value && "text-muted-foreground")}>
|
||||
{value ? getDisplayValue(value, field.fieldType) : ""}
|
||||
</div>
|
||||
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
<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">
|
||||
@@ -376,7 +380,7 @@ const ColumnHeader = <T extends string>({
|
||||
<div className="flex-1 overflow-hidden text-ellipsis">
|
||||
{field.label}
|
||||
</div>
|
||||
{data.length > 1 && (
|
||||
{data.length > 1 && !field.disabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -58,7 +58,7 @@ export type Data<T extends string> = { [key in T]: string | boolean | undefined
|
||||
// Data model RSI uses for spreadsheet imports
|
||||
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
|
||||
label: string
|
||||
// Field's unique identifier
|
||||
@@ -73,6 +73,9 @@ export type Field<T extends string> = {
|
||||
fieldType: Checkbox | Select | Input | MultiInput | MultiSelect
|
||||
// UI-facing values shown to user as field examples pre-upload phase
|
||||
example?: string
|
||||
width?: number
|
||||
disabled?: boolean
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export type Checkbox = {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useState } from "react";
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Code } from "@/components/ui/code";
|
||||
import { toast } from "sonner";
|
||||
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",
|
||||
options: [
|
||||
{ label: "Acme Corp", value: "acme" },
|
||||
{ label: "Global Supplies", value: "global" },
|
||||
{ label: "Best Manufacturers", value: "best" },
|
||||
{ label: "Quality Goods", value: "quality" },
|
||||
],
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
|
||||
},
|
||||
{
|
||||
label: "UPC",
|
||||
@@ -143,12 +143,7 @@ const IMPORT_FIELDS = [
|
||||
description: "Product tax category",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [
|
||||
{ label: "Standard", value: "standard" },
|
||||
{ label: "Reduced", value: "reduced" },
|
||||
{ label: "Zero", value: "zero" },
|
||||
{ label: "Exempt", value: "exempt" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 150,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
@@ -157,7 +152,10 @@ const IMPORT_FIELDS = [
|
||||
label: "Company",
|
||||
key: "company",
|
||||
description: "Company/Brand name",
|
||||
fieldType: { type: "input" },
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
@@ -165,21 +163,31 @@ const IMPORT_FIELDS = [
|
||||
label: "Line",
|
||||
key: "line",
|
||||
description: "Product line",
|
||||
fieldType: { type: "input" },
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated dynamically based on company selection
|
||||
},
|
||||
width: 150,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Sub Line",
|
||||
key: "subline",
|
||||
description: "Product sub-line",
|
||||
fieldType: { type: "input" },
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated dynamically based on line selection
|
||||
},
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
label: "Artist",
|
||||
key: "artist",
|
||||
description: "Artist/Designer name",
|
||||
fieldType: { type: "input" },
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
@@ -240,12 +248,7 @@ const IMPORT_FIELDS = [
|
||||
description: "Product shipping restrictions",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Hazmat", value: "hazmat" },
|
||||
{ label: "Oversize", value: "oversize" },
|
||||
{ label: "Restricted", value: "restricted" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 150,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
@@ -278,12 +281,7 @@ const IMPORT_FIELDS = [
|
||||
description: "Product size category",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Small", value: "small" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "Large", value: "large" },
|
||||
{ label: "Extra Large", value: "xl" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 150,
|
||||
},
|
||||
@@ -308,12 +306,7 @@ const IMPORT_FIELDS = [
|
||||
description: "Product categories",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Art Supplies", value: "art" },
|
||||
{ label: "Crafts", value: "crafts" },
|
||||
{ label: "Home Decor", value: "home" },
|
||||
{ label: "Stationery", value: "stationery" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
@@ -324,12 +317,7 @@ const IMPORT_FIELDS = [
|
||||
description: "Product themes/styles",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Modern", value: "modern" },
|
||||
{ label: "Vintage", value: "vintage" },
|
||||
{ label: "Nature", value: "nature" },
|
||||
{ label: "Abstract", value: "abstract" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
@@ -339,20 +327,180 @@ const IMPORT_FIELDS = [
|
||||
description: "Product colors",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Red", value: "red" },
|
||||
{ label: "Blue", value: "blue" },
|
||||
{ label: "Green", value: "green" },
|
||||
{ label: "Multi", value: "multi" },
|
||||
],
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
type ImportField = typeof BASE_IMPORT_FIELDS[number];
|
||||
type ImportFieldKey = ImportField["key"];
|
||||
|
||||
export function Import() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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) => {
|
||||
try {
|
||||
@@ -367,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 (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -390,10 +546,20 @@ export function Import() {
|
||||
<CardHeader>
|
||||
<CardTitle>Import Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={() => setIsOpen(true)} className="w-full">
|
||||
Upload Spreadsheet
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setStartFromScratch(true);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Start From Scratch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -412,9 +578,13 @@ export function Import() {
|
||||
|
||||
<ReactSpreadsheetImport
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
setStartFromScratch(false);
|
||||
}}
|
||||
onSubmit={handleData}
|
||||
fields={IMPORT_FIELDS}
|
||||
fields={importFields}
|
||||
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -4,9 +4,11 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inventory",
|
||||
"dependencies": {
|
||||
"shadcn": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-essentials": "^10.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/shadcn": {
|
||||
@@ -14,6 +16,21 @@
|
||||
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz",
|
||||
"integrity": "sha512-kCxBIBiPS83WxrWkOQHamWpr9XlLtOtOlJM6QX90h9A5xZCBMhxu4ibcNT2ZnzZLdexkYbQrnijfPKdOsZxOpA==",
|
||||
"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": {
|
||||
"shadcn": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-essentials": "^10.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user