Add remaining templates elements
This commit is contained in:
@@ -32,8 +32,6 @@
|
|||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/pg": "^8.11.2",
|
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const dotenv = require('dotenv');
|
|||||||
// Ensure environment variables are loaded
|
// Ensure environment variables are loaded
|
||||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||||
|
|
||||||
// Initialize OpenAI client
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY
|
apiKey: process.env.OPENAI_API_KEY
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { Pool } = require('pg');
|
const { getPool } = require('../utils/db');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -7,23 +7,14 @@ dotenv.config({ path: path.join(__dirname, "../../.env") });
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Initialize PostgreSQL connection pool
|
|
||||||
const pool = new Pool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
// Add SSL if needed (based on your environment)
|
|
||||||
...(process.env.NODE_ENV === 'production' && {
|
|
||||||
ssl: {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all templates
|
// Get all templates
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT * FROM templates
|
SELECT * FROM templates
|
||||||
ORDER BY company ASC, product_type ASC
|
ORDER BY company ASC, product_type ASC
|
||||||
@@ -42,6 +33,11 @@ router.get('/', async (req, res) => {
|
|||||||
router.get('/:company/:productType', async (req, res) => {
|
router.get('/:company/:productType', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { company, productType } = req.params;
|
const { company, productType } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT * FROM templates
|
SELECT * FROM templates
|
||||||
WHERE company = $1 AND product_type = $2
|
WHERE company = $1 AND product_type = $2
|
||||||
@@ -89,6 +85,11 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Company and Product Type are required' });
|
return res.status(400).json({ error: 'Company and Product Type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO templates (
|
INSERT INTO templates (
|
||||||
company,
|
company,
|
||||||
@@ -176,6 +177,11 @@ router.put('/:id', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Company and Product Type are required' });
|
return res.status(400).json({ error: 'Company and Product Type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE templates
|
UPDATE templates
|
||||||
SET
|
SET
|
||||||
@@ -244,6 +250,11 @@ router.put('/:id', async (req, res) => {
|
|||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query('DELETE FROM templates WHERE id = $1 RETURNING *', [id]);
|
const result = await pool.query('DELETE FROM templates WHERE id = $1 RETURNING *', [id]);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const aiValidationRouter = require('./routes/ai-validation');
|
|||||||
const templatesRouter = require('./routes/templates');
|
const templatesRouter = require('./routes/templates');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = path.join(__dirname, '..', '.env');
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
console.log('Looking for .env file at:', envPath);
|
console.log('Looking for .env file at:', envPath);
|
||||||
console.log('.env file exists:', fs.existsSync(envPath));
|
console.log('.env file exists:', fs.existsSync(envPath));
|
||||||
|
|
||||||
@@ -35,8 +35,7 @@ try {
|
|||||||
DB_NAME: process.env.DB_NAME || 'not set',
|
DB_NAME: process.env.DB_NAME || 'not set',
|
||||||
DB_PASSWORD: process.env.DB_PASSWORD ? '[password set]' : 'not set',
|
DB_PASSWORD: process.env.DB_PASSWORD ? '[password set]' : 'not set',
|
||||||
DB_PORT: process.env.DB_PORT || 'not set',
|
DB_PORT: process.env.DB_PORT || 'not set',
|
||||||
DB_SSL: process.env.DB_SSL || 'not set',
|
DB_SSL: process.env.DB_SSL || 'not set'
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? '[key set]' : 'not set'
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading .env file:', error);
|
console.error('Error loading .env file:', error);
|
||||||
@@ -72,13 +71,13 @@ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|||||||
// Initialize database pool and start server
|
// Initialize database pool and start server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
try {
|
||||||
// Initialize database pool with PostgreSQL configuration
|
// Initialize database pool
|
||||||
const pool = await initPool({
|
const pool = await initPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
max: process.env.NODE_ENV === 'production' ? 20 : 10,
|
max: process.env.NODE_ENV === 'production' ? 20 : 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 2000,
|
connectionTimeoutMillis: 2000,
|
||||||
@@ -110,8 +109,7 @@ async function startServer() {
|
|||||||
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
|
||||||
database: 'connected'
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,47 @@
|
|||||||
const { Pool } = require('pg');
|
const { Pool, Client } = require('pg');
|
||||||
const { Client: SSHClient } = require('ssh2');
|
const { Client: SSHClient } = require('ssh2');
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
async function initPool(config) {
|
function initPool(config) {
|
||||||
// Log config without sensitive data
|
// Log config without sensitive data
|
||||||
const safeConfig = {
|
const safeConfig = {
|
||||||
host: config.host,
|
host: config.host || process.env.DB_HOST,
|
||||||
user: config.user,
|
user: config.user || process.env.DB_USER,
|
||||||
database: config.database,
|
database: config.database || process.env.DB_NAME,
|
||||||
port: config.port,
|
port: config.port || process.env.DB_PORT || 5432,
|
||||||
max: config.max,
|
max: config.max || 10,
|
||||||
idleTimeoutMillis: config.idleTimeoutMillis,
|
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||||
connectionTimeoutMillis: config.connectionTimeoutMillis,
|
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||||
ssl: config.ssl,
|
ssl: config.ssl || false,
|
||||||
password: config.password ? '[password set]' : '[no password]'
|
password: (config.password || process.env.DB_PASSWORD) ? '[password set]' : '[no password]'
|
||||||
};
|
};
|
||||||
console.log('[Database] Initializing pool with config:', safeConfig);
|
console.log('[Database] Initializing pool with config:', safeConfig);
|
||||||
|
|
||||||
try {
|
// Create the pool with the configuration
|
||||||
// Create the pool
|
pool = new Pool({
|
||||||
pool = new Pool(config);
|
host: config.host || process.env.DB_HOST,
|
||||||
|
user: config.user || process.env.DB_USER,
|
||||||
|
password: config.password || process.env.DB_PASSWORD,
|
||||||
|
database: config.database || process.env.DB_NAME,
|
||||||
|
port: config.port || process.env.DB_PORT || 5432,
|
||||||
|
max: config.max || 10,
|
||||||
|
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||||
|
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||||
|
ssl: config.ssl || false
|
||||||
|
});
|
||||||
|
|
||||||
// Test the connection
|
// Test the pool connection
|
||||||
const client = await pool.connect();
|
return pool.connect()
|
||||||
try {
|
.then(client => {
|
||||||
await client.query('SELECT NOW()');
|
console.log('[Database] Pool connection successful');
|
||||||
console.log('[Database] Pool connection test successful');
|
|
||||||
} finally {
|
|
||||||
client.release();
|
client.release();
|
||||||
}
|
return pool;
|
||||||
|
})
|
||||||
return pool;
|
.catch(err => {
|
||||||
} catch (err) {
|
console.error('[Database] Connection failed:', err);
|
||||||
console.error('[Database] Connection failed:', err);
|
throw err;
|
||||||
throw err;
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConnection() {
|
async function getConnection() {
|
||||||
@@ -45,24 +51,8 @@ async function getConnection() {
|
|||||||
return pool.connect();
|
return pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to execute a query with error handling
|
|
||||||
async function query(text, params = []) {
|
|
||||||
if (!pool) {
|
|
||||||
throw new Error('Database pool not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await pool.query(text, params);
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Database] Query error:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
initPool,
|
initPool,
|
||||||
getConnection,
|
getConnection,
|
||||||
getPool: () => pool,
|
getPool: () => pool
|
||||||
query
|
|
||||||
};
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
22
inventory/src/components/ui/textarea.tsx
Normal file
22
inventory/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -99,3 +99,14 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState, useEffect, memo } from "react"
|
import { useCallback, useMemo, useState, useEffect, memo } from "react"
|
||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { Meta } from "./types"
|
import type { Meta, Error } from "./types"
|
||||||
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
||||||
import type { Data, SelectOption } from "../../types"
|
import type { Data, SelectOption, Result } from "../../types"
|
||||||
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
|
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
@@ -63,12 +63,44 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Code } from "@/components/ui/code"
|
import { Code } from "@/components/ui/code"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
|
// Template interface
|
||||||
|
interface Template {
|
||||||
|
id: number;
|
||||||
|
company: string;
|
||||||
|
product_type: string;
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend Meta type with template
|
||||||
|
type ExtendedMeta = Meta & {
|
||||||
|
__template?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a type for the row data that includes both Data and Meta
|
||||||
|
type RowData<T extends string> = Data<T> & ExtendedMeta;
|
||||||
|
|
||||||
|
// Define template-related types
|
||||||
|
type TemplateState = {
|
||||||
|
selectedTemplateId: string | null;
|
||||||
|
showSaveTemplateDialog: boolean;
|
||||||
|
newTemplateName: string;
|
||||||
|
newTemplateType: string;
|
||||||
|
}
|
||||||
|
|
||||||
type Props<T extends string> = {
|
type Props<T extends string> = {
|
||||||
initialData: (Data<T> & Meta)[]
|
initialData: RowData<T>[]
|
||||||
file: File
|
file: File
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
}
|
}
|
||||||
@@ -618,7 +650,7 @@ const ColumnHeader = memo(({
|
|||||||
onCopyDown
|
onCopyDown
|
||||||
}: {
|
}: {
|
||||||
field: Field<string>
|
field: Field<string>
|
||||||
data: (Data<string> & Meta)[]
|
data: (Data<string> & ExtendedMeta)[]
|
||||||
onCopyDown: (key: string) => void
|
onCopyDown: (key: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@@ -704,11 +736,288 @@ type DeepReadonlyField<T extends string> = {
|
|||||||
type ReadonlyField<T extends string> = Readonly<Field<T>>;
|
type ReadonlyField<T extends string> = Readonly<Field<T>>;
|
||||||
type ReadonlyFields<T extends string> = readonly ReadonlyField<T>[];
|
type ReadonlyFields<T extends string> = readonly ReadonlyField<T>[];
|
||||||
|
|
||||||
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
|
// Hook for managing templates
|
||||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
|
function useTemplates<T extends string>(
|
||||||
const { toast } = useToast()
|
data: RowData<T>[],
|
||||||
|
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||||
|
toastFn: typeof useToast,
|
||||||
|
rowSelection: RowSelectionState
|
||||||
|
) {
|
||||||
|
const { toast } = toastFn()
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [state, setState] = useState<TemplateState>({
|
||||||
|
selectedTemplateId: null,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
})
|
||||||
|
|
||||||
const [data, setData] = useState<(Data<T> & Meta)[]>(initialData)
|
// Fetch templates
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.apiUrl}/templates`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch templates')
|
||||||
|
const templateData = await response.json()
|
||||||
|
setTemplates(templateData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching templates:', error)
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load templates",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTemplates()
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const applyTemplate = useCallback(async (templateId: string, rowIndices?: number[]) => {
|
||||||
|
if (!templateId) return
|
||||||
|
|
||||||
|
const template = templates?.find(t => t.id.toString() === templateId)
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
setData((prevData: RowData<T>[]) => {
|
||||||
|
const newData = [...prevData]
|
||||||
|
const indicesToUpdate = rowIndices || newData.map((_, i) => i)
|
||||||
|
|
||||||
|
indicesToUpdate.forEach(index => {
|
||||||
|
const row = newData[index]
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
// Apply all template fields except id, company, product_type, created_at, and updated_at
|
||||||
|
Object.entries(template).forEach(([key, value]) => {
|
||||||
|
if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) {
|
||||||
|
// Handle numeric values that might be stored as strings
|
||||||
|
if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) {
|
||||||
|
// If it's a price field, add the dollar sign
|
||||||
|
if (['msrp', 'cost_each'].includes(key)) {
|
||||||
|
row[key as keyof typeof row] = `$${value}` as any;
|
||||||
|
} else {
|
||||||
|
row[key as keyof typeof row] = value as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle array values
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
row[key as keyof typeof row] = [...value] as any;
|
||||||
|
}
|
||||||
|
// Handle other values
|
||||||
|
else {
|
||||||
|
row[key as keyof typeof row] = value as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update the template reference
|
||||||
|
row.__template = templateId;
|
||||||
|
})
|
||||||
|
|
||||||
|
return newData
|
||||||
|
})
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Template Applied",
|
||||||
|
description: `Applied template to ${rowIndices?.length || data.length} row(s)`,
|
||||||
|
})
|
||||||
|
}, [templates, data.length, setData, toast])
|
||||||
|
|
||||||
|
const saveAsTemplate = useCallback(async () => {
|
||||||
|
const { newTemplateName, newTemplateType } = state
|
||||||
|
if (!newTemplateName || !newTemplateType) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Company and Product Type are required",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the selected row using rowSelection
|
||||||
|
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
|
||||||
|
const selectedRow = data[selectedRowIndex];
|
||||||
|
if (!selectedRow) throw new Error("No row selected");
|
||||||
|
|
||||||
|
const { __index, __errors, __template, ...templateData } = selectedRow;
|
||||||
|
|
||||||
|
// Clean numeric values and prepare template data
|
||||||
|
const cleanedData = Object.entries(templateData).reduce((acc, [key, value]) => {
|
||||||
|
// Handle numeric values with dollar signs
|
||||||
|
if (typeof value === 'string' && value.includes('$')) {
|
||||||
|
acc[key] = value.replace(/[$,\s]/g, '').trim();
|
||||||
|
}
|
||||||
|
// Handle array values (like categories or ship_restrictions)
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
// Handle other values
|
||||||
|
else {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
|
// Log the cleaned data before sending
|
||||||
|
console.log('Saving template with cleaned data:', {
|
||||||
|
...cleanedData,
|
||||||
|
company: newTemplateName,
|
||||||
|
product_type: newTemplateType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${config.apiUrl}/templates`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...cleanedData,
|
||||||
|
company: newTemplateName,
|
||||||
|
product_type: newTemplateType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the raw response for debugging
|
||||||
|
console.log('Template save response status:', response.status);
|
||||||
|
const responseData = await response.json();
|
||||||
|
console.log('Template save response:', responseData);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(responseData.error || responseData.details || "Failed to save template");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the templates list with the new template
|
||||||
|
const newTemplate = responseData;
|
||||||
|
setTemplates(prev => [...prev, newTemplate]);
|
||||||
|
|
||||||
|
// Update the row to show it's using this template
|
||||||
|
setData(prev => {
|
||||||
|
const newData = [...prev];
|
||||||
|
newData[selectedRowIndex] = {
|
||||||
|
...newData[selectedRowIndex],
|
||||||
|
__template: newTemplate.id.toString()
|
||||||
|
};
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Template saved successfully",
|
||||||
|
})
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Template save error:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to save template",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [state, data, rowSelection, toast, setTemplates, setState, setData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
selectedTemplateId: state.selectedTemplateId,
|
||||||
|
showSaveTemplateDialog: state.showSaveTemplateDialog,
|
||||||
|
setSelectedTemplateId: (id: string | null) => setState(prev => ({ ...prev, selectedTemplateId: id })),
|
||||||
|
setShowSaveTemplateDialog: (show: boolean) => setState(prev => ({ ...prev, showSaveTemplateDialog: show })),
|
||||||
|
setNewTemplateName: (name: string) => setState(prev => ({ ...prev, newTemplateName: name })),
|
||||||
|
setNewTemplateType: (type: string) => setState(prev => ({ ...prev, newTemplateType: type })),
|
||||||
|
newTemplateName: state.newTemplateName,
|
||||||
|
newTemplateType: state.newTemplateType,
|
||||||
|
applyTemplate,
|
||||||
|
saveAsTemplate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this component before the ValidationStep component
|
||||||
|
const SaveTemplateDialog = memo(({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (company: string, productType: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [company, setCompany] = useState("");
|
||||||
|
const [productType, setProductType] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
setCompany("");
|
||||||
|
setProductType("");
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save as Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the company and product type for this template.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="company" className="text-sm font-medium">
|
||||||
|
Company
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="company"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="productType" className="text-sm font-medium">
|
||||||
|
Product Type
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="productType"
|
||||||
|
value={productType}
|
||||||
|
onChange={(e) => setProductType(e.target.value)}
|
||||||
|
placeholder="Enter product type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onSave(company, productType);
|
||||||
|
onClose();
|
||||||
|
setCompany("");
|
||||||
|
setProductType("");
|
||||||
|
}}
|
||||||
|
disabled={!company || !productType}
|
||||||
|
>
|
||||||
|
Save Template
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ValidationStep = <T extends string>({
|
||||||
|
initialData,
|
||||||
|
file,
|
||||||
|
onBack}: Props<T>) => {
|
||||||
|
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [data, setData] = useState<(Data<T> & ExtendedMeta)[]>(initialData)
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [filterByErrors, setFilterByErrors] = useState(false)
|
const [filterByErrors, setFilterByErrors] = useState(false)
|
||||||
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
||||||
@@ -745,6 +1054,17 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
templates,
|
||||||
|
selectedTemplateId,
|
||||||
|
showSaveTemplateDialog,
|
||||||
|
setSelectedTemplateId,
|
||||||
|
setShowSaveTemplateDialog,
|
||||||
|
applyTemplate,
|
||||||
|
saveAsTemplate,
|
||||||
|
setNewTemplateName,
|
||||||
|
setNewTemplateType,
|
||||||
|
} = useTemplates(data, setData, useToast, rowSelection)
|
||||||
|
|
||||||
// Memoize filtered data to prevent recalculation on every render
|
// Memoize filtered data to prevent recalculation on every render
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
@@ -755,16 +1075,15 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
}, [data, filterByErrors])
|
}, [data, filterByErrors])
|
||||||
|
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
async (rows: typeof data, indexes?: number[]) => {
|
async (rows: (Data<T> & ExtendedMeta)[], indexes?: number[]) => {
|
||||||
// Set the data immediately first
|
|
||||||
setData(rows);
|
setData(rows);
|
||||||
|
|
||||||
// Then run the hooks if they exist
|
|
||||||
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
|
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
|
||||||
const updatedData = await addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes);
|
const updatedData = await addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes);
|
||||||
setData(updatedData);
|
setData(updatedData as (Data<T> & ExtendedMeta)[]);
|
||||||
} else {
|
} else {
|
||||||
addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes).then(setData);
|
const result = await addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes);
|
||||||
|
setData(result as (Data<T> & ExtendedMeta)[]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rowHook, tableHook, fields],
|
[rowHook, tableHook, fields],
|
||||||
@@ -813,8 +1132,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
setCopyDownField(null)
|
setCopyDownField(null)
|
||||||
}, [data, updateData, copyDownField])
|
}, [data, updateData, copyDownField])
|
||||||
|
|
||||||
|
// Update columns definition to include template column
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
|
const baseColumns: ColumnDef<Data<T> & ExtendedMeta>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -839,7 +1159,37 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
size: 50,
|
size: 50,
|
||||||
},
|
},
|
||||||
...(Array.from(fields as ReadonlyFields<T>).map((field): ColumnDef<Data<T> & Meta> => ({
|
{
|
||||||
|
id: "template",
|
||||||
|
header: "Template",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
value={row.original.__template || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newData = [...data];
|
||||||
|
const index = newData.findIndex(r => r.__index === row.original.__index);
|
||||||
|
if (index !== -1) {
|
||||||
|
newData[index] = { ...newData[index], __template: value };
|
||||||
|
setData(newData);
|
||||||
|
applyTemplate(value, [index]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select template" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templates?.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.id.toString()}>
|
||||||
|
{template.company} - {template.product_type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
),
|
||||||
|
size: 200,
|
||||||
|
},
|
||||||
|
...(Array.from(fields as ReadonlyFields<T>).map((field): ColumnDef<Data<T> & ExtendedMeta> => ({
|
||||||
accessorKey: field.key,
|
accessorKey: field.key,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="group">
|
<div className="group">
|
||||||
@@ -872,7 +1222,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
})))
|
})))
|
||||||
]
|
]
|
||||||
return baseColumns
|
return baseColumns
|
||||||
}, [fields, updateRows, data, copyValueDown])
|
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
@@ -974,9 +1324,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const submitData = useCallback(async () => {
|
const submitData = useCallback(async () => {
|
||||||
const calculatedData = data.reduce(
|
const calculatedData: Result<T> = data.reduce(
|
||||||
(acc, value) => {
|
(acc, value) => {
|
||||||
const { __index, __errors, ...values } = value
|
const { __index, __errors, __template, ...values } = value
|
||||||
|
|
||||||
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
||||||
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
|
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
|
||||||
@@ -1010,10 +1360,12 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
onClose()
|
onClose()
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
|
const defaultMessage = translations.alerts.submitError.defaultMessage
|
||||||
|
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: translations.alerts.submitError.title,
|
title: translations.alerts.submitError.title,
|
||||||
description: err?.message || translations.alerts.submitError.defaultMessage,
|
description: String(err?.message || errorMessage),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -1346,6 +1698,15 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<SaveTemplateDialog
|
||||||
|
isOpen={showSaveTemplateDialog}
|
||||||
|
onClose={() => setShowSaveTemplateDialog(false)}
|
||||||
|
onSave={(company, productType) => {
|
||||||
|
setNewTemplateName(company);
|
||||||
|
setNewTemplateType(productType);
|
||||||
|
saveAsTemplate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="px-8 pt-6">
|
<div className="px-8 pt-6">
|
||||||
@@ -1354,10 +1715,58 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
|||||||
{translations.validationStep.title}
|
{translations.validationStep.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedTemplateId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedTemplateId(value);
|
||||||
|
const selectedRows = Object.keys(rowSelection).map(Number);
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "No rows selected",
|
||||||
|
description: "Please select rows to apply the template to",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyTemplate(value, selectedRows);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Apply template to selected" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templates?.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.id.toString()}>
|
||||||
|
{template.company} - {template.product_type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const selectedRows = Object.keys(rowSelection);
|
||||||
|
if (selectedRows.length !== 1) {
|
||||||
|
toast({
|
||||||
|
title: "Invalid selection",
|
||||||
|
description: "Please select exactly one row to save as a template",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowSaveTemplateDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Selected as Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={deleteSelectedRows}
|
onClick={deleteSelectedRows}
|
||||||
|
disabled={Object.keys(rowSelection).length === 0}
|
||||||
>
|
>
|
||||||
{translations.validationStep.discardButtonTitle}
|
{translations.validationStep.discardButtonTitle}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function Settings() {
|
|||||||
Calculation Settings
|
Calculation Settings
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="templates">
|
<TabsTrigger value="templates">
|
||||||
Import Templates
|
Template Management
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user