AI tweaks/fixes + backend api interface updates

This commit is contained in:
2026-01-24 11:58:21 -05:00
parent 1866cbae7e
commit 3831cef234
24 changed files with 458 additions and 252 deletions

View File

@@ -35,7 +35,7 @@ global.pool = pool;
app.use(express.json()); app.use(express.json());
app.use(morgan('combined')); app.use(morgan('combined'));
app.use(cors({ app.use(cors({
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site'], origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://acob.acherryontop.com'],
credentials: true credentials: true
})); }));

View File

@@ -33,7 +33,7 @@ global.pool = pool;
app.use(express.json()); app.use(express.json());
app.use(morgan('combined')); app.use(morgan('combined'));
app.use(cors({ app.use(cors({
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site'], origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://acob.acherryontop.com'],
credentials: true credentials: true
})); }));

View File

@@ -33,7 +33,7 @@ const corsOptions = {
origin: function(origin, callback) { origin: function(origin, callback) {
const allowedOrigins = [ const allowedOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'https://dashboard.kent.pw' 'https://acob.acherryontop.com'
]; ];
console.log('CORS check for origin:', origin); console.log('CORS check for origin:', origin);

View File

@@ -6,6 +6,7 @@ const corsMiddleware = cors({
'https://inventory.kent.pw', 'https://inventory.kent.pw',
'http://localhost:5175', 'http://localhost:5175',
'https://acot.site', 'https://acot.site',
'https://acob.acherryontop.com',
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/, /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/ /^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
], ],
@@ -27,7 +28,7 @@ const corsErrorHandler = (err, req, res, next) => {
res.status(403).json({ res.status(403).json({
error: 'CORS not allowed', error: 'CORS not allowed',
origin: req.get('Origin'), origin: req.get('Origin'),
message: 'Origin not in allowed list: https://inventory.kent.pw, https://acot.site, localhost:5175, 192.168.x.x, or 10.x.x.x' message: 'Origin not in allowed list: https://inventory.kent.pw, https://acot.site, https://acob.acherryontop.com, localhost:5175, 192.168.x.x, or 10.x.x.x'
}); });
} else { } else {
next(err); next(err);

View File

@@ -635,7 +635,7 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
// Create URL for the uploaded file - using an absolute URL with domain // Create URL for the uploaded file - using an absolute URL with domain
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg // This will generate a URL like: https://acot.site/uploads/products/filename.jpg
const baseUrl = 'https://acot.site'; const baseUrl = 'https://acob.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`; const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
// Schedule this image for deletion in 24 hours // Schedule this image for deletion in 24 hours
@@ -715,6 +715,26 @@ router.delete('/delete-image', (req, res) => {
} }
}); });
// Clear all taxonomy caches
router.post('/clear-taxonomy-cache', (req, res) => {
try {
// Clear all entries from the query cache
const cacheSize = connectionCache.queryCache.size;
connectionCache.queryCache.clear();
console.log(`Cleared ${cacheSize} entries from taxonomy cache`);
res.json({
success: true,
message: `Cache cleared (${cacheSize} entries removed)`,
clearedEntries: cacheSize
});
} catch (error) {
console.error('Error clearing taxonomy cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
// Get all options for import fields // Get all options for import fields
router.get('/field-options', async (req, res) => { router.get('/field-options', async (req, res) => {
try { try {

View File

@@ -194,7 +194,7 @@ router.post('/upload', upload.single('image'), async (req, res) => {
} }
// Create URL for the uploaded file // Create URL for the uploaded file
const baseUrl = 'https://acot.site'; const baseUrl = 'https://acob.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`; const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`;
const pool = req.app.locals.pool; const pool = req.app.locals.pool;

View File

@@ -29,6 +29,7 @@ function buildSanityCheckUserPrompt(products, prompts) {
tax_cat: p.tax_cat_name || p.tax_cat, tax_cat: p.tax_cat_name || p.tax_cat,
size_cat: p.size_cat_name || p.size_cat, size_cat: p.size_cat_name || p.size_cat,
themes: p.theme_names || p.themes, themes: p.theme_names || p.themes,
categories: p.category_names || p.categories,
weight: p.weight, weight: p.weight,
length: p.length, length: p.length,
width: p.width, width: p.width,
@@ -59,7 +60,7 @@ function buildSanityCheckUserPrompt(products, prompts) {
suggestion: 'Suggested fix or verification (optional)' suggestion: 'Suggested fix or verification (optional)'
} }
], ],
summary: '1-2 sentences summarizing the batch quality' summary: '2-3 sentences summarizing the overall product quality'
}, null, 2)); }, null, 2));
parts.push(''); parts.push('');

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:deploy": "tsc -b && COPY_BUILD=true vite build", "build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/html/inventory/frontend vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"mount": "../mountremote.command" "mount": "../mountremote.command"

View File

@@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -14,6 +13,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -27,9 +27,10 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import config from "@/config"; import config from "@/config";
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2"; import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
import { AuthContext } from "@/contexts/AuthContext";
type Option = { type Option = {
label: string; label: string;
@@ -84,7 +85,11 @@ export function CreateProductCategoryDialog({
environment = "prod", environment = "prod",
onCreated, onCreated,
}: CreateProductCategoryDialogProps) { }: CreateProductCategoryDialogProps) {
const { user } = useContext(AuthContext);
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"line" | "subline">(defaultLineId ? "subline" : "line");
const [companyId, setCompanyId] = useState<string>(defaultCompanyId ?? ""); const [companyId, setCompanyId] = useState<string>(defaultCompanyId ?? "");
const [lineId, setLineId] = useState<string>(defaultLineId ?? ""); const [lineId, setLineId] = useState<string>(defaultLineId ?? "");
const [categoryName, setCategoryName] = useState(""); const [categoryName, setCategoryName] = useState("");
@@ -92,6 +97,7 @@ export function CreateProductCategoryDialog({
const [isLoadingLines, setIsLoadingLines] = useState(false); const [isLoadingLines, setIsLoadingLines] = useState(false);
const [lines, setLines] = useState<Option[]>([]); const [lines, setLines] = useState<Option[]>([]);
const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({}); const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({});
const [targetEnvironment, setTargetEnvironment] = useState<"dev" | "prod">(environment);
// Popover open states // Popover open states
const [companyOpen, setCompanyOpen] = useState(false); const [companyOpen, setCompanyOpen] = useState(false);
@@ -116,6 +122,7 @@ export function CreateProductCategoryDialog({
setCompanyId(defaultCompanyId ?? ""); setCompanyId(defaultCompanyId ?? "");
setLineId(defaultLineId ?? ""); setLineId(defaultLineId ?? "");
setCategoryName(""); setCategoryName("");
setActiveTab(defaultLineId ? "subline" : "line");
} }
}, [isOpen, defaultCompanyId, defaultLineId]); }, [isOpen, defaultCompanyId, defaultLineId]);
@@ -180,8 +187,13 @@ export function CreateProductCategoryDialog({
return; return;
} }
const parentId = lineId || companyId; if (activeTab === "subline" && !lineId) {
const creationType: "line" | "subline" = lineId ? "subline" : "line"; toast.error("Select a parent line to create a subline");
return;
}
const parentId = activeTab === "subline" ? lineId : companyId;
const creationType = activeTab;
setIsSubmitting(true); setIsSubmitting(true);
@@ -189,7 +201,7 @@ export function CreateProductCategoryDialog({
const result = await createProductCategory({ const result = await createProductCategory({
masterCatId: parentId, masterCatId: parentId,
name: trimmedName, name: trimmedName,
environment, environment: targetEnvironment,
}); });
if (!result.success) { if (!result.success) {
@@ -211,7 +223,7 @@ export function CreateProductCategoryDialog({
} }
} }
if (!lineId) { if (activeTab === "line") {
const nextOption: Option = { label: trimmedName, value: newId ?? trimmedName }; const nextOption: Option = { label: trimmedName, value: newId ?? trimmedName };
setLinesCache((prev) => { setLinesCache((prev) => {
const existing = prev[companyId] ?? []; const existing = prev[companyId] ?? [];
@@ -243,13 +255,13 @@ export function CreateProductCategoryDialog({
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
: `Failed to create ${lineId ? "subline" : "product line"}.`; : `Failed to create ${activeTab === "line" ? "product line" : "subline"}.`;
toast.error(message); toast.error(message);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, },
[categoryName, companyId, environment, lineId, onCreated], [activeTab, categoryName, companyId, targetEnvironment, lineId, onCreated],
); );
return ( return (
@@ -257,14 +269,17 @@ export function CreateProductCategoryDialog({
<DialogTrigger asChild>{trigger}</DialogTrigger> <DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create Product Line or Subline</DialogTitle> <DialogTitle>Create New Line or Subline</DialogTitle>
<DialogDescription>
Add a new product line beneath a company or create a subline beneath an existing line.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "line" | "subline")}>
{/* Company Select - Searchable */} <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="line">Create Line</TabsTrigger>
<TabsTrigger value="subline">Create Subline</TabsTrigger>
</TabsList>
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
{/* Company Select - shown in both tabs */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Company</Label> <Label>Company</Label>
<Popover open={companyOpen} onOpenChange={setCompanyOpen}> <Popover open={companyOpen} onOpenChange={setCompanyOpen}>
@@ -308,11 +323,22 @@ export function CreateProductCategoryDialog({
</Popover> </Popover>
</div> </div>
{/* Line Select - Searchable */} <TabsContent value="line" className="space-y-4 m-0">
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label htmlFor="create-line-name">Line Name</Label>
Parent Line <span className="text-muted-foreground">(optional)</span> <Input
</Label> id="create-line-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Enter the new line name"
/>
</div>
</TabsContent>
<TabsContent value="subline" className="space-y-4 m-0">
{/* Parent Line Select - only shown in subline tab */}
<div className="space-y-2">
<Label>Parent Line</Label>
<Popover open={lineOpen} onOpenChange={setLineOpen}> <Popover open={lineOpen} onOpenChange={setLineOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -326,7 +352,7 @@ export function CreateProductCategoryDialog({
? "Select a company first" ? "Select a company first"
: isLoadingLines : isLoadingLines
? "Loading product lines..." ? "Loading product lines..."
: selectedLineLabel || "Leave empty to create a new line"} : selectedLineLabel || "Select a parent line"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -336,17 +362,6 @@ export function CreateProductCategoryDialog({
<CommandList> <CommandList>
<CommandEmpty>No line found.</CommandEmpty> <CommandEmpty>No line found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{/* Option to clear selection */}
<CommandItem
value="none"
onSelect={() => {
setLineId("");
setLineOpen(false);
}}
>
<span className="text-muted-foreground">None (create new line)</span>
{lineId === "" && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
{lines.map((line) => ( {lines.map((line) => (
<CommandItem <CommandItem
key={line.value} key={line.value}
@@ -369,37 +384,57 @@ export function CreateProductCategoryDialog({
</Popover> </Popover>
{companyId && !isLoadingLines && !lines.length && ( {companyId && !isLoadingLines && !lines.length && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No existing lines found for this company. A new line will be created. No existing lines found for this company. Create a line first.
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="create-category-name">Name</Label> <Label htmlFor="create-subline-name">Subline Name</Label>
<Input <Input
id="create-category-name" id="create-subline-name"
value={categoryName} value={categoryName}
onChange={(event) => setCategoryName(event.target.value)} onChange={(event) => setCategoryName(event.target.value)}
placeholder="Enter the new line or subline name" placeholder="Enter the new subline name"
/> />
</div> </div>
</TabsContent>
{hasDebugPermission && (
<div className="pt-2 pb-2 border-t">
<div className="flex items-center gap-2">
<Switch
id="category-api-environment"
checked={targetEnvironment === "dev"}
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
/>
<Label htmlFor="category-api-environment" className="text-sm font-medium cursor-pointer">
Use test API
</Label>
</div>
</div>
)}
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}> <Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting || !companyId}> <Button
type="submit"
disabled={isSubmitting || !companyId || (activeTab === "subline" && !lineId)}
>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating... Creating...
</> </>
) : ( ) : (
"Create" `Create ${activeTab === "line" ? "Line" : "Subline"}`
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -143,7 +143,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Cost Each", label: "Cost Each",
key: "cost_each", key: "cost_each",
description: "Wholesale cost per unit", description: "Wholesale cost per unit",
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"], alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each","whls"],
fieldType: { fieldType: {
type: "input", type: "input",
price: true price: true

View File

@@ -29,7 +29,7 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import config from "@/config" import config from "@/config"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react" import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot, RefreshCw, Plus } from "lucide-react"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -611,6 +611,7 @@ const MatchColumnsStepComponent = <T extends string>({
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>() const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(() => { const [columns, setColumns] = useState<Columns<T>>(() => {
// Helper function to check if a column is completely empty // Helper function to check if a column is completely empty
@@ -846,6 +847,43 @@ const MatchColumnsStepComponent = <T extends string>({
[queryClient, setGlobalSelections], [queryClient, setGlobalSelections],
); );
// Handle manual cache refresh
const handleRefreshTaxonomy = useCallback(async () => {
setIsRefreshing(true);
try {
// Clear backend cache
const response = await fetch(`${config.apiUrl}/import/clear-taxonomy-cache`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to clear backend cache');
}
// Clear frontend React Query cache
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['field-options'] }),
queryClient.invalidateQueries({ queryKey: ['product-lines'] }),
queryClient.invalidateQueries({ queryKey: ['product-lines-mapped'] }),
queryClient.invalidateQueries({ queryKey: ['sublines'] }),
queryClient.invalidateQueries({ queryKey: ['sublines-mapped'] }),
]);
// Refetch field options immediately
await queryClient.refetchQueries({ queryKey: ['field-options'] });
} catch (error) {
console.error('Error refreshing taxonomy:', error);
toast.error('Failed to refresh taxonomy data', {
});
} finally {
setIsRefreshing(false);
}
}, [queryClient]);
// Check if a field is covered by global selections // Check if a field is covered by global selections
const isFieldCoveredByGlobalSelections = useCallback((key: string) => { const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
return (key === 'supplier' && !!globalSelections.supplier) || return (key === 'supplier' && !!globalSelections.supplier) ||
@@ -1815,11 +1853,11 @@ const MatchColumnsStepComponent = <T extends string>({
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-4 flex items-center gap-2">
<CreateProductCategoryDialog <CreateProductCategoryDialog
trigger={ trigger={
<Button variant="link" className="h-auto px-0 text-sm font-medium"> <Button variant="outline" size="sm" className="w-full">
+ New line or subline <Plus className="h-3 w-3" /> New line or subline
</Button> </Button>
} }
companies={fieldOptions?.companies || []} companies={fieldOptions?.companies || []}
@@ -1827,6 +1865,26 @@ const MatchColumnsStepComponent = <T extends string>({
defaultLineId={globalSelections.line} defaultLineId={globalSelections.line}
onCreated={handleCategoryCreated} onCreated={handleCategoryCreated}
/> />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleRefreshTaxonomy}
disabled={isRefreshing}
>
<RefreshCw className={`h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`} />
Refresh data
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>Reload all suppliers, companies, lines, categories, and other taxonomy data from the database</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</div> </div>

View File

@@ -52,6 +52,7 @@ export type StepState =
| { | {
type: StepType.validateDataNew type: StepType.validateDataNew
data: any[] data: any[]
file?: File
globalSelections?: GlobalSelections globalSelections?: GlobalSelections
isFromScratch?: boolean isFromScratch?: boolean
} }

View File

@@ -162,6 +162,7 @@ export const ValidationContainer = ({
size_cat: row.size_cat as string | number | undefined, size_cat: row.size_cat as string | number | undefined,
size_cat_name: getFieldLabel('size_cat', row.size_cat), size_cat_name: getFieldLabel('size_cat', row.size_cat),
themes: row.themes as string | undefined, themes: row.themes as string | undefined,
categories: row.categories as string | undefined,
weight: row.weight as string | number | undefined, weight: row.weight as string | number | undefined,
length: row.length as string | number | undefined, length: row.length as string | number | undefined,
width: row.width as string | number | undefined, width: row.width as string | number | undefined,
@@ -220,14 +221,17 @@ export const ValidationContainer = ({
}, []); }, []);
// Build product names lookup for sanity check dialog // Build product names lookup for sanity check dialog
const productNames = useMemo(() => { // Rebuild fresh whenever dialog opens to ensure names are current after AI suggestions
const buildProductNames = useCallback(() => {
const rows = useValidationStore.getState().rows; const rows = useValidationStore.getState().rows;
const names: Record<number, string> = {}; const names: Record<number, string> = {};
rows.forEach((row, index) => { rows.forEach((row, index) => {
names[index] = (row.name as string) || `Product ${index + 1}`; names[index] = (row.name as string) || `Product ${index + 1}`;
}); });
return names; return names;
}, [rowCount]); // Depend on rowCount to update when rows change }, []);
const productNames = useMemo(() => buildProductNames(), [sanityCheckDialogOpen, buildProductNames]);
return ( return (
<AiSuggestionsProvider <AiSuggestionsProvider

View File

@@ -7,7 +7,7 @@
import { useState, useCallback, useEffect, useRef, memo } from 'react'; import { useState, useCallback, useEffect, useRef, memo } from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Loader2, AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

@@ -18,7 +18,7 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Check, X, Sparkles, AlertTriangle, Info, Cpu, Brain } from 'lucide-react'; import { Check, X, Sparkles, AlertTriangle, Info, Cpu, Brain } from 'lucide-react';
import { Protected } from '@/components/auth/Protected'; import { Protected } from '@/components/auth/Protected';
import type { AiValidationResults, AiTokenUsage, AiValidationChange } from '../store/types'; import type { AiValidationResults, AiTokenUsage } from '../store/types';
interface AiValidationResultsDialogProps { interface AiValidationResultsDialogProps {
results: AiValidationResults; results: AiValidationResults;

View File

@@ -23,7 +23,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import type { SanityIssue, SanityCheckResult } from '../hooks/useSanityCheck'; import type { SanityCheckResult } from '../hooks/useSanityCheck';
interface SanityCheckDialogProps { interface SanityCheckDialogProps {
/** Whether the dialog is open */ /** Whether the dialog is open */
@@ -67,15 +67,25 @@ export function SanityCheckDialog({
const hasAnyIssues = hasSanityIssues || hasValidationErrors; const hasAnyIssues = hasSanityIssues || hasValidationErrors;
const allClear = !isChecking && !error && !hasSanityIssues && result; const allClear = !isChecking && !error && !hasSanityIssues && result;
// Group issues by severity/field for better organization // Group issues by field, then by exact issue+suggestion combination for deduplication
const issuesByField = result?.issues?.reduce((acc, issue) => { const issuesByField = result?.issues?.reduce((acc, issue) => {
const field = issue.field; const field = issue.field;
if (!acc[field]) { if (!acc[field]) {
acc[field] = []; acc[field] = {};
} }
acc[field].push(issue);
const key = `${issue.issue}:${issue.suggestion || ''}`;
if (!acc[field][key]) {
acc[field][key] = {
field: issue.field,
issue: issue.issue,
suggestion: issue.suggestion,
productIndices: []
};
}
acc[field][key].productIndices.push(issue.productIndex);
return acc; return acc;
}, {} as Record<string, SanityIssue[]>) || {}; }, {} as Record<string, Record<string, { field: string; issue: string; suggestion?: string; productIndices: number[] }>>) || {};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -180,28 +190,35 @@ export function SanityCheckDialog({
<div className="space-y-4"> <div className="space-y-4">
{/* Issues grouped by field */} {/* Issues grouped by field, then by unique issue+suggestion */}
{Object.entries(issuesByField).map(([field, fieldIssues]) => ( {Object.entries(issuesByField).map(([field, groups]) => (
<div key={field} className="space-y-2"> <div key={field} className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs font-semibold">
{formatFieldName(field)} {formatFieldName(field)}
</Badge> </Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{fieldIssues.length} issue{fieldIssues.length === 1 ? '' : 's'} {Object.values(groups).reduce((sum, g) => sum + g.productIndices.length, 0)} issue{Object.values(groups).reduce((sum, g) => sum + g.productIndices.length, 0) === 1 ? '' : 's'}
</span> </span>
</div> </div>
{fieldIssues.map((issue, index) => ( {Object.entries(groups).map(([key, group]) => (
<div <div
key={`${field}-${index}`} key={key}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors" className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors"
> >
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" /> <AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground">
{group.productIndices.length} product{group.productIndices.length === 1 ? '' : 's'}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-1 gap-y-1 mb-2">
{group.productIndices.map((productIndex, idx) => (
<span key={productIndex} className="inline-flex items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{productNames[issue.productIndex] || `Product ${issue.productIndex + 1}`} {productNames[productIndex] || `Product ${productIndex + 1}`}
</span> </span>
{onScrollToProduct && ( {onScrollToProduct && (
<Button <Button
@@ -210,18 +227,22 @@ export function SanityCheckDialog({
className="h-5 px-1 text-xs text-blue-600 hover:text-blue-800" className="h-5 px-1 text-xs text-blue-600 hover:text-blue-800"
onClick={() => { onClick={() => {
onOpenChange(false); onOpenChange(false);
onScrollToProduct(issue.productIndex); onScrollToProduct(productIndex);
}} }}
> >
Go to <ChevronRight className="h-3 w-3" />
<ChevronRight className="h-3 w-3 ml-0.5" />
</Button> </Button>
)} )}
{idx < group.productIndices.length - 1 && (
<span className="text-gray-400 mr-1">,</span>
)}
</span>
))}
</div> </div>
<p className="text-sm text-gray-600">{issue.issue}</p> <p className="text-sm text-gray-600">{group.issue}</p>
{issue.suggestion && ( {group.suggestion && (
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
{issue.suggestion} {group.suggestion}
</p> </p>
)} )}
</div> </div>

View File

@@ -12,7 +12,6 @@ import { useCallback } from 'react';
import { useValidationStore } from '../../store/validationStore'; import { useValidationStore } from '../../store/validationStore';
import type { AiValidationChange, AiValidationResults, AiTokenUsage } from '../../store/types'; import type { AiValidationChange, AiValidationResults, AiTokenUsage } from '../../store/types';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { AiValidationResponse, AiTokenUsage as ApiTokenUsage } from './useAiApi';
/** /**
* Helper to convert a value to number or null * Helper to convert a value to number or null

View File

@@ -53,6 +53,8 @@ export interface ProductForSanityCheck {
size_cat_name?: string; size_cat_name?: string;
themes?: string; themes?: string;
theme_names?: string; theme_names?: string;
categories?: string;
category_names?: string;
weight?: string | number; weight?: string | number;
length?: string | number; length?: string | number;
width?: string | number; width?: string | number;

View File

@@ -5,7 +5,7 @@
* only to the state they need, preventing unnecessary re-renders. * only to the state they need, preventing unnecessary re-renders.
*/ */
import { useMemo, useCallback } from 'react'; import { useMemo } from 'react';
import { useValidationStore } from './validationStore'; import { useValidationStore } from './validationStore';
import type { RowData, ValidationError } from './types'; import type { RowData, ValidationError } from './types';

View File

@@ -107,6 +107,7 @@ export interface NameValidationPayload {
line_name?: string; line_name?: string;
subline_name?: string; subline_name?: string;
siblingNames?: string[]; siblingNames?: string[];
[key: string]: unknown;
} }
/** /**
@@ -118,6 +119,7 @@ export interface DescriptionValidationPayload {
company_name?: string; company_name?: string;
company_id?: string; // Needed by backend to load company-specific prompts (not sent to AI) company_id?: string; // Needed by backend to load company-specific prompts (not sent to AI)
categories?: string; categories?: string;
[key: string]: unknown;
} }
/** /**
@@ -145,7 +147,7 @@ export function buildNameValidationPayload(
): NameValidationPayload { ): NameValidationPayload {
// Use override line for sibling computation if provided // Use override line for sibling computation if provided
const effectiveRow = overrides?.line !== undefined const effectiveRow = overrides?.line !== undefined
? { ...row, line: overrides.line } ? { ...row, line: String(overrides.line) }
: row; : row;
const siblingNames = computeSiblingNames(effectiveRow, allRows); const siblingNames = computeSiblingNames(effectiveRow, allRows);

View File

@@ -5,15 +5,15 @@ const isLocal = window.location.hostname === 'localhost' || window.location.host
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site'); const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
const liveDashboardConfig = { const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth', auth: isDev || useProxy ? '/dashboard-auth' : 'https://acob.acherryontop.com/auth',
aircall: isDev || useProxy ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall', aircall: isDev || useProxy ? '/api/aircall' : 'https://acob.acherryontop.com/api/aircall',
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo', klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://acob.acherryontop.com/api/klaviyo',
meta: isDev || useProxy ? '/api/meta' : 'https://dashboard.kent.pw/api/meta', meta: isDev || useProxy ? '/api/meta' : 'https://acob.acherryontop.com/api/meta',
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias', gorgias: isDev || useProxy ? '/api/gorgias' : 'https://acob.acherryontop.com/api/gorgias',
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics', analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://acob.acherryontop.com/api/analytics',
typeform: isDev || useProxy ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform', typeform: isDev || useProxy ? '/api/typeform' : 'https://acob.acherryontop.com/api/typeform',
acot: isDev || useProxy ? '/api/acot' : 'https://dashboard.kent.pw/api/acot', acot: isDev || useProxy ? '/api/acot' : 'https://acob.acherryontop.com/api/acot',
clarity: isDev || useProxy ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity' clarity: isDev || useProxy ? '/api/clarity' : 'https://acob.acherryontop.com/api/clarity'
}; };
export default liveDashboardConfig; export default liveDashboardConfig;

View File

@@ -29,10 +29,12 @@ export interface CreateProductCategoryResponse {
category?: unknown; category?: unknown;
} }
const DEV_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/product/setup_new"; // Always use relative URLs - proxied by Vite in dev and Caddy in production
const PROD_ENDPOINT = "https://backend.acherryontop.com/apiv2/product/setup_new"; // Frontend calls /apiv2/* -> Caddy transforms to /api/* -> proxies to www.acherryontop.com
const DEV_CREATE_CATEGORY_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/prod_cat/new"; const DEV_ENDPOINT = "/apiv2-test/product/setup_new";
const PROD_CREATE_CATEGORY_ENDPOINT = "https://backend.acherryontop.com/apiv2/prod_cat/new"; const DEV_CREATE_CATEGORY_ENDPOINT = "/apiv2-test/prod_cat/new";
const PROD_ENDPOINT = "/apiv2/product/setup_new";
const PROD_CREATE_CATEGORY_ENDPOINT = "/apiv2/prod_cat/new";
const isHtmlResponse = (payload: string) => { const isHtmlResponse = (payload: string) => {
const trimmed = payload.trim(); const trimmed = payload.trim();
@@ -44,31 +46,38 @@ export async function submitNewProducts({
environment, environment,
useTestDataSource, useTestDataSource,
}: SubmitNewProductsArgs): Promise<SubmitNewProductsResponse> { }: SubmitNewProductsArgs): Promise<SubmitNewProductsResponse> {
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (!authToken) {
throw new Error("VITE_APIV2_AUTH_TOKEN is not configured");
}
const baseUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT; const baseUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
const targetUrl = useTestDataSource ? `${baseUrl}?use_test_data_source=1` : baseUrl; const targetUrl = useTestDataSource ? `${baseUrl}?use_test_data_source=1` : baseUrl;
const payload = new URLSearchParams(); const payload = new URLSearchParams();
payload.append("auth", authToken);
const serializedProducts = JSON.stringify(products); const serializedProducts = JSON.stringify(products);
payload.append("products", serializedProducts); payload.append("products", serializedProducts);
let response: Response; let response: Response;
try { const fetchOptions: RequestInit = {
response = await fetch(targetUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
}, },
body: payload, body: payload,
}); };
// Authentication strategy depends on endpoint
if (environment === "dev") {
// Test endpoint: Use auth token in request body
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
// Prod endpoint: Use cookies (proxied in both dev and production)
fetchOptions.credentials = "include";
}
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) { } catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed"); throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
} }
@@ -115,16 +124,9 @@ export async function createProductCategory({
nameForCustoms, nameForCustoms,
taxCodeId, taxCodeId,
}: CreateProductCategoryArgs): Promise<CreateProductCategoryResponse> { }: CreateProductCategoryArgs): Promise<CreateProductCategoryResponse> {
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (!authToken) {
throw new Error("VITE_APIV2_AUTH_TOKEN is not configured");
}
const targetUrl = environment === "dev" ? DEV_CREATE_CATEGORY_ENDPOINT : PROD_CREATE_CATEGORY_ENDPOINT; const targetUrl = environment === "dev" ? DEV_CREATE_CATEGORY_ENDPOINT : PROD_CREATE_CATEGORY_ENDPOINT;
const payload = new URLSearchParams(); const payload = new URLSearchParams();
payload.append("auth", authToken);
payload.append("master_cat_id", masterCatId.toString()); payload.append("master_cat_id", masterCatId.toString());
payload.append("name", name); payload.append("name", name);
@@ -142,14 +144,29 @@ export async function createProductCategory({
let response: Response; let response: Response;
try { const fetchOptions: RequestInit = {
response = await fetch(targetUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
}, },
body: payload, body: payload,
}); };
// Authentication strategy depends on endpoint
if (environment === "dev") {
// Test endpoint: Use auth token in request body
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
// Prod endpoint: Use cookies (proxied in both dev and production)
fetchOptions.credentials = "include";
}
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) { } catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed"); throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
} }

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { loadEnv } from "vite" import { loadEnv } from "vite"
import fs from 'fs-extra' import fs from 'fs-extra'
import { execSync } from 'child_process'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
@@ -16,7 +17,31 @@ export default defineConfig(({ mode }) => {
name: 'copy-build', name: 'copy-build',
closeBundle: async () => { closeBundle: async () => {
if (!isDev && process.env.COPY_BUILD === 'true') { if (!isDev && process.env.COPY_BUILD === 'true') {
const sourcePath = path.resolve(__dirname, 'build'); const sourcePath = path.resolve(__dirname, 'build/');
// Check if we should use rsync (for remote deployment)
const useRsync = process.env.DEPLOY_TARGET;
if (useRsync) {
// Use rsync over SSH - much faster than sshfs copying
const deployTarget = process.env.DEPLOY_TARGET;
const targetPath = process.env.DEPLOY_PATH || '/var/www/html/inventory/inventory-server/frontend';
try {
console.log(`Deploying to ${deployTarget}:${targetPath}...`);
// Delete remote directory first, then sync
execSync(`ssh ${deployTarget} "rm -rf ${targetPath}"`, { stdio: 'inherit' });
execSync(`ssh ${deployTarget} "mkdir -p ${targetPath}"`, { stdio: 'inherit' });
execSync(`rsync -avz --delete ${sourcePath} ${deployTarget}:${targetPath}/`, { stdio: 'inherit' });
console.log('✓ Build deployed');
} catch (error) {
console.error('Error deploying build files:', error);
process.exit(1);
}
} else {
// Local copy (original behavior)
const targetPath = path.resolve(__dirname, '../inventory-server/frontend/build'); const targetPath = path.resolve(__dirname, '../inventory-server/frontend/build');
try { try {
@@ -31,6 +56,7 @@ export default defineConfig(({ mode }) => {
} }
} }
} }
}
], ],
define: { define: {
'process.env.NODE_ENV': JSON.stringify(mode) 'process.env.NODE_ENV': JSON.stringify(mode)
@@ -44,6 +70,25 @@ export default defineConfig(({ mode }) => {
host: "0.0.0.0", host: "0.0.0.0",
port: 5175, port: 5175,
proxy: { proxy: {
"/api-testv2": {
target: "https://work-test-backend.acherryontop.com",
changeOrigin: true,
secure: true,
cookieDomainRewrite: "localhost",
rewrite: (path) => path.replace(/^\/api-testv2/, "/apiv2"),
},
"/apiv2": {
target: "https://backend.acherryontop.com",
changeOrigin: true,
secure: true,
cookieDomainRewrite: "localhost",
},
"/login": {
target: "https://backend.acherryontop.com",
changeOrigin: true,
secure: true,
cookieDomainRewrite: "localhost",
},
"/api/aircall": { "/api/aircall": {
target: "https://acot.site", target: "https://acot.site",
changeOrigin: true, changeOrigin: true,