Add audit log for product import, add tiff image support, add new/preorder filters on product editor, fix sorting in product editor

This commit is contained in:
2026-03-26 10:46:24 -04:00
parent 76a8836769
commit 9643cf191f
13 changed files with 592 additions and 38 deletions
@@ -192,11 +192,13 @@ export function ProductEditForm({
product,
fieldOptions,
layoutMode,
initialImages,
onClose,
}: {
product: SearchProduct;
fieldOptions: FieldOptions;
layoutMode: LayoutMode;
initialImages?: ProductImage[];
onClose: () => void;
}) {
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
@@ -260,19 +262,24 @@ export function ProductEditForm({
originalValuesRef.current = { ...formValues };
reset(formValues);
// Fetch images and categories with abort support
// Fetch categories (and images if not pre-fetched) with abort support
const controller = new AbortController();
const { signal } = controller;
setIsLoadingImages(true);
axios
.get(`/api/import/product-images/${product.pid}`, { signal })
.then((res) => {
setProductImages(res.data);
originalImagesRef.current = res.data;
})
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
.finally(() => setIsLoadingImages(false));
if (initialImages) {
setProductImages(initialImages);
originalImagesRef.current = initialImages;
} else {
setIsLoadingImages(true);
axios
.get(`/api/import/product-images/${product.pid}`, { signal })
.then((res) => {
setProductImages(res.data);
originalImagesRef.current = res.data;
})
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
.finally(() => setIsLoadingImages(false));
}
axios
.get(`/api/import/product-categories/${product.pid}`, { signal })
@@ -299,6 +306,14 @@ export function ProductEditForm({
return () => controller.abort();
}, [product, reset]);
// Apply batch-fetched images when they arrive after mount
useEffect(() => {
if (initialImages && productImages.length === 0 && !isLoadingImages) {
setProductImages(initialImages);
originalImagesRef.current = initialImages;
}
}, [initialImages]);
// Load lines when company changes (cached across forms)
useEffect(() => {
if (!watchCompany) {
@@ -33,6 +33,8 @@ export interface SearchProduct {
tax_code?: string;
size_cat?: string;
shipping_restrictions?: string;
is_new?: number;
is_preorder?: number;
}
export interface FieldOption {
@@ -20,7 +20,7 @@ export const GenericDropzone = ({
}: GenericDropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
},
onDrop,
multiple: true
@@ -10,7 +10,7 @@ interface ImageDropzoneProps {
export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
},
onDrop: (acceptedFiles) => {
onDrop(acceptedFiles);
+3 -3
View File
@@ -776,7 +776,7 @@ export default function BulkEdit() {
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading new products...
Loading products...
</div>
)}
</TabsContent>
@@ -792,7 +792,7 @@ export default function BulkEdit() {
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading pre-order products...
Loading products...
</div>
)}
</TabsContent>
@@ -801,7 +801,7 @@ export default function BulkEdit() {
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading hidden recently-created products...
Loading products...
</div>
)}
</TabsContent>
+47 -3
View File
@@ -15,6 +15,7 @@ import { Separator } from "@/components/ui/separator";
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
import { createImportAuditLog } from "@/services/importAuditLogApi";
import { AuthContext } from "@/contexts/AuthContext";
import { TemplateForm } from "@/components/templates/TemplateForm";
@@ -521,9 +522,18 @@ export function Import() {
};
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise<boolean> => {
// Hoist for audit logging in catch block
const targetEnvironment = submitOptions?.targetEnvironment ?? "prod";
const useTestDataSource = Boolean(submitOptions?.useTestDataSource);
const targetEndpoint = targetEnvironment === "dev"
? "/apiv2-test/product/setup_new"
: "/apiv2/product/setup_new";
let formattedRows: NormalizedProduct[] = [];
let startTime = performance.now();
try {
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
const formattedRows: NormalizedProduct[] = rows.map((row) => {
formattedRows = rows.map((row) => {
const baseValues = importFields.reduce((acc, field) => {
const rawRow = row as Record<string, DataValue>;
const fieldKey = field.key as ImportFieldKey;
@@ -582,12 +592,14 @@ export function Import() {
return true;
}
startTime = performance.now();
const response = await submitNewProducts({
products: formattedRows,
environment: submitOptions?.targetEnvironment ?? "prod",
useTestDataSource: Boolean(submitOptions?.useTestDataSource),
environment: targetEnvironment,
useTestDataSource,
employeeId: user?.id ?? undefined,
});
const durationMs = Math.round(performance.now() - startTime);
const isSuccess = response.success;
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
@@ -620,6 +632,24 @@ export function Import() {
};
}
// Audit log — fire-and-forget, never blocks the UI
const auditPayload = extractBackendPayload(normalizedResponse.data);
createImportAuditLog({
user_id: user?.id ?? 0,
username: user?.username,
product_count: formattedRows.length,
request_payload: formattedRows,
environment: targetEnvironment,
target_endpoint: targetEndpoint,
use_test_data_source: useTestDataSource,
success: isSuccess,
response_payload: normalizedResponse,
error_message: isSuccess ? undefined : (resolvedFailureMessage ?? defaultFailureMessage),
created_count: auditPayload.created.length,
errored_count: auditPayload.errored.length,
duration_ms: durationMs,
});
setResumeStepState(undefined);
setImportOutcome({
submittedProducts: formattedRows.map((product) => ({ ...product })),
@@ -641,6 +671,20 @@ export function Import() {
return isSuccess;
} catch (error) {
// Audit log for thrown errors (network failures, parse errors, etc.)
createImportAuditLog({
user_id: user?.id ?? 0,
username: user?.username,
product_count: formattedRows.length,
request_payload: formattedRows,
environment: targetEnvironment,
target_endpoint: targetEndpoint,
use_test_data_source: useTestDataSource,
success: false,
error_message: error instanceof Error ? error.message : "Unknown error",
duration_ms: Math.round(performance.now() - startTime),
});
console.error("Import error:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to import data. Please try again.";
+126 -8
View File
@@ -8,6 +8,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
@@ -20,7 +22,7 @@ import {
import { ProductSearch } from "@/components/product-editor/ProductSearch";
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra, ProductImage } from "@/components/product-editor/types";
import { ExternalLink } from "lucide-react";
const PER_PAGE = 20;
@@ -169,16 +171,56 @@ export default function ProductEditor() {
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
const [viewingFeaturedExtra, setViewingFeaturedExtra] = useState<LandingExtra | null>(null);
const [newFeedOnly, setNewFeedOnly] = useState(false);
const [preorderFeedOnly, setPreorderFeedOnly] = useState(false);
const [lineNewOnly, setLineNewOnly] = useState(false);
const [linePreorderOnly, setLinePreorderOnly] = useState(false);
// Abort controller for cancelling in-flight product requests
const abortRef = useRef<AbortController | null>(null);
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
const filteredProducts = useMemo(() => {
if (viewingFeaturedExtra && activeTab === "new" && newFeedOnly) {
return allProducts.filter((p) => p.is_new);
}
if (viewingFeaturedExtra && activeTab === "preorder" && preorderFeedOnly) {
return allProducts.filter((p) => p.is_preorder);
}
if (activeTab === "by-line" && (lineNewOnly || linePreorderOnly)) {
return allProducts.filter((p) =>
(lineNewOnly && p.is_new) || (linePreorderOnly && p.is_preorder)
);
}
return allProducts;
}, [allProducts, viewingFeaturedExtra, activeTab, newFeedOnly, preorderFeedOnly, lineNewOnly, linePreorderOnly]);
const totalPages = Math.ceil(filteredProducts.length / PER_PAGE);
const products = useMemo(
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
[allProducts, page]
() => filteredProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
[filteredProducts, page]
);
// Batch-fetch images for the current page of products
const [batchImages, setBatchImages] = useState<Record<number, ProductImage[]>>({});
useEffect(() => {
if (products.length === 0) return;
const pids = products.map((p) => p.pid);
const controller = new AbortController();
axios
.get("/api/import/product-images-batch", {
params: { pids: pids.join(",") },
signal: controller.signal,
})
.then((res) => {
setBatchImages((prev) => ({ ...prev, ...res.data }));
})
.catch((e) => {
if (!axios.isCancel(e)) console.error("Failed to batch-load images", e);
});
return () => controller.abort();
}, [products]);
useEffect(() => {
axios
.get("/api/import/field-options")
@@ -308,6 +350,9 @@ export default function ProductEditor() {
const controller = new AbortController();
abortRef.current = controller;
setActiveLandingItem(extra.path);
setViewingFeaturedExtra(extra);
setNewFeedOnly(false);
setPreorderFeedOnly(false);
setAllProducts([]);
setIsLoadingProducts(true);
try {
@@ -331,6 +376,11 @@ export default function ProductEditor() {
setActiveTab(tab);
setQueryStatus(null);
setQueryId("");
setViewingFeaturedExtra(null);
setNewFeedOnly(false);
setPreorderFeedOnly(false);
setLineNewOnly(false);
setLinePreorderOnly(false);
if (tab === "new" && loadedTab !== "new") {
setLoadedTab("new");
loadFeedProducts("new-products", "new");
@@ -356,6 +406,8 @@ export default function ProductEditor() {
abortRef.current = controller;
setAllProducts([]);
setIsLoadingProducts(true);
setLineNewOnly(false);
setLinePreorderOnly(false);
try {
const params: Record<string, string> = { company: lineCompany, line: lineLine };
if (lineSubline) params.subline = lineSubline;
@@ -634,10 +686,26 @@ export default function ProductEditor() {
</div>
)}
{renderLandingExtras("new")}
{viewingFeaturedExtra && activeTab === "new" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_new) && (
<div className="flex items-center space-x-2 mb-3">
<Switch
id="new-only"
checked={newFeedOnly}
onCheckedChange={(checked) => {
setNewFeedOnly(checked);
setPage(1);
}}
/>
<Label htmlFor="new-only" className="text-sm text-muted-foreground">Show only new products</Label>
{newFeedOnly && (
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
)}
</div>
)}
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading new products...
Loading products...
</div>
)}
</TabsContent>
@@ -650,10 +718,26 @@ export default function ProductEditor() {
</div>
)}
{renderLandingExtras("preorder")}
{viewingFeaturedExtra && activeTab === "preorder" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_preorder) && (
<div className="flex items-center space-x-2 mb-3">
<Switch
id="preorder-only"
checked={preorderFeedOnly}
onCheckedChange={(checked) => {
setPreorderFeedOnly(checked);
setPage(1);
}}
/>
<Label htmlFor="preorder-only" className="text-sm text-muted-foreground">Show only pre-order products</Label>
{preorderFeedOnly && (
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
)}
</div>
)}
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading pre-order products...
Loading products...
</div>
)}
</TabsContent>
@@ -662,7 +746,7 @@ export default function ProductEditor() {
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading hidden recently-created products...
Loading products...
</div>
)}
</TabsContent>
@@ -709,7 +793,40 @@ export default function ProductEditor() {
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
<Loader2 className="h-4 w-4 animate-spin" />
Loading line products...
Loading products...
</div>
)}
{!isLoadingProducts && allProducts.length > 0 && activeTab === "by-line" && (allProducts.some((p) => p.is_new && !p.is_preorder) || allProducts.some((p) => p.is_preorder)) && (
<div className="flex items-center gap-4 mt-3">
{!allProducts.every((p) => p.is_new) && allProducts.some((p) => p.is_new) && (
<div className="flex items-center space-x-2">
<Switch
id="line-new-only"
checked={lineNewOnly}
onCheckedChange={(checked) => {
setLineNewOnly(checked);
setPage(1);
}}
/>
<Label htmlFor="line-new-only" className="text-sm text-muted-foreground">Show only new products</Label>
</div>
)}
{!allProducts.every((p) => p.is_preorder) && allProducts.some((p) => p.is_preorder) && (
<div className="flex items-center space-x-2">
<Switch
id="line-preorder-only"
checked={linePreorderOnly}
onCheckedChange={(checked) => {
setLinePreorderOnly(checked);
setPage(1);
}}
/>
<Label htmlFor="line-preorder-only" className="text-sm text-muted-foreground">Show only pre-order</Label>
</div>
)}
{(lineNewOnly || linePreorderOnly) && (
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
)}
</div>
)}
</TabsContent>
@@ -725,6 +842,7 @@ export default function ProductEditor() {
product={product}
fieldOptions={fieldOptions}
layoutMode={layoutMode}
initialImages={batchImages[product.pid]}
onClose={() => handleRemoveProduct(product.pid)}
/>
))}
@@ -0,0 +1,43 @@
/**
* Import Audit Log API Service
*
* Logs every product import submission to a permanent audit trail.
* Fire-and-forget by default — callers should not block on the result.
*/
const BASE_URL = '/api/import-audit-log';
export interface ImportAuditLogEntry {
user_id: number;
username?: string;
product_count: number;
request_payload: unknown;
environment: 'dev' | 'prod';
target_endpoint?: string;
use_test_data_source?: boolean;
success: boolean;
response_payload?: unknown;
error_message?: string;
created_count?: number;
errored_count?: number;
session_id?: number | null;
duration_ms?: number | null;
}
/**
* Send an audit log entry to the backend.
* Designed to be fire-and-forget — errors are logged but never thrown
* so that a logging failure never blocks the user's import flow.
*/
export async function createImportAuditLog(entry: ImportAuditLogEntry): Promise<void> {
try {
await fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry),
});
} catch (error) {
// Never throw — audit logging should not disrupt the import flow
console.error('Failed to write import audit log:', error);
}
}
File diff suppressed because one or more lines are too long