2 Commits

Author SHA1 Message Date
ec8ab17d3f Product import fixes/enhancements 2026-01-25 21:59:57 -05:00
100e398aae Update acob url to tools 2026-01-25 15:50:55 -05:00
23 changed files with 1178 additions and 144 deletions

View File

@@ -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<string>,
fieldOptions: FieldOptionsMap,
productLinesCache: Map<string, SelectOption[]>,
sublinesCache: Map<string, SelectOption[]>
) => {
const payload: Record<string, unknown> = {
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
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveAsTemplate(index)}
>
<BookmarkPlus className="h-4 w-4" />
</Button>
```
2. **Add state and dialog** for template saving in Import.tsx:
```typescript
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(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 (
<Popover open={showPopover} onOpenChange={setShowPopover}>
<PopoverTrigger>
<Scale className="h-4 w-4" /> {/* or similar icon */}
</PopoverTrigger>
<PopoverContent>
{conversions[fieldType].map(conv => (
<Button key={conv.label} onClick={() => applyConversion(conv.factor)}>
{conv.label}
</Button>
))}
</PopoverContent>
</Popover>
);
};
```
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
<Popover>
<PopoverTrigger><Calculator className="h-4 w-4" /></PopoverTrigger>
<PopoverContent className="w-48">
<div className="space-y-2">
<Label>Multiplier</Label>
<div className="grid grid-cols-3 gap-1">
{multipliers.map(m => (
<Button
key={m}
variant={selectedMultiplier === m ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMultiplier(m)}
>
{m}x
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Checkbox checked={roundToNine} onCheckedChange={setRoundToNine} />
<Label>Round to .X9</Label>
</div>
<Button onClick={applyCalculation} className="w-full">
Apply
</Button>
</div>
</PopoverContent>
</Popover>
```
**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*

View File

@@ -35,7 +35,7 @@ global.pool = pool;
app.use(express.json());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://acob.acherryontop.com'],
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://tools.acherryontop.com'],
credentials: true
}));

View File

@@ -33,7 +33,7 @@ global.pool = pool;
app.use(express.json());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://acob.acherryontop.com'],
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://tools.acherryontop.com'],
credentials: true
}));

View File

@@ -33,7 +33,7 @@ const corsOptions = {
origin: function(origin, callback) {
const allowedOrigins = [
'http://localhost:3000',
'https://acob.acherryontop.com'
'https://tools.acherryontop.com'
];
console.log('CORS check for origin:', origin);

View File

@@ -6,7 +6,7 @@ const corsMiddleware = cors({
'https://inventory.kent.pw',
'http://localhost:5175',
'https://acot.site',
'https://acob.acherryontop.com',
'https://tools.acherryontop.com',
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
],
@@ -28,7 +28,7 @@ const corsErrorHandler = (err, req, res, next) => {
res.status(403).json({
error: 'CORS not allowed',
origin: req.get('Origin'),
message: 'Origin not in allowed list: https://inventory.kent.pw, https://acot.site, https://acob.acherryontop.com, localhost:5175, 192.168.x.x, or 10.x.x.x'
message: 'Origin not in allowed list: https://inventory.kent.pw, https://acot.site, https://tools.acherryontop.com, localhost:5175, 192.168.x.x, or 10.x.x.x'
});
} else {
next(err);

View File

@@ -635,7 +635,7 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
// Create URL for the uploaded file - using an absolute URL with domain
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
const baseUrl = 'https://acob.acherryontop.com';
const baseUrl = 'https://tools.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
// Schedule this image for deletion in 24 hours

View File

@@ -194,7 +194,7 @@ router.post('/upload', upload.single('image'), async (req, res) => {
}
// Create URL for the uploaded file
const baseUrl = 'https://acob.acherryontop.com';
const baseUrl = 'https://tools.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`;
const pool = req.app.locals.pool;

View File

@@ -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

View File

@@ -47,6 +47,7 @@ export const ImageUploadStep = ({
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(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 (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
@@ -297,27 +299,43 @@ export const ImageUploadStep = ({
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
{hasDebugPermission && (
<div className="flex gap-4 text-sm">
{!skipApiSubmission && (
<>
<div className="flex items-center gap-1">
<Switch
id="product-import-api-environment"
checked={targetEnvironment === "dev"}
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
/>
<div>
<Label htmlFor="product-import-api-environment" className="text-sm font-medium">
Use test API
</Label>
</div>
</div>
<div className="flex items-center gap-1">
<Switch
id="product-import-api-test-data"
checked={useTestDataSource}
onCheckedChange={(checked) => setUseTestDataSource(checked)}
/>
<div>
<Label htmlFor="product-import-api-test-data" className="text-sm font-medium">
Use test database
</Label>
</div>
</div>
</>
)}
<div className="flex items-center gap-1">
<Switch
id="product-import-api-environment"
checked={targetEnvironment === "dev"}
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
id="product-import-skip-api"
checked={skipApiSubmission}
onCheckedChange={(checked) => setSkipApiSubmission(checked)}
/>
<div>
<Label htmlFor="product-import-api-environment" className="text-sm font-medium">
Use test API
</Label>
</div>
</div>
<div className="flex items-center gap-1">
<Switch
id="product-import-api-test-data"
checked={useTestDataSource}
onCheckedChange={(checked) => setUseTestDataSource(checked)}
/>
<div>
<Label htmlFor="product-import-api-test-data" className="text-sm font-medium">
Use test database
<Label htmlFor="product-import-skip-api" className="text-sm font-medium text-amber-600">
Skip API (Debug)
</Label>
</div>
</div>

View File

@@ -147,17 +147,17 @@ const MemoizedColumnSamplePreview = React.memo(function ColumnSamplePreview({ sa
<FileSpreadsheetIcon className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-[250px] p-0">
<ScrollArea className="h-[200px] overflow-y-auto">
<PopoverContent side="right" align="start" className="w-[280px] p-0" onWheel={(e) => e.stopPropagation()}>
<div className="max-h-[300px] overflow-y-auto overscroll-contain" style={{ overscrollBehavior: 'contain' }}>
<div className="p-3 space-y-2">
{samples.map((sample, i) => (
<div key={i} className="text-sm">
<div key={i} className="text-sm break-words">
<span className="font-medium">{String(sample || '(empty)')}</span>
{i < samples.length - 1 && <Separator className="w-full my-2" />}
</div>
))}
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -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<ReadonlySet<number>>(new Set([0]))
const [isLoading, setIsLoading] = useState(false)
const [localData, setLocalData] = useState<RawData[]>(data)
// Automatically filter out completely empty rows on initial load
const initialFilteredData = useMemo(() => {
return data.filter(row => !isRowCompletelyEmpty(row));
}, [data]);
const [localData, setLocalData] = useState<RawData[]>(initialFilteredData)
const handleContinue = useCallback(async () => {
const [selectedRowIndex] = selectedRows

View File

@@ -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<void>
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<string, (string | number | null)[][]> = {}
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
<RadioGroup
value={value}
onValueChange={setValue}
className="space-y-4"
className="space-y-6"
>
{sheetNames.map((sheetName) => (
<div key={sheetName} className="flex items-center space-x-2">
<RadioGroupItem value={sheetName} id={sheetName} />
<Label
htmlFor={sheetName}
className="text-base"
{sheetNames.map((sheetName) => {
const preview = sheetPreviews[sheetName] || []
const isSelected = value === sheetName
return (
<div
key={sheetName}
className={`rounded-lg border p-4 transition-colors cursor-pointer ${
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/50"
}`}
onClick={() => setValue(sheetName)}
>
{sheetName}
</Label>
</div>
))}
<div className="flex items-center space-x-2 mb-3">
<RadioGroupItem value={sheetName} id={sheetName} />
<Label
htmlFor={sheetName}
className="text-base font-medium cursor-pointer"
>
{sheetName}
</Label>
<span className="text-xs text-muted-foreground">
({preview.length === 10 ? 'first ' : ''}{preview.length} rows shown)
</span>
</div>
{preview.length > 0 && (
<ScrollArea className="w-full">
<div className="rounded border bg-muted/30">
<table className="text-xs w-full">
<tbody>
{preview.map((row, rowIndex) => (
<tr
key={rowIndex}
className={rowIndex === 0 ? "bg-muted/50 font-medium" : ""}
>
{row.map((cell, colIndex) => (
<td
key={colIndex}
className="px-2 py-1 border-r border-b last:border-r-0 whitespace-nowrap max-w-[150px] overflow-hidden text-ellipsis"
title={cell !== null ? String(cell) : ""}
>
{truncateCell(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
{preview.length === 0 && (
<p className="text-xs text-muted-foreground italic">
No data in this sheet
</p>
)}
</div>
)
})}
</RadioGroup>
</div>
</div>

View File

@@ -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<File | null>(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 (
<SelectSheetStep
sheetNames={state.workbook.SheetNames}
workbook={state.workbook}
onContinue={async (sheetName) => {
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))

View File

@@ -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

View File

@@ -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
<div
className="flex items-center gap-1 truncate w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
onMouseLeave={() => {
if (!isPopoverOpen) setIsHovered(false);
}}
>
<span className="">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{isHovered && hasFillableCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCalculate();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
{(isHovered || isPopoverOpen) && hasFillableCells && (
isMsrp ? (
// MSRP: Show popover with multiplier options
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
>
<Calculator className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-52 p-3" align="end">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Calculate MSRP from Cost
</p>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Multiplier</p>
<div className="grid grid-cols-3 gap-1">
{MSRP_MULTIPLIERS.map((m) => (
<Button
key={m}
variant={selectedMultiplier === m ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedMultiplier(m)}
>
{m}x
</Button>
))}
</div>
</div>
{selectedMultiplier > 2.0 && (
<div className="flex items-center gap-2">
<Checkbox
id="round-to-nine"
checked={shouldRoundToNine}
onCheckedChange={(checked) => setShouldRoundToNine(checked === true)}
/>
<label htmlFor="round-to-nine" className="text-xs cursor-pointer">
Round to .X9 (e.g., 12.39)
</label>
</div>
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{selectedMultiplier === 2.0 && (
<p className="text-xs text-muted-foreground">
Auto-adjusts ±1¢ for .99 pricing
</p>
)}
<Button
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleCalculateMsrp(selectedMultiplier, shouldRoundToNine)}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
) : (
// Cost Each: Simple click behavior
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCalculateCostEach();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
</div>
);
@@ -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 (
<div
className="flex items-center gap-1 truncate w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => {
if (!isPopoverOpen) setIsHovered(false);
}}
>
<span className="">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity'
)}
>
<Scale className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Convert units for entire column</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-48 p-2" align="end">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground mb-2">
Convert {isWeightField ? 'Weight' : 'Dimensions'}
</p>
{conversions.map((conversion) => (
<Button
key={conversion.label}
variant="ghost"
size="sm"
className="w-full justify-start text-xs h-7"
onClick={() => handleConversion(conversion)}
>
{conversion.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
});
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
/**
* Main table component
*
@@ -1731,23 +2033,41 @@ export const ValidationTable = () => {
const dataColumns: ColumnDef<RowData>[] = 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 ? (
<PriceColumnHeader
fieldKey={field.key as 'msrp' | 'cost_each'}
label={field.label}
isRequired={isRequired}
/>
) : (
// Determine which header component to render
const renderHeader = () => {
if (isPriceColumn) {
return (
<PriceColumnHeader
fieldKey={field.key as 'msrp' | 'cost_each'}
label={field.label}
isRequired={isRequired}
/>
);
}
if (isUnitConversionColumn) {
return (
<UnitConversionColumnHeader
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
label={field.label}
isRequired={isRequired}
/>
);
}
return (
<div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
</div>
),
);
};
return {
id: field.key,
header: renderHeader,
size: field.width || 150,
};
});

View File

@@ -59,6 +59,7 @@ export interface ProductForSanityCheck {
length?: string | number;
width?: string | number;
height?: string | number;
additional_context?: Record<string, string>; // AI supplemental columns from MatchColumnsStep
}
/**

View File

@@ -21,7 +21,7 @@ export interface RowData {
__original?: Record<string, unknown>; // Original values before AI changes
__corrected?: Record<string, unknown>; // AI-corrected values
__changes?: Record<string, boolean>; // Fields changed by AI
__aiSupplemental?: string[]; // AI supplemental columns from MatchColumnsStep
__aiSupplemental?: Record<string, string>; // AI supplemental columns from MatchColumnsStep (header -> value)
// Standard fields (from config.ts)
supplier?: string;

View File

@@ -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<T extends string> = Data<T> & Meta & {
@@ -56,6 +57,21 @@ export const addErrorsAndRunHooks = async <T extends string>(
}
}
// Normalize country of origin (coo) to 2-letter ISO codes
processedData.forEach((row) => {
const coo = (row as Record<string, unknown>).coo
if (typeof coo === "string" && coo.trim()) {
const raw = coo.trim()
const normalized = normalizeCountryCode(raw)
if (normalized) {
(row as Record<string, unknown>).coo = normalized
} else if (raw.length === 2) {
// Uppercase 2-letter values as fallback
(row as Record<string, unknown>).coo = raw.toUpperCase()
}
}
})
fields.forEach((field) => {
const fieldKey = field.key as string
field.validations?.forEach((validation) => {

View File

@@ -185,11 +185,18 @@ export function buildDescriptionValidationPayload(
fields: Field<string>[],
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;
}

View File

@@ -9,6 +9,7 @@ export type Meta = { __index: string }
export type SubmitOptions = {
targetEnvironment: "dev" | "prod"
useTestDataSource: boolean
skipApiSubmission?: boolean
}
export type RsiProps<T extends string> = {

View File

@@ -5,15 +5,15 @@ const isLocal = window.location.hostname === 'localhost' || window.location.host
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://acob.acherryontop.com/auth',
aircall: isDev || useProxy ? '/api/aircall' : 'https://acob.acherryontop.com/api/aircall',
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://acob.acherryontop.com/api/klaviyo',
meta: isDev || useProxy ? '/api/meta' : 'https://acob.acherryontop.com/api/meta',
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://acob.acherryontop.com/api/gorgias',
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://acob.acherryontop.com/api/analytics',
typeform: isDev || useProxy ? '/api/typeform' : 'https://acob.acherryontop.com/api/typeform',
acot: isDev || useProxy ? '/api/acot' : 'https://acob.acherryontop.com/api/acot',
clarity: isDev || useProxy ? '/api/clarity' : 'https://acob.acherryontop.com/api/clarity'
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
aircall: isDev || useProxy ? '/api/aircall' : 'https://tools.acherryontop.com/api/aircall',
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://tools.acherryontop.com/api/klaviyo',
meta: isDev || useProxy ? '/api/meta' : 'https://tools.acherryontop.com/api/meta',
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://tools.acherryontop.com/api/gorgias',
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://tools.acherryontop.com/api/analytics',
typeform: isDev || useProxy ? '/api/typeform' : 'https://tools.acherryontop.com/api/typeform',
acot: isDev || useProxy ? '/api/acot' : 'https://tools.acherryontop.com/api/acot',
clarity: isDev || useProxy ? '/api/clarity' : 'https://tools.acherryontop.com/api/clarity'
};
export default liveDashboardConfig;

View File

@@ -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<ImportFieldKey | "product_images", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
@@ -264,8 +265,11 @@ export function Import() {
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null);
const [startFromScratch, setStartFromScratch] = useState(false);
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(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 (
<div className="container mx-auto py-6">
@@ -1084,11 +1206,27 @@ export function Import() {
<span className="text-sm font-medium">{product.name}</span>
)}
</div>
{product.submittedProductIndex !== undefined && product.submittedProductIndex >= 0 && importOutcome && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 gap-1.5"
onClick={() => handleSaveAsTemplate(importOutcome.submittedProducts[product.submittedProductIndex])}
>
<BookmarkPlus className="h-4 w-4" />
<span className="text-xs">Save as Template</span>
</Button>
)}
</div>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
UPC: {product.upc}
</span>
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
</div>
</div>
<span className="text-xs text-muted-foreground">
UPC: {product.upc}
</span>
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
</div>
</div>
);
@@ -1167,6 +1305,19 @@ export function Import() {
: undefined)
}
/>
{/* Template Save Dialog */}
<TemplateForm
isOpen={templateSaveDialogOpen}
onClose={() => {
setTemplateSaveDialogOpen(false);
setSelectedProductForTemplate(null);
}}
onSuccess={handleTemplateSaveSuccess}
initialData={templateFormData}
mode="create"
fieldOptions={templateFieldOptions}
/>
</motion.div>
);
}

File diff suppressed because one or more lines are too long