Add remaining templates elements
This commit is contained in:
@@ -32,8 +32,6 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/pg": "^8.11.2",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ const dotenv = require('dotenv');
|
||||
// Ensure environment variables are loaded
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
// Initialize OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const { getPool } = require('../utils/db');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
@@ -7,23 +7,14 @@ dotenv.config({ path: path.join(__dirname, "../../.env") });
|
||||
|
||||
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
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM templates
|
||||
ORDER BY company ASC, product_type ASC
|
||||
@@ -42,6 +33,11 @@ router.get('/', async (req, res) => {
|
||||
router.get('/:company/:productType', async (req, res) => {
|
||||
try {
|
||||
const { company, productType } = req.params;
|
||||
const pool = getPool();
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM templates
|
||||
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' });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO templates (
|
||||
company,
|
||||
@@ -176,6 +177,11 @@ router.put('/:id', async (req, res) => {
|
||||
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(`
|
||||
UPDATE templates
|
||||
SET
|
||||
@@ -244,6 +250,11 @@ router.put('/:id', async (req, res) => {
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
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]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
|
||||
@@ -20,7 +20,7 @@ const aiValidationRouter = require('./routes/ai-validation');
|
||||
const templatesRouter = require('./routes/templates');
|
||||
|
||||
// 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('.env file exists:', fs.existsSync(envPath));
|
||||
|
||||
@@ -35,8 +35,7 @@ try {
|
||||
DB_NAME: process.env.DB_NAME || 'not set',
|
||||
DB_PASSWORD: process.env.DB_PASSWORD ? '[password set]' : 'not set',
|
||||
DB_PORT: process.env.DB_PORT || 'not set',
|
||||
DB_SSL: process.env.DB_SSL || 'not set',
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? '[key set]' : 'not set'
|
||||
DB_SSL: process.env.DB_SSL || 'not set'
|
||||
});
|
||||
} catch (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
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize database pool with PostgreSQL configuration
|
||||
// Initialize database pool
|
||||
const pool = await initPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
max: process.env.NODE_ENV === 'production' ? 20 : 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
@@ -110,8 +109,7 @@ async function startServer() {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
database: 'connected'
|
||||
environment: process.env.NODE_ENV
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
const { Pool } = require('pg');
|
||||
const { Pool, Client } = require('pg');
|
||||
const { Client: SSHClient } = require('ssh2');
|
||||
|
||||
let pool;
|
||||
|
||||
async function initPool(config) {
|
||||
function initPool(config) {
|
||||
// Log config without sensitive data
|
||||
const safeConfig = {
|
||||
host: config.host,
|
||||
user: config.user,
|
||||
database: config.database,
|
||||
port: config.port,
|
||||
max: config.max,
|
||||
idleTimeoutMillis: config.idleTimeoutMillis,
|
||||
connectionTimeoutMillis: config.connectionTimeoutMillis,
|
||||
ssl: config.ssl,
|
||||
password: config.password ? '[password set]' : '[no password]'
|
||||
host: config.host || process.env.DB_HOST,
|
||||
user: config.user || process.env.DB_USER,
|
||||
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,
|
||||
password: (config.password || process.env.DB_PASSWORD) ? '[password set]' : '[no password]'
|
||||
};
|
||||
console.log('[Database] Initializing pool with config:', safeConfig);
|
||||
|
||||
try {
|
||||
// Create the pool
|
||||
pool = new Pool(config);
|
||||
// Create the pool with the configuration
|
||||
pool = new Pool({
|
||||
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
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('SELECT NOW()');
|
||||
console.log('[Database] Pool connection test successful');
|
||||
} finally {
|
||||
// Test the pool connection
|
||||
return pool.connect()
|
||||
.then(client => {
|
||||
console.log('[Database] Pool connection successful');
|
||||
client.release();
|
||||
}
|
||||
|
||||
return pool;
|
||||
} catch (err) {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Database] Connection failed:', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getConnection() {
|
||||
@@ -45,24 +51,8 @@ async function getConnection() {
|
||||
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 = {
|
||||
initPool,
|
||||
getConnection,
|
||||
getPool: () => pool,
|
||||
query
|
||||
getPool: () => pool
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@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 { useRsi } from "../../hooks/useRsi"
|
||||
import type { Meta } from "./types"
|
||||
import type { Meta, Error } from "./types"
|
||||
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 { cn } from "@/lib/utils"
|
||||
import {
|
||||
@@ -63,12 +63,44 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
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> = {
|
||||
initialData: (Data<T> & Meta)[]
|
||||
initialData: RowData<T>[]
|
||||
file: File
|
||||
onBack?: () => void
|
||||
}
|
||||
@@ -618,7 +650,7 @@ const ColumnHeader = memo(({
|
||||
onCopyDown
|
||||
}: {
|
||||
field: Field<string>
|
||||
data: (Data<string> & Meta)[]
|
||||
data: (Data<string> & ExtendedMeta)[]
|
||||
onCopyDown: (key: string) => void
|
||||
}) => {
|
||||
return (
|
||||
@@ -704,11 +736,288 @@ type DeepReadonlyField<T extends string> = {
|
||||
type ReadonlyField<T extends string> = Readonly<Field<T>>;
|
||||
type ReadonlyFields<T extends string> = readonly ReadonlyField<T>[];
|
||||
|
||||
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
|
||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
|
||||
const { toast } = useToast()
|
||||
// Hook for managing templates
|
||||
function useTemplates<T extends string>(
|
||||
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 [filterByErrors, setFilterByErrors] = useState(false)
|
||||
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
||||
@@ -745,6 +1054,17 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
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
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -755,16 +1075,15 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
}, [data, filterByErrors])
|
||||
|
||||
const updateData = useCallback(
|
||||
async (rows: typeof data, indexes?: number[]) => {
|
||||
// Set the data immediately first
|
||||
async (rows: (Data<T> & ExtendedMeta)[], indexes?: number[]) => {
|
||||
setData(rows);
|
||||
|
||||
// Then run the hooks if they exist
|
||||
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
|
||||
const updatedData = await addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes);
|
||||
setData(updatedData);
|
||||
setData(updatedData as (Data<T> & ExtendedMeta)[]);
|
||||
} 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],
|
||||
@@ -813,8 +1132,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
setCopyDownField(null)
|
||||
}, [data, updateData, copyDownField])
|
||||
|
||||
// Update columns definition to include template column
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
|
||||
const baseColumns: ColumnDef<Data<T> & ExtendedMeta>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
@@ -839,7 +1159,37 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
enableHiding: false,
|
||||
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,
|
||||
header: () => (
|
||||
<div className="group">
|
||||
@@ -872,7 +1222,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
})))
|
||||
]
|
||||
return baseColumns
|
||||
}, [fields, updateRows, data, copyValueDown])
|
||||
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
@@ -974,9 +1324,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
}, [])
|
||||
|
||||
const submitData = useCallback(async () => {
|
||||
const calculatedData = data.reduce(
|
||||
const calculatedData: Result<T> = data.reduce(
|
||||
(acc, value) => {
|
||||
const { __index, __errors, ...values } = value
|
||||
const { __index, __errors, __template, ...values } = value
|
||||
|
||||
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
||||
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()
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
const defaultMessage = translations.alerts.submitError.defaultMessage
|
||||
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: translations.alerts.submitError.title,
|
||||
description: err?.message || translations.alerts.submitError.defaultMessage,
|
||||
description: String(err?.message || errorMessage),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1346,6 +1698,15 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SaveTemplateDialog
|
||||
isOpen={showSaveTemplateDialog}
|
||||
onClose={() => setShowSaveTemplateDialog(false)}
|
||||
onSave={(company, productType) => {
|
||||
setNewTemplateName(company);
|
||||
setNewTemplateType(productType);
|
||||
saveAsTemplate();
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-8 pt-6">
|
||||
@@ -1354,10 +1715,58 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
{translations.validationStep.title}
|
||||
</h2>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deleteSelectedRows}
|
||||
disabled={Object.keys(rowSelection).length === 0}
|
||||
>
|
||||
{translations.validationStep.discardButtonTitle}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Settings() {
|
||||
Calculation Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates">
|
||||
Import Templates
|
||||
Template Management
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user