diff --git a/PRODUCT_IMPORT_ENHANCEMENTS.md b/PRODUCT_IMPORT_ENHANCEMENTS.md new file mode 100644 index 0000000..32eaff7 --- /dev/null +++ b/PRODUCT_IMPORT_ENHANCEMENTS.md @@ -0,0 +1,375 @@ +# Product Import Module - Enhancement & Issues Outline + +This document outlines the investigation and implementation requirements for each requested enhancement to the product import module. + +--- + +## 1. UPC Import - Strip Quotes and Spaces ✅ IMPLEMENTED + +**Issue:** When importing UPCs, strip `'`, `"` characters and any spaces, leaving only numbers. + +**Implementation (Completed):** +- Modified `normalizeUpcValue()` in [Import.tsx:661-667](inventory/src/pages/Import.tsx#L661-L667) +- Strips single quotes, double quotes, smart quotes (`'"`), and whitespace before processing +- Then handles scientific notation and extracts only digits + +**Files Modified:** +- `inventory/src/pages/Import.tsx` - `normalizeUpcValue()` function + +--- + +## 2. AI Context Columns in Validation Payloads ✅ IMPLEMENTED + +**Issue:** The match columns step has a setting to use a field only for AI context (`isAiSupplemental`). Update AI description validation to include any columns selected with this option in the payload. Also include in sanity check payload. Not needed for names. + +**Current Implementation:** +- AI Supplemental toggle: [MatchColumnsStep.tsx:102-118](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L102-L118) +- AI supplemental data stored in `__aiSupplemental` field on each row +- Description payload builder: [inlineAiPayload.ts:183-195](inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts#L183-L195) + +**Implementation:** +1. **Update `buildDescriptionValidationPayload()` in `inlineAiPayload.ts`** to include AI supplemental data: + ```typescript + export const buildDescriptionValidationPayload = ( + row: Data, + fieldOptions: FieldOptionsMap, + productLinesCache: Map, + sublinesCache: Map + ) => { + const payload: Record = { + name: row.name, + description: row.description, + company_name: getFieldOptionLabel(row.company, fieldOptions, 'company'), + company_id: row.company, + categories: getFieldOptionLabel(row.category, fieldOptions, 'category'), + }; + + // Add AI supplemental context if present + if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') { + payload.additional_context = row.__aiSupplemental; + } + + return payload; + }; + ``` + +2. **Update sanity check payload** - Locate sanity check submission logic and include `__aiSupplemental` data + +3. **Verify `__aiSupplemental` is properly populated** from MatchColumnsStep when columns are marked as AI context only + +**Files to Modify:** +- `inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts` +- Backend sanity check endpoint (if separate from description validation) +- Verify data flow in `MatchColumnsStep.tsx` → `ValidationStep` + +--- + +## 3. Fresh Taxonomy Data Per Session ✅ IMPLEMENTED + +**Issue:** Ensure taxonomy data is brought in fresh with each session - cache should be invalidated if we exit the import flow and start again. + +**Current Implementation:** +- Field options cached 5 minutes: [ValidationStep/index.tsx:128-133](inventory/src/components/product-import/steps/ValidationStep/index.tsx#L128-L133) +- Product lines cache: `productLinesCache` in Zustand store +- Sublines cache: `sublinesCache` in Zustand store +- Caches set to 10-minute stale time + +**Implementation:** +1. **Add cache invalidation on import flow mount/unmount** in `UploadFlow.tsx`: + ```typescript + useEffect(() => { + // On mount - invalidate import-related query cache + queryClient.invalidateQueries({ queryKey: ['import-field-options'] }); + + return () => { + // On unmount - clear caches + queryClient.removeQueries({ queryKey: ['import-field-options'] }); + queryClient.removeQueries({ queryKey: ['product-lines'] }); + queryClient.removeQueries({ queryKey: ['sublines'] }); + }; + }, []); + ``` + +2. **Clear Zustand store caches** when exiting import flow: + - Add action to `validationStore.ts` to clear `productLinesCache` and `sublinesCache` + - Call this action on unmount of `UploadFlow` or when navigating away + +3. **Consider adding a `sessionId`** that changes on each import flow start, used as part of cache keys + +**Files to Modify:** +- `inventory/src/components/product-import/steps/UploadFlow.tsx` - Add cleanup effect +- `inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts` - Add cache clear action +- Potentially `inventory/src/components/product-import/steps/ValidationStep/index.tsx` - Query key updates + +--- + +## 4. Save Template from Confirmation Page ✅ IMPLEMENTED + +**Issue:** Add option to save rows of submitted data as a new template on the confirmation page after completing the import flow. Verify this works with new validation step changes. + +**Current Implementation:** +- **Import Results section already exists** inline in [Import.tsx:968-1150](inventory/src/pages/Import.tsx#L968-L1150) + - Shows created products (lines 1021-1097) with image, name, UPC, item number + - Shows errored products (lines 1100-1138) with error details + - "Fix products with errors" button resumes validation flow for failed items +- Template saving logic in ValidationStep: [useTemplateManagement.ts:204-266](inventory/src/components/product-import/steps/ValidationStep/hooks/useTemplateManagement.ts#L204-L266) +- Saves via `POST /api/templates` +- `importOutcome.submittedProducts` contains the full product data for each row + +**Implementation:** +1. **Add "Save as Template" button** to each created product row in the results section (around line 1087-1092 in Import.tsx): + ```typescript + // Add button after the item number display + + ``` + +2. **Add state and dialog** for template saving in Import.tsx: + ```typescript + const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false); + const [selectedProductForTemplate, setSelectedProductForTemplate] = useState(null); + ``` + +3. **Extract/reuse template save logic** from `useTemplateManagement.ts`: + - The `saveNewTemplate()` function (lines 204-266) can be extracted into a shared utility + - Or create a `SaveTemplateDialog` component that can be used in both places + - Key fields needed: `company` (for template name), `product_type`, and all product field values + +4. **Data mapping consideration:** + - `importOutcome.submittedProducts` uses `NormalizedProduct` type + - Templates expect raw field values - may need to map back from normalized format + - Exclude metadata fields: `['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes', '__aiSupplemental']` + +**Files to Modify:** +- `inventory/src/pages/Import.tsx` - Add save template button, state, and dialog +- Consider creating `inventory/src/components/product-import/SaveTemplateDialog.tsx` for reusability +- Potentially extract core save logic from `useTemplateManagement.ts` into shared utility + +--- + +## 5. Sheet Preview on Select Sheet Step ✅ IMPLEMENTED + +**Issue:** On the select sheet step, show a preview of the first 10 lines or so of each sheet underneath the options. + +**Implementation (Completed):** +- Added `workbook` prop to `SelectSheetStep` component +- Added `sheetPreviews` memoized computation using `XLSXLib.utils.sheet_to_json()` +- Shows first 10 rows, 8 columns max per sheet +- Added `truncateCell()` helper to limit cell content to 30 characters with ellipsis +- Each sheet option is now a clickable card with: + - Radio button and sheet name + - Row count indicator + - Scrollable preview table with horizontal scroll + - Selected state highlighted with primary border +- Updated `UploadFlow.tsx` to pass workbook prop + +**Files Modified:** +- `inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx` +- `inventory/src/components/product-import/steps/UploadFlow.tsx` + +--- + +## 6. Empty Row Removal ✅ IMPLEMENTED + +**Issue:** When importing a sheet, automatically remove completely empty rows. + +**Current Implementation:** +- Empty columns are filtered: [MatchColumnsStep.tsx:616-634](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L616-L634) +- A "Remove empty/duplicates" button exists that removes empty rows, single-value rows, AND duplicates +- The automatic removal should ONLY remove completely empty rows, not duplicates or single-value rows + +**Implementation (Completed):** +- Added `isRowCompletelyEmpty()` helper function to [SelectHeaderStep.tsx](inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx) +- Added `useMemo` to filter empty rows on initial data load +- Uses `Object.values(row)` to check all cell values (matches existing button logic) +- Only removes rows where ALL values are undefined, null, or whitespace-only strings +- Manual "Remove Empty/Duplicates" button still available for additional cleanup (duplicates, single-value rows) + +**Files Modified:** +- `inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx` + +--- + +## 7. Unit Conversion for Weight/Dimensions ✅ IMPLEMENTED + +**Issue:** Add unit conversion feature for weight and dimensions columns - similar to calculator button on cost/msrp, add button that opens popover with options to convert grams → oz, lbs → oz for the whole column at once. + +**Current Implementation:** +- Calculator button on price columns: [ValidationTable.tsx:1491-1627](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1491-L1627) +- `PriceColumnHeader` component shows calculator icon on hover +- Weight field defined in config with validation + +**Implementation:** +1. **Create `UnitConversionColumnHeader` component** (similar to `PriceColumnHeader`): + ```typescript + const UnitConversionColumnHeader = ({ field, table }) => { + const [showPopover, setShowPopover] = useState(false); + + const conversions = { + weight: [ + { label: 'Grams → Ounces', factor: 0.035274 }, + { label: 'Pounds → Ounces', factor: 16 }, + { label: 'Kilograms → Ounces', factor: 35.274 }, + ], + dimensions: [ + { label: 'Centimeters → Inches', factor: 0.393701 }, + { label: 'Millimeters → Inches', factor: 0.0393701 }, + ] + }; + + const applyConversion = (factor: number) => { + // Batch update all cells in column + table.rows.forEach((row, index) => { + const currentValue = parseFloat(row[field.key]); + if (!isNaN(currentValue)) { + updateCell(index, field.key, (currentValue * factor).toFixed(2)); + } + }); + }; + + return ( + + + {/* or similar icon */} + + + {conversions[fieldType].map(conv => ( + + ))} + + + ); + }; + ``` + +2. **Identify weight/dimension fields** in config: + - `weight_oz`, `length_in`, `width_in`, `height_in` (check actual field keys) + +3. **Add to column header render logic** in ValidationTable + +**Files to Modify:** +- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx` +- Potentially create new component file for `UnitConversionColumnHeader` +- Update column header rendering to use new component for weight/dimension fields + +--- + +## 8. Expanded MSRP Auto-Fill from Cost ✅ IMPLEMENTED + +**Issue:** Expand auto-fill functionality for MSRP from cost - open small popover with options for 2x, 2.1x, 2.2x, 2.3x, 2.4x, 2.5x multipliers, plus checkbox to round up to nearest 9. + +**Current Implementation:** +- Calculator on MSRP column: [ValidationTable.tsx:1540-1584](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1540-L1584) +- Currently only does `Cost × 2` then subtracts 0.01 if whole number + +**Implementation:** +1. **Replace simple click with popover** in `PriceColumnHeader`: + ```typescript + const [selectedMultiplier, setSelectedMultiplier] = useState(2.0); + const [roundToNine, setRoundToNine] = useState(false); + const multipliers = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5]; + + const roundUpToNine = (value: number): number => { + // 1.41 → 1.49, 2.78 → 2.79, 12.32 → 12.39 + const wholePart = Math.floor(value); + const decimal = value - wholePart; + if (decimal <= 0.09) return wholePart + 0.09; + if (decimal <= 0.19) return wholePart + 0.19; + // ... continue pattern, or: + const lastDigit = Math.floor(decimal * 10); + return wholePart + (lastDigit / 10) + 0.09; + }; + + const calculateMsrp = (cost: number): number => { + let result = cost * selectedMultiplier; + if (roundToNine) { + result = roundUpToNine(result); + } + return result; + }; + ``` + +2. **Create popover UI**: + ```tsx + + + +
+ +
+ {multipliers.map(m => ( + + ))} +
+
+ + +
+ +
+
+
+ ``` + +**Files to Modify:** +- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx` - `PriceColumnHeader` component + +--- + +## 9. Debug Mode - Skip API Submission ✅ IMPLEMENTED + +**Issue:** Add a third switch in the footer of image upload step (visible only to users with `admin:debug` permission) that will not submit data to any API, only complete the process and show results page as if it had worked. + +**Implementation (Completed):** +- Added `skipApiSubmission` state to `ImageUploadStep.tsx` +- Added amber-colored "Skip API (Debug)" switch (visible only with `admin:debug` permission) +- When skip is active, "Use Test API" and "Use Test Database" switches are hidden +- Added `skipApiSubmission?: boolean` to `SubmitOptions` type in `types.ts` +- In `Import.tsx`, when `skipApiSubmission` is true: + - Skips the actual API call entirely + - Generates mock success response with mock PIDs + - Shows `[DEBUG]` prefix in toast and result message + - Displays results page as if submission succeeded + +**Files Modified:** +- `inventory/src/components/product-import/types.ts` - Added `skipApiSubmission` to `SubmitOptions` +- `inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx` - Added switch UI +- `inventory/src/pages/Import.tsx` - Added skip logic in `handleData()` + +--- + +## Summary + +| # | Enhancement | Complexity | Status | +|---|-------------|------------|--------| +| 1 | Strip UPC quotes/spaces | Low | ✅ Implemented | +| 2 | AI context in validation | Medium | ✅ Implemented | +| 3 | Fresh taxonomy per session | Medium | ✅ Implemented | +| 4 | Save template from confirmation | Medium-High | ✅ Implemented | +| 5 | Sheet preview | Low-Medium | ✅ Implemented | +| 6 | Remove empty rows | Low | ✅ Implemented | +| 7 | Unit conversion | Medium | ✅ Implemented | +| 8 | MSRP multiplier options | Medium | ✅ Implemented | +| 9 | Debug skip API | Low-Medium | ✅ Implemented | + +**Implemented:** 9 of 9 items - All enhancements complete! + +--- + +*Document generated: 2026-01-25* diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts index 322c962..9955df1 100644 --- a/inventory/src/components/product-import/config.ts +++ b/inventory/src/components/product-import/config.ts @@ -95,7 +95,6 @@ export const BASE_IMPORT_FIELDS = [ fieldType: { type: "input" }, width: 110, validations: [ - { rule: "required", errorMessage: "Required", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" }, ], @@ -265,7 +264,7 @@ export const BASE_IMPORT_FIELDS = [ label: "HTS Code", key: "hts_code", description: "Harmonized Tariff Schedule code", - alternateMatches: ["taric","hts"], + alternateMatches: ["taric","hts","hs code","hs code (commodity code)"], fieldType: { type: "input" }, width: 130, validations: [ @@ -286,7 +285,7 @@ export const BASE_IMPORT_FIELDS = [ label: "Description", key: "description", description: "Detailed product description", - alternateMatches: ["details/description"], + alternateMatches: ["details/description","description of item"], fieldType: { type: "input", multiline: true diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx index d7ebf11..5a6fa46 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx @@ -47,6 +47,7 @@ export const ImageUploadStep = ({ const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")); const [targetEnvironment, setTargetEnvironment] = useState("prod"); const [useTestDataSource, setUseTestDataSource] = useState(false); + const [skipApiSubmission, setSkipApiSubmission] = useState(false); // Use our hook for product images initialization const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); @@ -177,6 +178,7 @@ export const ImageUploadStep = ({ const submitOptions: SubmitOptions = { targetEnvironment, useTestDataSource, + skipApiSubmission, }; await onSubmit(updatedData, file, submitOptions); @@ -186,7 +188,7 @@ export const ImageUploadStep = ({ } finally { setIsSubmitting(false); } - }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource]); + }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]); return (
@@ -297,27 +299,43 @@ export const ImageUploadStep = ({
{hasDebugPermission && (
+ {!skipApiSubmission && ( + <> +
+ setTargetEnvironment(checked ? "dev" : "prod")} + /> +
+ +
+
+
+ setUseTestDataSource(checked)} + /> +
+ +
+
+ + )}
setTargetEnvironment(checked ? "dev" : "prod")} + id="product-import-skip-api" + checked={skipApiSubmission} + onCheckedChange={(checked) => setSkipApiSubmission(checked)} />
- -
-
-
- setUseTestDataSource(checked)} - /> -
-
diff --git a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx index a6fbd31..d6b1660 100644 --- a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -147,17 +147,17 @@ const MemoizedColumnSamplePreview = React.memo(function ColumnSamplePreview({ sa - - + e.stopPropagation()}> +
{samples.map((sample, i) => ( -
+
{String(sample || '(empty)')} {i < samples.length - 1 && }
))}
- +
diff --git a/inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx b/inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx index 8b05e85..79f6bbd 100644 --- a/inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx +++ b/inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useMemo } from "react" import { SelectHeaderTable } from "./components/SelectHeaderTable" import { useRsi } from "../../hooks/useRsi" import type { RawData } from "../../types" @@ -11,12 +11,29 @@ type SelectHeaderProps = { onBack?: () => void } +/** + * Checks if a row is completely empty (all values are undefined, null, or whitespace-only strings) + */ +const isRowCompletelyEmpty = (row: RawData): boolean => { + return Object.values(row).every(val => + val === undefined || + val === null || + (typeof val === 'string' && val.trim() === '') + ); +}; + export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => { const { translations } = useRsi() const { toast } = useToast() const [selectedRows, setSelectedRows] = useState>(new Set([0])) const [isLoading, setIsLoading] = useState(false) - const [localData, setLocalData] = useState(data) + + // Automatically filter out completely empty rows on initial load + const initialFilteredData = useMemo(() => { + return data.filter(row => !isRowCompletelyEmpty(row)); + }, [data]); + + const [localData, setLocalData] = useState(initialFilteredData) const handleContinue = useCallback(async () => { const [selectedRowIndex] = selectedRows diff --git a/inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx b/inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx index 1e43ad5..5ede5df 100644 --- a/inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx +++ b/inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx @@ -1,21 +1,63 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useMemo } from "react" +import type XLSX from "xlsx" +import * as XLSXLib from "xlsx" import { useRsi } from "../../hooks/useRsi" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" import { ChevronLeft } from "lucide-react" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" type SelectSheetProps = { sheetNames: string[] + workbook: XLSX.WorkBook onContinue: (sheetName: string) => Promise onBack?: () => void } -export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetProps) => { +const MAX_PREVIEW_ROWS = 10 +const MAX_PREVIEW_COLS = 8 +const MAX_CELL_LENGTH = 30 + +export const SelectSheetStep = ({ sheetNames, workbook, onContinue, onBack }: SelectSheetProps) => { const [isLoading, setIsLoading] = useState(false) const { translations } = useRsi() const [value, setValue] = useState(sheetNames[0]) + // Generate preview data for each sheet + const sheetPreviews = useMemo(() => { + const previews: Record = {} + + for (const sheetName of sheetNames) { + const sheet = workbook.Sheets[sheetName] + if (!sheet) continue + + // Convert sheet to array of arrays + const data = XLSXLib.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + defval: null, + }) + + // Take first N rows and limit columns + const previewRows = data.slice(0, MAX_PREVIEW_ROWS).map(row => + (row as (string | number | null)[]).slice(0, MAX_PREVIEW_COLS) + ) + + previews[sheetName] = previewRows + } + + return previews + }, [sheetNames, workbook]) + + const truncateCell = (value: string | number | null): string => { + if (value === null || value === undefined) return "" + const str = String(value) + if (str.length > MAX_CELL_LENGTH) { + return str.slice(0, MAX_CELL_LENGTH - 1) + "…" + } + return str + } + const handleOnContinue = useCallback( async (data: typeof value) => { setIsLoading(true) @@ -37,19 +79,69 @@ export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetP - {sheetNames.map((sheetName) => ( -
- -
+ ) + })}
diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index 2583e2c..fc3aca0 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -1,5 +1,6 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useEffect } from "react" import type XLSX from "xlsx" +import { useQueryClient } from "@tanstack/react-query" import { UploadStep } from "./UploadStep/UploadStep" import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep" import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep" @@ -14,6 +15,7 @@ import type { RawData, Data } from "../types" import { Progress } from "@/components/ui/progress" import { useToast } from "@/hooks/use-toast" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" +import { useValidationStore } from "./ValidationStep/store/validationStore" export enum StepType { upload = "upload", @@ -82,6 +84,31 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { onSubmit } = useRsi() const [uploadedFile, setUploadedFile] = useState(null) const { toast } = useToast() + const queryClient = useQueryClient() + const resetValidationStore = useValidationStore((state) => state.reset) + + // Fresh taxonomy data per session: + // Invalidate caches on mount and clear on unmount to ensure fresh data each import session + useEffect(() => { + // On mount - invalidate import-related query caches to fetch fresh data + queryClient.invalidateQueries({ queryKey: ['field-options'] }); + queryClient.invalidateQueries({ queryKey: ['product-lines'] }); + queryClient.invalidateQueries({ queryKey: ['product-lines-mapped'] }); + queryClient.invalidateQueries({ queryKey: ['sublines'] }); + queryClient.invalidateQueries({ queryKey: ['sublines-mapped'] }); + + return () => { + // On unmount - remove queries from cache entirely and reset Zustand store + queryClient.removeQueries({ queryKey: ['field-options'] }); + queryClient.removeQueries({ queryKey: ['product-lines'] }); + queryClient.removeQueries({ queryKey: ['product-lines-mapped'] }); + queryClient.removeQueries({ queryKey: ['sublines'] }); + queryClient.removeQueries({ queryKey: ['sublines-mapped'] }); + + // Reset the validation store to clear productLinesCache and sublinesCache + resetValidationStore(); + }; + }, [queryClient, resetValidationStore]); const errorToast = useCallback( (description: string) => { toast({ @@ -143,6 +170,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { return ( { if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) { errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString())) diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx index 4349544..307e784 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx @@ -146,28 +146,37 @@ export const ValidationContainer = ({ }; // Convert rows to sanity check format - return rows.map((row) => ({ - name: row.name as string | undefined, - supplier: row.supplier as string | undefined, - supplier_name: getFieldLabel('supplier', row.supplier), - company: row.company as string | undefined, - company_name: getFieldLabel('company', row.company), - supplier_no: row.supplier_no as string | undefined, - msrp: row.msrp as string | number | undefined, - cost_each: row.cost_each as string | number | undefined, - qty_per_unit: row.qty_per_unit as string | number | undefined, - case_qty: row.case_qty as string | number | undefined, - tax_cat: row.tax_cat as string | number | undefined, - tax_cat_name: getFieldLabel('tax_cat', row.tax_cat), - size_cat: row.size_cat as string | number | undefined, - size_cat_name: getFieldLabel('size_cat', row.size_cat), - themes: row.themes as string | undefined, - categories: row.categories as string | undefined, - weight: row.weight as string | number | undefined, - length: row.length as string | number | undefined, - width: row.width as string | number | undefined, - height: row.height as string | number | undefined, - })); + return rows.map((row) => { + const product: ProductForSanityCheck = { + name: row.name as string | undefined, + supplier: row.supplier as string | undefined, + supplier_name: getFieldLabel('supplier', row.supplier), + company: row.company as string | undefined, + company_name: getFieldLabel('company', row.company), + supplier_no: row.supplier_no as string | undefined, + msrp: row.msrp as string | number | undefined, + cost_each: row.cost_each as string | number | undefined, + qty_per_unit: row.qty_per_unit as string | number | undefined, + case_qty: row.case_qty as string | number | undefined, + tax_cat: row.tax_cat as string | number | undefined, + tax_cat_name: getFieldLabel('tax_cat', row.tax_cat), + size_cat: row.size_cat as string | number | undefined, + size_cat_name: getFieldLabel('size_cat', row.size_cat), + themes: row.themes as string | undefined, + categories: row.categories as string | undefined, + weight: row.weight as string | number | undefined, + length: row.length as string | number | undefined, + width: row.width as string | number | undefined, + height: row.height as string | number | undefined, + }; + + // Add AI supplemental context if present (from MatchColumnsStep "AI context only" columns) + if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') { + product.additional_context = row.__aiSupplemental; + } + + return product; + }); }, []); // Handle viewing cached sanity check results diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 68096e3..57f191f 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -16,7 +16,9 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Checkbox } from '@/components/ui/checkbox'; -import { ArrowDown, Wand2, Loader2, Calculator } from 'lucide-react'; +import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -1493,7 +1495,7 @@ HeaderCheckbox.displayName = 'HeaderCheckbox'; * * Renders a column header for MSRP or Cost Each with a hover button * that fills empty cells based on the other price field. - * - MSRP: Fill with Cost Each × 2 + * - MSRP: Opens popover with multiplier options (2x-2.5x) and round-to-.X9 checkbox * - Cost Each: Fill with MSRP ÷ 2 * * PERFORMANCE: Uses local hover state and getState() for bulk updates. @@ -1505,14 +1507,32 @@ interface PriceColumnHeaderProps { isRequired: boolean; } +const MSRP_MULTIPLIERS = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5]; + +/** + * Round up to nearest .X9 (e.g., 12.32 → 12.39, 15.71 → 15.79) + */ +const roundToNine = (value: number): number => { + const wholePart = Math.floor(value); + const decimal = value - wholePart; + // Get the tenths digit and add .09 to make it end in 9 + const tenths = Math.floor(decimal * 10); + return wholePart + (tenths / 10) + 0.09; +}; + const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => { const [isHovered, setIsHovered] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [hasFillableCells, setHasFillableCells] = useState(false); + const [selectedMultiplier, setSelectedMultiplier] = useState(2.0); + const [shouldRoundToNine, setShouldRoundToNine] = useState(false); - // Determine the source field and calculation - const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp'; - const tooltipText = fieldKey === 'msrp' - ? 'Fill empty cells with Cost Each × 2' + const isMsrp = fieldKey === 'msrp'; + + // Determine the source field + const sourceField = isMsrp ? 'cost_each' : 'msrp'; + const tooltipText = isMsrp + ? 'Fill empty MSRP from Cost' : 'Fill empty cells with MSRP ÷ 2'; // Check if there are any cells that can be filled (called on hover) @@ -1537,7 +1557,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead setHasFillableCells(checkFillableCells()); }, [checkFillableCells]); - const handleCalculate = useCallback(() => { + const handleCalculateMsrp = useCallback((multiplier: number, roundNine: boolean) => { const updatedIndices: number[] = []; // Use setState() for efficient batch update with Immer @@ -1553,26 +1573,65 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead if (isEmpty && hasSource) { const sourceNum = parseFloat(String(sourceValue)); if (!isNaN(sourceNum) && sourceNum > 0) { - // Calculate the new value - let newValue: string; - if (fieldKey === 'msrp') { - let msrp = sourceNum * 2; - // Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99) - if (msrp === Math.floor(msrp)) { + let msrp = sourceNum * multiplier; + + if (multiplier === 2.0) { + // For 2x: auto-adjust by ±1 cent to get to .99 if close + const cents = Math.round((msrp % 1) * 100); + if (cents === 0) { + // .00 → subtract 1 cent to get .99 msrp -= 0.01; + } else if (cents === 98) { + // .98 → add 1 cent to get .99 + msrp += 0.01; } - newValue = msrp.toFixed(2); - } else { - newValue = (sourceNum / 2).toFixed(2); + // Otherwise leave as-is + } else if (roundNine) { + // For >2x with checkbox: round to nearest .X9 + msrp = roundToNine(msrp); } - draft.rows[index][fieldKey] = newValue; + + draft.rows[index][fieldKey] = msrp.toFixed(2); + updatedIndices.push(index); + } + } + }); + }); + + // Clear validation errors for all updated cells + if (updatedIndices.length > 0) { + const { clearFieldError } = useValidationStore.getState(); + updatedIndices.forEach((rowIndex) => { + clearFieldError(rowIndex, fieldKey); + }); + + toast.success(`Updated ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`); + } + + setIsPopoverOpen(false); + }, [fieldKey, sourceField, label]); + + const handleCalculateCostEach = useCallback(() => { + const updatedIndices: number[] = []; + + useValidationStore.setState((draft) => { + draft.rows.forEach((row, index) => { + const currentValue = row[fieldKey]; + const sourceValue = row[sourceField]; + + const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; + + if (isEmpty && hasSource) { + const sourceNum = parseFloat(String(sourceValue)); + if (!isNaN(sourceNum) && sourceNum > 0) { + draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2); updatedIndices.push(index); } } }); }); - // Clear validation errors for all updated cells (removes "required" error styling) if (updatedIndices.length > 0) { const { clearFieldError } = useValidationStore.getState(); updatedIndices.forEach((rowIndex) => { @@ -1587,38 +1646,118 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
setIsHovered(false)} + onMouseLeave={() => { + if (!isPopoverOpen) setIsHovered(false); + }} > {label} {isRequired && ( * )} - {isHovered && hasFillableCells && ( - - - - + + + +

{tooltipText}

+
+
+
+ +
+

+ Calculate MSRP from Cost +

+
+

Multiplier

+
+ {MSRP_MULTIPLIERS.map((m) => ( + + ))} +
+
+ {selectedMultiplier > 2.0 && ( +
+ setShouldRoundToNine(checked === true)} + /> + +
)} - > - - - - -

{tooltipText}

-
- - + {selectedMultiplier === 2.0 && ( +

+ Auto-adjusts ±1¢ for .99 pricing +

+ )} + +
+
+ + ) : ( + // Cost Each: Simple click behavior + + + + + + +

{tooltipText}

+
+
+
+ ) )}
); @@ -1626,6 +1765,169 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead PriceColumnHeader.displayName = 'PriceColumnHeader'; +/** + * UnitConversionColumnHeader Component + * + * Renders a column header for weight/dimension fields with a hover button + * that opens a popover with unit conversion options. + * - Weight: grams → oz, lbs → oz, kg → oz + * - Dimensions: cm → in, mm → in + * + * PERFORMANCE: Uses local hover state and getState() for bulk updates. + */ +interface UnitConversionColumnHeaderProps { + fieldKey: 'weight' | 'length' | 'width' | 'height'; + label: string; + isRequired: boolean; +} + +type ConversionOption = { + label: string; + factor: number; + roundTo?: number; +}; + +const WEIGHT_CONVERSIONS: ConversionOption[] = [ + { label: 'Grams → Ounces', factor: 0.035274, roundTo: 2 }, + { label: 'Pounds → Ounces', factor: 16, roundTo: 2 }, + { label: 'Kilograms → Ounces', factor: 35.274, roundTo: 2 }, +]; + +const DIMENSION_CONVERSIONS: ConversionOption[] = [ + { label: 'Centimeters → Inches', factor: 0.393701, roundTo: 2 }, + { label: 'Millimeters → Inches', factor: 0.0393701, roundTo: 2 }, +]; + +const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitConversionColumnHeaderProps) => { + const [isHovered, setIsHovered] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [hasConvertibleCells, setHasConvertibleCells] = useState(false); + + const isWeightField = fieldKey === 'weight'; + const conversions = isWeightField ? WEIGHT_CONVERSIONS : DIMENSION_CONVERSIONS; + + // Check if there are any cells with numeric values that can be converted + const checkConvertibleCells = useCallback(() => { + const { rows } = useValidationStore.getState(); + return rows.some((row) => { + const value = row[fieldKey]; + if (value !== undefined && value !== null && value !== '') { + const num = parseFloat(String(value)); + return !isNaN(num) && num > 0; + } + return false; + }); + }, [fieldKey]); + + // Update convertible check on hover + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + setHasConvertibleCells(checkConvertibleCells()); + }, [checkConvertibleCells]); + + const handleConversion = useCallback((conversion: ConversionOption) => { + const updatedIndices: number[] = []; + + // Use setState() for efficient batch update with Immer + useValidationStore.setState((draft) => { + draft.rows.forEach((row, index) => { + const value = row[fieldKey]; + + if (value !== undefined && value !== null && value !== '') { + const num = parseFloat(String(value)); + if (!isNaN(num) && num > 0) { + const converted = num * conversion.factor; + const rounded = conversion.roundTo !== undefined + ? converted.toFixed(conversion.roundTo) + : converted.toString(); + draft.rows[index][fieldKey] = rounded; + updatedIndices.push(index); + } + } + }); + }); + + // Clear validation errors for all updated cells + if (updatedIndices.length > 0) { + const { clearFieldError } = useValidationStore.getState(); + updatedIndices.forEach((rowIndex) => { + clearFieldError(rowIndex, fieldKey); + }); + + toast.success(`Converted ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`); + } else { + toast.info('No values to convert'); + } + + setIsPopoverOpen(false); + }, [fieldKey, label]); + + return ( +
{ + if (!isPopoverOpen) setIsHovered(false); + }} + > + {label} + {isRequired && ( + * + )} + {(isHovered || isPopoverOpen) && hasConvertibleCells && ( + + + + + + + + + +

Convert units for entire column

+
+
+
+ +
+

+ Convert {isWeightField ? 'Weight' : 'Dimensions'} +

+ {conversions.map((conversion) => ( + + ))} +
+
+
+ )} +
+ ); +}); + +UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader'; + /** * Main table component * @@ -1731,23 +2033,41 @@ export const ValidationTable = () => { const dataColumns: ColumnDef[] = fields.map((field) => { const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false; const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each'; + const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height'; - return { - id: field.key, - header: () => isPriceColumn ? ( - - ) : ( + // Determine which header component to render + const renderHeader = () => { + if (isPriceColumn) { + return ( + + ); + } + if (isUnitConversionColumn) { + return ( + + ); + } + return (
{field.label} {isRequired && ( * )}
- ), + ); + }; + + return { + id: field.key, + header: renderHeader, size: field.width || 150, }; }); diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts index 3eb879c..a176de4 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts @@ -59,6 +59,7 @@ export interface ProductForSanityCheck { length?: string | number; width?: string | number; height?: string | number; + additional_context?: Record; // AI supplemental columns from MatchColumnsStep } /** diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts index 2e52fc2..46f40c1 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -21,7 +21,7 @@ export interface RowData { __original?: Record; // Original values before AI changes __corrected?: Record; // AI-corrected values __changes?: Record; // Fields changed by AI - __aiSupplemental?: string[]; // AI supplemental columns from MatchColumnsStep + __aiSupplemental?: Record; // AI supplemental columns from MatchColumnsStep (header -> value) // Standard fields (from config.ts) supplier?: string; diff --git a/inventory/src/components/product-import/steps/ValidationStep/utils/dataMutations.ts b/inventory/src/components/product-import/steps/ValidationStep/utils/dataMutations.ts index 605be81..8133aff 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/utils/dataMutations.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/utils/dataMutations.ts @@ -1,6 +1,7 @@ import type { Data, Fields, Info, RowHook, TableHook, Meta, Errors } from "../../../types" import { v4 } from "uuid" import { ErrorSources, ErrorType } from "../../../types" +import { normalizeCountryCode } from "./countryUtils" type DataWithMeta = Data & Meta & { @@ -56,6 +57,21 @@ export const addErrorsAndRunHooks = async ( } } + // Normalize country of origin (coo) to 2-letter ISO codes + processedData.forEach((row) => { + const coo = (row as Record).coo + if (typeof coo === "string" && coo.trim()) { + const raw = coo.trim() + const normalized = normalizeCountryCode(raw) + if (normalized) { + (row as Record).coo = normalized + } else if (raw.length === 2) { + // Uppercase 2-letter values as fallback + (row as Record).coo = raw.toUpperCase() + } + } + }) + fields.forEach((field) => { const fieldKey = field.key as string field.validations?.forEach((validation) => { diff --git a/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts b/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts index 6bc39fc..5f65353 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts @@ -185,11 +185,18 @@ export function buildDescriptionValidationPayload( fields: Field[], overrides?: PayloadOverrides ): DescriptionValidationPayload { - return { + const payload: DescriptionValidationPayload = { name: overrides?.name ?? String(row.name || ''), description: overrides?.description ?? String(row.description || ''), company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined, company_id: row.company ? String(row.company) : undefined, // For backend prompt loading categories: row.categories as string | undefined, }; + + // Add AI supplemental context if present (from MatchColumnsStep "AI context only" columns) + if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') { + payload.additional_context = row.__aiSupplemental; + } + + return payload; } diff --git a/inventory/src/components/product-import/types.ts b/inventory/src/components/product-import/types.ts index 9d5ee65..b321fc8 100644 --- a/inventory/src/components/product-import/types.ts +++ b/inventory/src/components/product-import/types.ts @@ -9,6 +9,7 @@ export type Meta = { __index: string } export type SubmitOptions = { targetEnvironment: "dev" | "prod" useTestDataSource: boolean + skipApiSubmission?: boolean } export type RsiProps = { diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index 561824a..461a8b5 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -1,4 +1,4 @@ -import { useState, useContext } from "react"; +import { useState, useContext, useMemo } from "react"; import { ReactSpreadsheetImport, StepType } from "@/components/product-import"; import type { StepState } from "@/components/product-import/steps/UploadFlow"; import { Button } from "@/components/ui/button"; @@ -6,9 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Code } from "@/components/ui/code"; import { toast } from "sonner"; import { motion } from "framer-motion"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import config from "@/config"; -import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle, ExternalLink } from "lucide-react"; +import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle, ExternalLink, BookmarkPlus } from "lucide-react"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Separator } from "@/components/ui/separator"; @@ -16,6 +16,7 @@ import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/compon import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config"; import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2"; import { AuthContext } from "@/contexts/AuthContext"; +import { TemplateForm } from "@/components/templates/TemplateForm"; type NormalizedProduct = Record; type ImportResult = Result & { all?: Result["validData"] }; @@ -264,8 +265,11 @@ export function Import() { const [selectedCompany, setSelectedCompany] = useState(null); const [selectedLine, setSelectedLine] = useState(null); const [startFromScratch, setStartFromScratch] = useState(false); + const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false); + const [selectedProductForTemplate, setSelectedProductForTemplate] = useState(null); const { user } = useContext(AuthContext); const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")); + const queryClient = useQueryClient(); // ========== TEMPORARY TEST DATA ========== // Uncomment the useEffect below to test the results page without submitting actual data @@ -659,7 +663,11 @@ export function Import() { }; const normalizeUpcValue = (value: string): string => { - const expanded = expandScientificNotation(value); + // First strip quotes (single, double, smart quotes) and whitespace + const cleaned = value.replace(/['"'"\s]/g, ""); + // Then handle scientific notation + const expanded = expandScientificNotation(cleaned); + // Extract only digits const digitsOnly = expanded.replace(/[^0-9]/g, ""); return digitsOnly || expanded; }; @@ -732,6 +740,38 @@ export function Import() { } as NormalizedProduct; }); + // Handle debug mode: skip API submission entirely + if (submitOptions?.skipApiSubmission) { + // Generate mock response simulating successful creation + const mockCreated = formattedRows.map((product, index) => ({ + upc: product.upc, + item_number: product.item_number, + pid: `mock-${Date.now()}-${index}`, + })); + + const mockResponse: SubmitNewProductsResponse = { + success: true, + message: `[DEBUG] Skipped API - ${formattedRows.length} product(s) would have been submitted`, + data: { + created: mockCreated, + errored: [], + }, + }; + + setResumeStepState(undefined); + setImportOutcome({ + submittedProducts: formattedRows.map((product) => ({ ...product })), + submittedRows: rows.map((row) => ({ ...row })), + response: mockResponse, + }); + setIsDebugDataVisible(false); + setIsOpen(false); + setStartFromScratch(false); + + toast.success(`[DEBUG] Skipped API submission for ${formattedRows.length} product(s)`); + return; + } + const response = await submitNewProducts({ products: formattedRows, environment: submitOptions?.targetEnvironment ?? "prod", @@ -824,6 +864,8 @@ export function Import() { itemNumber: productItemNumber ?? responseItemNumber ?? "—", url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null, pid: pidValue, + // Store index to access full product data for template saving + submittedProductIndex: productIndex, }; }) : []; @@ -918,6 +960,86 @@ export function Import() { setIsOpen(true); }; + // Handle opening save template dialog for a created product + const handleSaveAsTemplate = (product: NormalizedProduct) => { + setSelectedProductForTemplate(product); + setTemplateSaveDialogOpen(true); + }; + + // Convert NormalizedProduct to TemplateForm format + const templateFormData = useMemo(() => { + if (!selectedProductForTemplate) return null; + + const product = selectedProductForTemplate; + + // Helper to parse numeric values + const parseNumeric = (val: string | string[] | boolean | null): number | undefined => { + if (typeof val === 'string') { + const parsed = parseFloat(val.replace(/[$,]/g, '')); + return isNaN(parsed) ? undefined : parsed; + } + return undefined; + }; + + // Helper to extract string value + const getString = (val: string | string[] | boolean | null): string | undefined => { + if (typeof val === 'string') return val || undefined; + if (Array.isArray(val) && val.length > 0) return val[0]; + return undefined; + }; + + // Helper to extract string array + const getStringArray = (val: string | string[] | boolean | null): string[] | undefined => { + if (Array.isArray(val)) return val.length > 0 ? val : undefined; + if (typeof val === 'string' && val) return [val]; + return undefined; + }; + + return { + company: getString(product.company) || '', + product_type: getString(product.name) || '', // Use product name as default type + supplier: getString(product.supplier), + msrp: parseNumeric(product.msrp), + cost_each: parseNumeric(product.cost_each), + qty_per_unit: parseNumeric(product.qty_per_unit), + case_qty: parseNumeric(product.case_qty), + hts_code: getString(product.hts_code), + description: getString(product.description), + weight: parseNumeric(product.weight), + length: parseNumeric(product.length), + width: parseNumeric(product.width), + height: parseNumeric(product.height), + tax_cat: getString(product.tax_cat), + size_cat: getString(product.size_cat), + categories: getStringArray(product.categories), + ship_restrictions: getStringArray(product.ship_restrictions), + }; + }, [selectedProductForTemplate]); + + // Convert fieldOptions to TemplateForm format + const templateFieldOptions = useMemo(() => { + if (!fieldOptions) return null; + return { + companies: fieldOptions.companies || [], + artists: fieldOptions.artists || [], + sizes: fieldOptions.sizeCategories || [], + themes: fieldOptions.themes || [], + categories: fieldOptions.categories || [], + colors: fieldOptions.colors || [], + suppliers: fieldOptions.suppliers || [], + taxCategories: fieldOptions.taxCategories || [], + shippingRestrictions: fieldOptions.shippingRestrictions || [], + }; + }, [fieldOptions]); + + // Handle successful template save + const handleTemplateSaveSuccess = () => { + setTemplateSaveDialogOpen(false); + setSelectedProductForTemplate(null); + // Invalidate templates query if it exists + queryClient.invalidateQueries({ queryKey: ['templates'] }); + }; + if (isLoadingOptions) { return (
@@ -1084,11 +1206,27 @@ export function Import() { {product.name} )}
+ {product.submittedProductIndex !== undefined && product.submittedProductIndex >= 0 && importOutcome && ( + + )} +
+
+
+ + UPC: {product.upc} + + Item #: {product.itemNumber} +
+
- - UPC: {product.upc} - - Item #: {product.itemNumber} ); @@ -1167,6 +1305,19 @@ export function Import() { : undefined) } /> + + {/* Template Save Dialog */} + { + setTemplateSaveDialogOpen(false); + setSelectedProductForTemplate(null); + }} + onSuccess={handleTemplateSaveSuccess} + initialData={templateFormData} + mode="create" + fieldOptions={templateFieldOptions} + /> ); }