Add remaining templates elements

This commit is contained in:
2025-02-24 00:02:27 -05:00
parent f628774267
commit 441a2c74ad
10 changed files with 1446 additions and 331 deletions

View File

@@ -32,8 +32,6 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/pg": "^8.11.2",
"nodemon": "^3.0.2"
}
}

View File

@@ -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
});

View File

@@ -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) {

View File

@@ -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
});
});

View File

@@ -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

View 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 }

View File

@@ -99,3 +99,14 @@
@apply bg-background text-foreground;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -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>

View File

@@ -24,7 +24,7 @@ export function Settings() {
Calculation Settings
</TabsTrigger>
<TabsTrigger value="templates">
Import Templates
Template Management
</TabsTrigger>
</TabsList>