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:
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user