From 23b94d1c4842959d3aa00c06e2c465c0f8da3d28 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Mar 2026 11:42:32 -0400 Subject: [PATCH] Add product editor audit log, fix bug that would overwrite editor fields if edited too soon after load, add audit log ui --- .../004_create_product_editor_audit_log.sql | 54 ++ .../src/routes/product-editor-audit-log.js | 193 +++++ inventory-server/src/server.js | 2 + .../product-editor/ProductEditForm.tsx | 88 ++- .../src/components/settings/AuditLog.tsx | 675 ++++++++++++++++++ inventory/src/pages/Settings.tsx | 21 +- .../src/services/productEditorAuditLog.ts | 39 + inventory/tsconfig.tsbuildinfo | 2 +- 8 files changed, 1054 insertions(+), 20 deletions(-) create mode 100644 inventory-server/migrations/004_create_product_editor_audit_log.sql create mode 100644 inventory-server/src/routes/product-editor-audit-log.js create mode 100644 inventory/src/components/settings/AuditLog.tsx create mode 100644 inventory/src/services/productEditorAuditLog.ts diff --git a/inventory-server/migrations/004_create_product_editor_audit_log.sql b/inventory-server/migrations/004_create_product_editor_audit_log.sql new file mode 100644 index 0000000..6f201ed --- /dev/null +++ b/inventory-server/migrations/004_create_product_editor_audit_log.sql @@ -0,0 +1,54 @@ +-- Migration: Create product_editor_audit_log table +-- Permanent audit trail of all product editor API submissions +-- Run this against your PostgreSQL database + +CREATE TABLE IF NOT EXISTS product_editor_audit_log ( + id SERIAL PRIMARY KEY, + + -- Who made the edit + user_id INTEGER NOT NULL, + username VARCHAR(255), + + -- Which product + pid INTEGER NOT NULL, + + -- What was submitted + action VARCHAR(50) NOT NULL, -- 'product_edit', 'image_changes', 'taxonomy_set' + request_payload JSONB NOT NULL, -- The exact payload sent to the external API + target_endpoint VARCHAR(255), -- The API URL that was called + + -- What came back + success BOOLEAN NOT NULL, + response_payload JSONB, -- Full API response + error_message TEXT, -- Extracted error message on failure + + -- Metadata + duration_ms INTEGER, -- How long the API call took + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for looking up edits by product +CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid + ON product_editor_audit_log (pid); + +-- Index for looking up edits by user +CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_id + ON product_editor_audit_log (user_id); + +-- Index for time-based queries +CREATE INDEX IF NOT EXISTS idx_pe_audit_log_created_at + ON product_editor_audit_log (created_at DESC); + +-- Composite index for product + time queries +CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid_created + ON product_editor_audit_log (pid, created_at DESC); + +-- Composite index for user + time queries +CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_created + ON product_editor_audit_log (user_id, created_at DESC); + +COMMENT ON TABLE product_editor_audit_log IS 'Permanent audit log of all product editor API submissions'; +COMMENT ON COLUMN product_editor_audit_log.action IS 'Type of edit: product_edit, image_changes, or taxonomy_set'; +COMMENT ON COLUMN product_editor_audit_log.request_payload IS 'Exact payload sent to the external API'; +COMMENT ON COLUMN product_editor_audit_log.response_payload IS 'Full response received from the external API'; +COMMENT ON COLUMN product_editor_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds'; diff --git a/inventory-server/src/routes/product-editor-audit-log.js b/inventory-server/src/routes/product-editor-audit-log.js new file mode 100644 index 0000000..8473c8a --- /dev/null +++ b/inventory-server/src/routes/product-editor-audit-log.js @@ -0,0 +1,193 @@ +const express = require('express'); +const router = express.Router(); + +// Create a new audit log entry +router.post('/', async (req, res) => { + try { + const { + user_id, + username, + pid, + action, + request_payload, + target_endpoint, + success, + response_payload, + error_message, + duration_ms, + } = req.body; + + // Validate required fields + if (!user_id) { + return res.status(400).json({ error: 'user_id is required' }); + } + if (!pid) { + return res.status(400).json({ error: 'pid is required' }); + } + if (!action) { + return res.status(400).json({ error: 'action is required' }); + } + if (!request_payload) { + return res.status(400).json({ error: 'request_payload is required' }); + } + if (typeof success !== 'boolean') { + return res.status(400).json({ error: 'success (boolean) is required' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + INSERT INTO product_editor_audit_log ( + user_id, + username, + pid, + action, + request_payload, + target_endpoint, + success, + response_payload, + error_message, + duration_ms + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, created_at + `, [ + user_id, + username || null, + pid, + action, + JSON.stringify(request_payload), + target_endpoint || null, + success, + response_payload ? JSON.stringify(response_payload) : null, + error_message || null, + duration_ms || null, + ]); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating product editor audit log:', error); + res.status(500).json({ + error: 'Failed to create product editor audit log', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// List audit log entries (with pagination) +router.get('/', async (req, res) => { + try { + const { user_id, pid, action, limit = 50, offset = 0, success: successFilter } = req.query; + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const conditions = []; + const params = []; + let paramIndex = 1; + + if (user_id) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(user_id); + } + + if (pid) { + conditions.push(`pid = $${paramIndex++}`); + params.push(pid); + } + + if (action) { + conditions.push(`action = $${paramIndex++}`); + params.push(action); + } + + if (successFilter !== undefined) { + conditions.push(`success = $${paramIndex++}`); + params.push(successFilter === 'true'); + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + // Get total count + const countResult = await pool.query( + `SELECT COUNT(*) FROM product_editor_audit_log ${whereClause}`, + params + ); + + // Get paginated results (exclude large payload columns in list view) + const dataParams = [...params, parseInt(limit, 10), parseInt(offset, 10)]; + const result = await pool.query(` + SELECT + id, + user_id, + username, + pid, + action, + target_endpoint, + success, + error_message, + duration_ms, + created_at + FROM product_editor_audit_log + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `, dataParams); + + res.json({ + total: parseInt(countResult.rows[0].count, 10), + entries: result.rows, + }); + } catch (error) { + console.error('Error fetching product editor audit log:', error); + res.status(500).json({ + error: 'Failed to fetch product editor audit log', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get a single audit log entry (with full payloads) +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query( + 'SELECT * FROM product_editor_audit_log WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Audit log entry not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching product editor audit log entry:', error); + res.status(500).json({ + error: 'Failed to fetch audit log entry', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Product editor audit log route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index 10f5d95..933a91e 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -25,6 +25,7 @@ const brandsAggregateRouter = require('./routes/brandsAggregate'); const htsLookupRouter = require('./routes/hts-lookup'); const importSessionsRouter = require('./routes/import-sessions'); const importAuditLogRouter = require('./routes/import-audit-log'); +const productEditorAuditLogRouter = require('./routes/product-editor-audit-log'); const newsletterRouter = require('./routes/newsletter'); // Get the absolute path to the .env file @@ -135,6 +136,7 @@ async function startServer() { app.use('/api/hts-lookup', htsLookupRouter); app.use('/api/import-sessions', importSessionsRouter); app.use('/api/import-audit-log', importAuditLogRouter); + app.use('/api/product-editor-audit-log', productEditorAuditLogRouter); app.use('/api/newsletter', newsletterRouter); // Basic health check route diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx index e3a54f9..1578630 100644 --- a/inventory/src/components/product-editor/ProductEditForm.tsx +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useContext } from "react"; import axios from "axios"; import { useForm, Controller } from "react-hook-form"; import { toast } from "sonner"; @@ -13,6 +13,8 @@ import { useInlineAiValidation } from "@/components/product-import/steps/Validat import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge"; import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare"; import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor"; +import { createProductEditorAuditLog } from "@/services/productEditorAuditLog"; +import { AuthContext } from "@/contexts/AuthContext"; import { EditableComboboxField } from "./EditableComboboxField"; import { EditableInput } from "./EditableInput"; import { EditableMultiSelect } from "./EditableMultiSelect"; @@ -201,6 +203,7 @@ export function ProductEditForm({ initialImages?: ProductImage[]; onClose: () => void; }) { + const { user } = useContext(AuthContext); const [lineOptions, setLineOptions] = useState([]); const [sublineOptions, setSublineOptions] = useState([]); const [productImages, setProductImages] = useState([]); @@ -291,13 +294,12 @@ export function ProductEditForm({ if (t >= 10 && t <= 13) cats.push(String(item.value)); else if (t >= 20 && t <= 21) themes.push(String(item.value)); } - const updatedValues = { - ...formValues, - categories: cats, - themes, - }; - originalValuesRef.current = { ...updatedValues }; - reset(updatedValues); + if (originalValuesRef.current) { + originalValuesRef.current.categories = cats; + originalValuesRef.current.themes = themes; + } + setValue("categories", cats); + setValue("themes", themes); }) .catch(() => { // Non-critical — just leave arrays empty @@ -390,22 +392,28 @@ export function ProductEditForm({ const imageChanges = computeImageChanges(); // Extract taxonomy changes for separate API calls - const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[] }[] = []; + const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[]; previousIds: number[] }[] = []; if ("categories" in changes) { - taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number) }); + taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number), previousIds: (original.categories as string[] ?? []).map(Number) }); delete changes.categories; } if ("themes" in changes) { - taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number) }); + taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number), previousIds: (original.themes as string[] ?? []).map(Number) }); delete changes.themes; } if ("colors" in changes) { - taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number) }); + taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number), previousIds: (original.colors as string[] ?? []).map(Number) }); delete changes.colors; } const hasFieldChanges = Object.keys(changes).length > 0; + // Build previous values for changed fields (for audit trail) + const previousValues: Record = {}; + for (const key of Object.keys(changes)) { + previousValues[key] = original[key as keyof ProductFormValues]; + } + if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) { toast.info("No changes to submit"); return; @@ -413,16 +421,62 @@ export function ProductEditForm({ setIsSubmitting(true); try { + // Helper: call an API function, time it, fire audit log + const audited = async ( + action: 'product_edit' | 'image_changes' | 'taxonomy_set', + targetEndpoint: string, + requestPayload: unknown, + fn: () => Promise, + ): Promise => { + const start = Date.now(); + try { + const result = await fn(); + createProductEditorAuditLog({ + user_id: user?.id ?? 0, + username: user?.username, + pid: product.pid, + action, + request_payload: requestPayload, + target_endpoint: targetEndpoint, + success: result.success, + response_payload: result, + error_message: result.success ? undefined : ( + typeof result.error === 'string' ? result.error + : Array.isArray(result.error) ? result.error.join('; ') + : result.message + ), + duration_ms: Date.now() - start, + }); + return result; + } catch (err) { + createProductEditorAuditLog({ + user_id: user?.id ?? 0, + username: user?.username, + pid: product.pid, + action, + request_payload: requestPayload, + target_endpoint: targetEndpoint, + success: false, + error_message: err instanceof Error ? err.message : 'Unknown error', + duration_ms: Date.now() - start, + }); + throw err; + } + }; + const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = []; if (hasFieldChanges) { - promises.push(submitProductEdit({ pid: product.pid, changes, environment: "prod" })); + promises.push(audited('product_edit', '/apiv2/product/edit', { pid: product.pid, changes, previous_values: previousValues }, + () => submitProductEdit({ pid: product.pid, changes, environment: "prod" }))); } if (imageChanges) { - promises.push(submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" })); + promises.push(audited('image_changes', '/apiv2/product/image_changes', { pid: product.pid, imageChanges }, + () => submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" }))); } - for (const { type, ids } of taxonomyCalls) { - promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" })); + for (const { type, ids, previousIds } of taxonomyCalls) { + promises.push(audited('taxonomy_set', `/apiv2/product/${type}/${product.pid}/set`, { pid: product.pid, type, ids, previous_ids: previousIds }, + () => submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }))); } const results = await Promise.all(promises); @@ -449,7 +503,7 @@ export function ProductEditForm({ setIsSubmitting(false); } }, - [product.pid, reset, computeImageChanges, productImages] + [product.pid, reset, computeImageChanges, productImages, user] ); // Resolve options for a field config diff --git a/inventory/src/components/settings/AuditLog.tsx b/inventory/src/components/settings/AuditLog.tsx new file mode 100644 index 0000000..dc56405 --- /dev/null +++ b/inventory/src/components/settings/AuditLog.tsx @@ -0,0 +1,675 @@ +import { useState, useEffect, useCallback } from "react"; +import axios from "axios"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Loader2, ChevronLeft, ChevronRight, Eye, Search, ChevronDown, ChevronRight as ChevronRightIcon, Copy, Check } from "lucide-react"; + +interface AuditEntry { + id: number; + user_id: number; + username: string | null; + success: boolean; + error_message: string | null; + duration_ms: number | null; + created_at: string; + target_endpoint: string | null; + // Import-specific + product_count?: number; + environment?: string; + created_count?: number; + errored_count?: number; + // Editor-specific + pid?: number; + action?: string; +} + +interface AuditDetail extends AuditEntry { + request_payload: unknown; + response_payload: unknown; + // Import-specific + use_test_data_source?: boolean; + session_id?: number | null; +} + +type LogType = "editor" | "import"; + +const PAGE_SIZE = 25; + +const ACTION_LABELS: Record = { + product_edit: "Product Edit", + image_changes: "Image Changes", + taxonomy_set: "Taxonomy Set", +}; + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function formatDuration(ms: number | null): string { + if (ms === null) return "-"; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function AuditLog() { + const [logType, setLogType] = useState("editor"); + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [isLoadingDetail, setIsLoadingDetail] = useState(false); + + // Filters + const [filterPid, setFilterPid] = useState(""); + const [filterSuccess, setFilterSuccess] = useState("all"); + const [filterAction, setFilterAction] = useState("all"); + + const baseUrl = logType === "editor" ? "/api/product-editor-audit-log" : "/api/import-audit-log"; + + const fetchEntries = useCallback(async () => { + setIsLoading(true); + try { + const params: Record = { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }; + if (filterSuccess !== "all") params.success = filterSuccess; + if (logType === "editor") { + if (filterPid.trim()) params.pid = filterPid.trim(); + if (filterAction !== "all") params.action = filterAction; + } + const res = await axios.get(baseUrl, { params }); + setEntries(res.data.entries); + setTotal(res.data.total); + } catch { + setEntries([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, [baseUrl, page, filterSuccess, filterPid, filterAction, logType]); + + useEffect(() => { + fetchEntries(); + }, [fetchEntries]); + + // Reset page when filters or log type change + useEffect(() => { + setPage(0); + }, [filterSuccess, filterPid, filterAction, logType]); + + const handleTabChange = (val: string) => { + setLogType(val as LogType); + setFilterPid(""); + setFilterSuccess("all"); + setFilterAction("all"); + setEntries([]); + setTotal(0); + setDetail(null); + }; + + const viewDetail = async (id: number) => { + setIsLoadingDetail(true); + try { + const res = await axios.get(`${baseUrl}/${id}`); + setDetail(res.data); + } catch { + setDetail(null); + } finally { + setIsLoadingDetail(false); + } + }; + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+
+

Audit Log

+

+ View API call history for product imports and editor changes. +

+
+ + + + Product Editor + Product Import + + + + + + + {}} + filterSuccess={filterSuccess} + onFilterSuccessChange={setFilterSuccess} + filterAction="" + onFilterActionChange={() => {}} + showPidFilter={false} + showActionFilter={false} + /> + + + + + +
+ + {total} {total === 1 ? "entry" : "entries"} + +
+ + + {totalPages > 0 ? `${page + 1} / ${totalPages}` : "0 / 0"} + + +
+
+
+ + {isLoading ? ( +
+ +
+ ) : entries.length === 0 ? ( +

No entries found.

+ ) : logType === "editor" ? ( + + ) : ( + + )} +
+
+ + { if (!open) setDetail(null); }}> + + {isLoadingDetail ? ( +
+ +
+ ) : detail ? ( + <> + + + Entry #{detail.id} + + + + + + ) : null} +
+
+
+ ); +} + +// --- Sub-components --- + +function Filters({ + filterPid, + onFilterPidChange, + filterSuccess, + onFilterSuccessChange, + filterAction, + onFilterActionChange, + showPidFilter, + showActionFilter, +}: { + filterPid: string; + onFilterPidChange: (v: string) => void; + filterSuccess: string; + onFilterSuccessChange: (v: string) => void; + filterAction: string; + onFilterActionChange: (v: string) => void; + showPidFilter: boolean; + showActionFilter: boolean; +}) { + return ( +
+ {showPidFilter && ( +
+ + onFilterPidChange(e.target.value)} + className="pl-8 w-40" + /> +
+ )} + + {showActionFilter && ( + + )} +
+ ); +} + +function SuccessBadge({ success }: { success: boolean }) { + return success ? ( + + Success + + ) : ( + + Failed + + ); +} + +function EditorTable({ entries, onView }: { entries: AuditEntry[]; onView: (id: number) => void }) { + return ( + + + + Time + User + PID + Action + Status + Duration + + + + + {entries.map((e) => ( + + {formatDate(e.created_at)} + {e.username ?? `#${e.user_id}`} + {e.pid} + + + {ACTION_LABELS[e.action ?? ""] ?? e.action} + + + + {formatDuration(e.duration_ms)} + + + + + ))} + +
+ ); +} + +function ImportTable({ entries, onView }: { entries: AuditEntry[]; onView: (id: number) => void }) { + return ( + + + + Time + User + Products + Env + Status + Created / Errored + Duration + + + + + {entries.map((e) => ( + + {formatDate(e.created_at)} + {e.username ?? `#${e.user_id}`} + {e.product_count} + + + {e.environment} + + + + + {e.created_count ?? 0} / {e.errored_count ?? 0} + + {formatDuration(e.duration_ms)} + + + + + ))} + +
+ ); +} + +function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType }) { + return ( +
+
+
+ User +

{detail.username ?? `#${detail.user_id}`}

+
+
+ Time +

{new Date(detail.created_at).toLocaleString()}

+
+ {logType === "editor" && ( + <> +
+ Product ID +

{detail.pid}

+
+
+ Action +

{ACTION_LABELS[detail.action ?? ""] ?? detail.action}

+
+ + )} + {logType === "import" && ( + <> +
+ Product Count +

{detail.product_count}

+
+
+ Environment +

{detail.environment}

+
+
+ Created / Errored +

{detail.created_count ?? 0} / {detail.errored_count ?? 0}

+
+ + )} +
+ Endpoint +

{detail.target_endpoint ?? "-"}

+
+
+ Duration +

{formatDuration(detail.duration_ms)}

+
+
+ + {detail.error_message && ( +
+ Error +

+ {detail.error_message} +

+
+ )} + + + + {detail.response_payload != null && ( + + )} +
+ ); +} + +// --- Formatted JSON viewer --- + +/** Unwrap double-encoded JSON strings from JSONB columns */ +function parsePayload(raw: unknown): unknown { + if (typeof raw === "string") { + try { return JSON.parse(raw); } catch { return raw; } + } + return raw; +} + +function PayloadSection({ label, data: rawData }: { label: string; data: unknown }) { + const data = parsePayload(rawData); + const [showRaw, setShowRaw] = useState(false); + const [copied, setCopied] = useState(false); + + const copyJson = () => { + navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+ {label} +
+ + +
+
+ {showRaw ? ( +
+          {JSON.stringify(data, null, 2)}
+        
+ ) : ( +
+ +
+ )} +
+ ); +} + +function JsonValue({ value, depth }: { value: unknown; depth: number }) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "boolean") { + return {String(value)}; + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "string") { + return ; + } + + if (Array.isArray(value)) { + return ; + } + + if (typeof value === "object") { + return } depth={depth} />; + } + + return {String(value)}; +} + +const STRING_TRUNCATE_LIMIT = 1000; + +function JsonString({ value }: { value: string }) { + const [expanded, setExpanded] = useState(false); + + if (value.length <= STRING_TRUNCATE_LIMIT || expanded) { + return ( + + {value} + {expanded && value.length > STRING_TRUNCATE_LIMIT && ( + + )} + + ); + } + + return ( + + {value.slice(0, STRING_TRUNCATE_LIMIT)} + ... + + + ); +} + +function JsonObject({ obj, depth }: { obj: Record; depth: number }) { + const entries = Object.entries(obj); + + if (entries.length === 0) { + return {"{}"}; + } + + // For shallow objects with only primitive values, show inline + const allPrimitive = entries.every(([, v]) => v === null || typeof v !== "object"); + if (allPrimitive && entries.length <= 4) { + return ( + + {"{ "} + {entries.map(([k, v], i) => ( + + {k} + : + + {i < entries.length - 1 && , } + + ))} + {" }"} + + ); + } + + return ( +
+ {entries.map(([key, val]) => ( + + ))} +
+ ); +} + +/** Renders a single key-value pair. Nested objects/arrays use the key as a collapsible label. */ +function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknown; depth: number; expanded?: boolean }) { + const [open, setOpen] = useState(depth < 1); + const isNestedObj = value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length > 0; + const isNestedArr = Array.isArray(value) && value.length > 0 && !value.every((v) => v === null || typeof v !== "object"); + + if (isNestedObj || isNestedArr) { + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); + } + + // Primitive or small array — render inline + return ( +
+ {fieldKey} + : + +
+ ); +} + +function JsonArray({ items, depth }: { items: unknown[]; depth: number }) { + if (items.length === 0) { + return []; + } + + // Small arrays of primitives — show inline + const allPrimitive = items.every((v) => v === null || typeof v !== "object"); + if (allPrimitive && items.length <= 10) { + return ( + + {"["} + {items.map((v, i) => ( + + + {i < items.length - 1 && , } + + ))} + {"]"} + + ); + } + + return ( +
+ {items.map((item, i) => ( +
+ [{i}] + +
+ ))} +
+ ); +} diff --git a/inventory/src/pages/Settings.tsx b/inventory/src/pages/Settings.tsx index e275130..102d595 100644 --- a/inventory/src/pages/Settings.tsx +++ b/inventory/src/pages/Settings.tsx @@ -7,6 +7,7 @@ import { TemplateManagement } from "@/components/settings/TemplateManagement"; import { UserManagement } from "@/components/settings/UserManagement"; import { PromptManagement } from "@/components/settings/PromptManagement"; import { ReusableImageManagement } from "@/components/settings/ReusableImageManagement"; +import { AuditLog } from "@/components/settings/AuditLog"; import { motion } from 'framer-motion'; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Protected } from "@/components/auth/Protected"; @@ -53,6 +54,7 @@ const SETTINGS_GROUPS: SettingsGroup[] = [ tabs: [ { id: "user-management", permission: "settings:user_management", label: "User Management" }, { id: "data-management", permission: "settings:data_management", label: "Data Management" }, + { id: "audit-log", permission: "settings:audit_log", label: "Audit Log" }, ] } ]; @@ -251,8 +253,8 @@ export function Settings() { - @@ -264,6 +266,21 @@ export function Settings() { + + + + + You don't have permission to access the Audit Log. + + + } + > + + + diff --git a/inventory/src/services/productEditorAuditLog.ts b/inventory/src/services/productEditorAuditLog.ts new file mode 100644 index 0000000..22ce458 --- /dev/null +++ b/inventory/src/services/productEditorAuditLog.ts @@ -0,0 +1,39 @@ +/** + * Product Editor Audit Log API Service + * + * Logs every product editor submission to a permanent audit trail. + * Fire-and-forget by default — callers should not block on the result. + */ + +const BASE_URL = '/api/product-editor-audit-log'; + +export interface ProductEditorAuditLogEntry { + user_id: number; + username?: string; + pid: number; + action: 'product_edit' | 'image_changes' | 'taxonomy_set'; + request_payload: unknown; + target_endpoint?: string; + success: boolean; + response_payload?: unknown; + error_message?: string; + 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 editing flow. + */ +export async function createProductEditorAuditLog(entry: ProductEditorAuditLogEntry): Promise { + 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 editor flow + console.error('Failed to write product editor audit log:', error); + } +} diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 9c35d49..c6c46b8 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file