AI validation tweaks, add templates settings page and schema and routes

This commit is contained in:
2025-02-23 15:14:12 -05:00
parent 959a64aebc
commit 3f16413769
12 changed files with 1167 additions and 201 deletions

View File

@@ -660,75 +660,83 @@ export function DataManagement() {
<CardContent className="px-4 mb-4 max-h-[300px] overflow-y-auto">
<Table>
<TableBody>
{importHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
<Accordion type="single" collapsible>
<AccordionItem
value={`import-${record.id}`}
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span>
<span>
{record.end_time
? formatDate(record.end_time)
: "N/A"}
{importHistory.length > 0 ? (
importHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
<Accordion type="single" collapsible>
<AccordionItem
value={`import-${record.id}`}
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Records:</span>
<span>
{record.records_added} added,{" "}
{record.records_updated} updated
</span>
</div>
{record.error_message && (
<div className="text-sm text-red-600 mt-2">
{record.error_message}
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span>
<span>
{record.end_time
? formatDate(record.end_time)
: "N/A"}
</span>
</div>
)}
{record.additional_info &&
formatJsonData(record.additional_info)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Records:</span>
<span>
{record.records_added} added,{" "}
{record.records_updated} updated
</span>
</div>
{record.error_message && (
<div className="text-sm text-red-600 mt-2">
{record.error_message}
</div>
)}
{record.additional_info &&
formatJsonData(record.additional_info)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
No import history available
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
</CardContent>
@@ -742,90 +750,98 @@ export function DataManagement() {
<CardContent className="px-4 mb-4 max-h-[300px] overflow-y-auto">
<Table>
<TableBody>
{calculateHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
<Accordion type="single" collapsible>
<AccordionItem
value={`calc-${record.id}`}
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
{calculateHistory.length > 0 ? (
calculateHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
<Accordion type="single" collapsible>
<AccordionItem
value={`calc-${record.id}`}
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span>
<span>
{record.end_time
? formatDate(record.end_time)
: "N/A"}
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Products:
</span>
<span>{record.processed_products}</span>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span>
<span>
{record.end_time
? formatDate(record.end_time)
: "N/A"}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Products:
</span>
<span>{record.processed_products}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Orders:
</span>
<span>{record.processed_orders}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Purchase Orders:
</span>
<span>{record.processed_purchase_orders}</span>
</div>
{record.error_message && (
<div className="text-sm text-red-600 mt-2">
{record.error_message}
</div>
)}
{record.additional_info &&
formatJsonData(record.additional_info)}
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Orders:
</span>
<span>{record.processed_orders}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Purchase Orders:
</span>
<span>{record.processed_purchase_orders}</span>
</div>
{record.error_message && (
<div className="text-sm text-red-600 mt-2">
{record.error_message}
</div>
)}
{record.additional_info &&
formatJsonData(record.additional_info)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</AccordionContent>
</AccordionItem>
</Accordion>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
No calculation history available
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
</CardContent>
</CardContent>
</Card>
</div>
</div>

View File

@@ -0,0 +1,422 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import config from "@/config";
interface Template {
id: number;
company: string;
product_type: string;
supplier?: string;
msrp?: number;
cost_each?: number;
qty_per_unit?: number;
case_qty?: number;
hts_code?: string;
description?: string;
weight?: number;
length?: number;
width?: number;
height?: number;
tax_cat?: string;
size_cat?: string;
categories?: string[];
ship_restrictions?: string[];
created_at: string;
updated_at: string;
}
interface TemplateFormData extends Omit<Template, 'id' | 'created_at' | 'updated_at'> {}
export function TemplateManagement() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [formData, setFormData] = useState<TemplateFormData>({
company: "",
product_type: "",
});
const queryClient = useQueryClient();
const { data: templates, isLoading } = useQuery<Template[]>({
queryKey: ["templates"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/templates`);
if (!response.ok) {
throw new Error("Failed to fetch templates");
}
return response.json();
},
});
const createMutation = useMutation({
mutationFn: async (data: TemplateFormData) => {
const response = await fetch(`${config.apiUrl}/templates`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to create template");
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
setIsCreateOpen(false);
setFormData({ company: "", product_type: "" });
toast.success("Template created successfully");
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to create template");
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete template");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Template deleted successfully");
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to delete template");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(formData);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleArrayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value.split(",").map((item) => item.trim()),
}));
};
const handleNumberInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value === "" ? undefined : Number(value),
}));
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Import Templates</h2>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>Create Template</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Import Template</DialogTitle>
<DialogDescription>
Create a new template for importing products. Company and Product Type combination must be unique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<ScrollArea className="h-[60vh]">
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company *</Label>
<Input
id="company"
name="company"
value={formData.company}
onChange={handleInputChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="product_type">Product Type *</Label>
<Input
id="product_type"
name="product_type"
value={formData.product_type}
onChange={handleInputChange}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="supplier">Supplier</Label>
<Input
id="supplier"
name="supplier"
value={formData.supplier || ""}
onChange={handleInputChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="msrp">MSRP</Label>
<Input
id="msrp"
name="msrp"
type="number"
step="0.01"
value={formData.msrp || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cost_each">Cost Each</Label>
<Input
id="cost_each"
name="cost_each"
type="number"
step="0.01"
value={formData.cost_each || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="qty_per_unit">Quantity per Unit</Label>
<Input
id="qty_per_unit"
name="qty_per_unit"
type="number"
value={formData.qty_per_unit || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="case_qty">Case Quantity</Label>
<Input
id="case_qty"
name="case_qty"
type="number"
value={formData.case_qty || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hts_code">HTS Code</Label>
<Input
id="hts_code"
name="hts_code"
value={formData.hts_code || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
name="description"
value={formData.description || ""}
onChange={handleInputChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="weight">Weight</Label>
<Input
id="weight"
name="weight"
type="number"
step="0.01"
value={formData.weight || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="length">Length</Label>
<Input
id="length"
name="length"
type="number"
step="0.01"
value={formData.length || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width">Width</Label>
<Input
id="width"
name="width"
type="number"
step="0.01"
value={formData.width || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height">Height</Label>
<Input
id="height"
name="height"
type="number"
step="0.01"
value={formData.height || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_cat">Tax Category</Label>
<Input
id="tax_cat"
name="tax_cat"
value={formData.tax_cat || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="size_cat">Size Category</Label>
<Input
id="size_cat"
name="size_cat"
value={formData.size_cat || ""}
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="categories">Categories (comma-separated)</Label>
<Input
id="categories"
name="categories"
value={formData.categories?.join(", ") || ""}
onChange={handleArrayInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ship_restrictions">
Shipping Restrictions (comma-separated)
</Label>
<Input
id="ship_restrictions"
name="ship_restrictions"
value={formData.ship_restrictions?.join(", ") || ""}
onChange={handleArrayInputChange}
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create Template"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div>Loading templates...</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Product Type</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates?.map((template) => (
<TableRow key={template.id}>
<TableCell>{template.company}</TableCell>
<TableCell>{template.product_type}</TableCell>
<TableCell>{template.supplier || "-"}</TableCell>
<TableCell>
{new Date(template.updated_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => {
if (
window.confirm(
"Are you sure you want to delete this template?"
)
) {
deleteMutation.mutate(template.id);
}
}}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
{templates?.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center">
No templates found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -744,6 +744,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
prompt: null,
isLoading: false,
});
// Memoize filtered data to prevent recalculation on every render
const filteredData = useMemo(() => {
@@ -893,6 +894,59 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
}
}, [rowSelection, data, updateData]);
const discardEmptyAndDuplicateRows = useCallback(() => {
// Helper function to count non-empty values in a row
const countNonEmptyValues = (values: Record<string, any>): number => {
return Object.values(values).filter(val =>
val !== undefined &&
val !== null &&
(typeof val === 'string' ? val.trim() !== '' : true)
).length;
};
// First, analyze all rows to determine if we have rows with multiple values
const rowsWithValues = data.map(row => {
const { __index, __errors, ...values } = row;
return countNonEmptyValues(values);
});
// Check if we have any rows with more than one value
const hasMultiValueRows = rowsWithValues.some(count => count > 1);
// Filter out empty rows and rows with single values (if we have multi-value rows)
const nonEmptyRows = data.filter((row, index) => {
const nonEmptyCount = rowsWithValues[index];
// Keep the row if:
// 1. It has more than one value, OR
// 2. It has exactly one value AND we don't have any rows with multiple values
return nonEmptyCount > 0 && (!hasMultiValueRows || nonEmptyCount > 1);
});
// Then, remove duplicates by creating a unique string representation of each row
const seen = new Set<string>();
const uniqueRows = nonEmptyRows.filter(row => {
const { __index, __errors, ...values } = row;
const rowStr = JSON.stringify(Object.entries(values).sort());
if (seen.has(rowStr)) {
return false;
}
seen.add(rowStr);
return true;
});
// Only update if we actually removed any rows
if (uniqueRows.length < data.length) {
updateData(uniqueRows);
setRowSelection({});
toast({
title: "Rows removed",
description: `Removed ${data.length - uniqueRows.length} empty, single-value, or duplicate rows`,
variant: "default"
});
}
}, [data, updateData, toast]);
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
if (field.fieldType.type === "checkbox") {
if (typeof value === "boolean") return value
@@ -1218,7 +1272,12 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
<Dialog open={aiValidationProgress.isOpen} onOpenChange={() => {}}>
<Dialog open={aiValidationProgress.isOpen} onOpenChange={(open) => {
// Only allow closing if validation failed
if (!open && aiValidationProgress.step === -1) {
setAiValidationProgress(prev => ({ ...prev, isOpen: false }));
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>AI Validation Progress</DialogTitle>
@@ -1302,6 +1361,13 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
>
{translations.validationStep.discardButtonTitle}
</Button>
<Button
variant="outline"
size="sm"
onClick={discardEmptyAndDuplicateRows}
>
Remove Empty/Duplicates
</Button>
<Button
variant="secondary"
size="sm"

View File

@@ -37,7 +37,24 @@ export function AiValidationDebug() {
const fetchDebugData = async () => {
setIsLoading(true)
try {
const response = await fetch(`${config.apiUrl}/ai-validation/debug`)
// Use a sample product to avoid loading full taxonomy
const sampleProduct = {
title: "Sample Product",
description: "A sample product for testing",
SKU: "SAMPLE-001",
price: "9.99",
cost_each: "5.00",
qty_per_unit: "1",
case_qty: "12"
}
const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: [sampleProduct] })
})
if (!response.ok) {
throw new Error('Failed to fetch debug data')
}

View File

@@ -3,6 +3,7 @@ import { DataManagement } from "@/components/settings/DataManagement";
import { StockManagement } from "@/components/settings/StockManagement";
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
import { CalculationSettings } from "@/components/settings/CalculationSettings";
import { TemplateManagement } from "@/components/settings/TemplateManagement";
import { motion } from 'motion/react';
export function Settings() {
@@ -22,6 +23,9 @@ export function Settings() {
<TabsTrigger value="calculation-settings">
Calculation Settings
</TabsTrigger>
<TabsTrigger value="templates">
Import Templates
</TabsTrigger>
</TabsList>
<TabsContent value="data-management">
@@ -39,6 +43,10 @@ export function Settings() {
<TabsContent value="calculation-settings">
<CalculationSettings />
</TabsContent>
<TabsContent value="templates">
<TemplateManagement />
</TabsContent>
</Tabs>
</motion.div>
);