AI tweaks/fixes + backend api interface updates
This commit is contained in:
@@ -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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user