Product import fixes/enhancements
This commit is contained in:
375
PRODUCT_IMPORT_ENHANCEMENTS.md
Normal file
375
PRODUCT_IMPORT_ENHANCEMENTS.md
Normal 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*
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user