Compare commits
2 Commits
aec02e490a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ec8ab17d3f | |||
| 100e398aae |
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*
|
||||||
@@ -35,7 +35,7 @@ global.pool = pool;
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
app.use(cors({
|
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
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ global.pool = pool;
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
app.use(cors({
|
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
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const corsOptions = {
|
|||||||
origin: function(origin, callback) {
|
origin: function(origin, callback) {
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
'https://acob.acherryontop.com'
|
'https://tools.acherryontop.com'
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log('CORS check for origin:', origin);
|
console.log('CORS check for origin:', origin);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const corsMiddleware = cors({
|
|||||||
'https://inventory.kent.pw',
|
'https://inventory.kent.pw',
|
||||||
'http://localhost:5175',
|
'http://localhost:5175',
|
||||||
'https://acot.site',
|
'https://acot.site',
|
||||||
'https://acob.acherryontop.com',
|
'https://tools.acherryontop.com',
|
||||||
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
|
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
|
||||||
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
|
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
|
||||||
],
|
],
|
||||||
@@ -28,7 +28,7 @@ const corsErrorHandler = (err, req, res, next) => {
|
|||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'CORS not allowed',
|
error: 'CORS not allowed',
|
||||||
origin: req.get('Origin'),
|
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 {
|
} else {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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}`;
|
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||||
|
|
||||||
// Schedule this image for deletion in 24 hours
|
// Schedule this image for deletion in 24 hours
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ router.post('/upload', upload.single('image'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create URL for the uploaded file
|
// 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 imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`;
|
||||||
|
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
fieldType: { type: "input" },
|
fieldType: { type: "input" },
|
||||||
width: 110,
|
width: 110,
|
||||||
validations: [
|
validations: [
|
||||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
|
||||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", 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",
|
label: "HTS Code",
|
||||||
key: "hts_code",
|
key: "hts_code",
|
||||||
description: "Harmonized Tariff Schedule code",
|
description: "Harmonized Tariff Schedule code",
|
||||||
alternateMatches: ["taric","hts"],
|
alternateMatches: ["taric","hts","hs code","hs code (commodity code)"],
|
||||||
fieldType: { type: "input" },
|
fieldType: { type: "input" },
|
||||||
width: 130,
|
width: 130,
|
||||||
validations: [
|
validations: [
|
||||||
@@ -286,7 +285,7 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
label: "Description",
|
label: "Description",
|
||||||
key: "description",
|
key: "description",
|
||||||
description: "Detailed product description",
|
description: "Detailed product description",
|
||||||
alternateMatches: ["details/description"],
|
alternateMatches: ["details/description","description of item"],
|
||||||
fieldType: {
|
fieldType: {
|
||||||
type: "input",
|
type: "input",
|
||||||
multiline: true
|
multiline: true
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const ImageUploadStep = ({
|
|||||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
||||||
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
||||||
|
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
||||||
|
|
||||||
// Use our hook for product images initialization
|
// Use our hook for product images initialization
|
||||||
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
||||||
@@ -177,6 +178,7 @@ export const ImageUploadStep = ({
|
|||||||
const submitOptions: SubmitOptions = {
|
const submitOptions: SubmitOptions = {
|
||||||
targetEnvironment,
|
targetEnvironment,
|
||||||
useTestDataSource,
|
useTestDataSource,
|
||||||
|
skipApiSubmission,
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(updatedData, file, submitOptions);
|
await onSubmit(updatedData, file, submitOptions);
|
||||||
@@ -186,7 +188,7 @@ export const ImageUploadStep = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource]);
|
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
||||||
@@ -297,6 +299,8 @@ export const ImageUploadStep = ({
|
|||||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
||||||
{hasDebugPermission && (
|
{hasDebugPermission && (
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
|
{!skipApiSubmission && (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Switch
|
<Switch
|
||||||
id="product-import-api-environment"
|
id="product-import-api-environment"
|
||||||
@@ -321,6 +325,20 @@ export const ImageUploadStep = ({
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
id="product-import-skip-api"
|
||||||
|
checked={skipApiSubmission}
|
||||||
|
onCheckedChange={(checked) => setSkipApiSubmission(checked)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="product-import-skip-api" className="text-sm font-medium text-amber-600">
|
||||||
|
Skip API (Debug)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -147,17 +147,17 @@ const MemoizedColumnSamplePreview = React.memo(function ColumnSamplePreview({ sa
|
|||||||
<FileSpreadsheetIcon className="h-4 w-4 text-muted-foreground" />
|
<FileSpreadsheetIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent side="right" align="start" className="w-[250px] p-0">
|
<PopoverContent side="right" align="start" className="w-[280px] p-0" onWheel={(e) => e.stopPropagation()}>
|
||||||
<ScrollArea className="h-[200px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto overscroll-contain" style={{ overscrollBehavior: 'contain' }}>
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
{samples.map((sample, i) => (
|
{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>
|
<span className="font-medium">{String(sample || '(empty)')}</span>
|
||||||
{i < samples.length - 1 && <Separator className="w-full my-2" />}
|
{i < samples.length - 1 && <Separator className="w-full my-2" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState, useMemo } from "react"
|
||||||
import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { RawData } from "../../types"
|
import type { RawData } from "../../types"
|
||||||
@@ -11,12 +11,29 @@ type SelectHeaderProps = {
|
|||||||
onBack?: () => void
|
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) => {
|
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
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 handleContinue = useCallback(async () => {
|
||||||
const [selectedRowIndex] = selectedRows
|
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 { useRsi } from "../../hooks/useRsi"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ChevronLeft } from "lucide-react"
|
import { ChevronLeft } from "lucide-react"
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
type SelectSheetProps = {
|
type SelectSheetProps = {
|
||||||
sheetNames: string[]
|
sheetNames: string[]
|
||||||
|
workbook: XLSX.WorkBook
|
||||||
onContinue: (sheetName: string) => Promise<void>
|
onContinue: (sheetName: string) => Promise<void>
|
||||||
onBack?: () => 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 [isLoading, setIsLoading] = useState(false)
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
const [value, setValue] = useState(sheetNames[0])
|
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(
|
const handleOnContinue = useCallback(
|
||||||
async (data: typeof value) => {
|
async (data: typeof value) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -37,19 +79,69 @@ export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetP
|
|||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={setValue}
|
onValueChange={setValue}
|
||||||
className="space-y-4"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{sheetNames.map((sheetName) => (
|
{sheetNames.map((sheetName) => {
|
||||||
<div key={sheetName} className="flex items-center space-x-2">
|
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)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
<RadioGroupItem value={sheetName} id={sheetName} />
|
<RadioGroupItem value={sheetName} id={sheetName} />
|
||||||
<Label
|
<Label
|
||||||
htmlFor={sheetName}
|
htmlFor={sheetName}
|
||||||
className="text-base"
|
className="text-base font-medium cursor-pointer"
|
||||||
>
|
>
|
||||||
{sheetName}
|
{sheetName}
|
||||||
</Label>
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({preview.length === 10 ? 'first ' : ''}{preview.length} rows shown)
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState, useEffect } from "react"
|
||||||
import type XLSX from "xlsx"
|
import type XLSX from "xlsx"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { UploadStep } from "./UploadStep/UploadStep"
|
import { UploadStep } from "./UploadStep/UploadStep"
|
||||||
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
|
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
|
||||||
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
||||||
@@ -14,6 +15,7 @@ import type { RawData, Data } from "../types"
|
|||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||||
|
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||||
|
|
||||||
export enum StepType {
|
export enum StepType {
|
||||||
upload = "upload",
|
upload = "upload",
|
||||||
@@ -82,6 +84,31 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
onSubmit } = useRsi()
|
onSubmit } = useRsi()
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||||
const { toast } = useToast()
|
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(
|
const errorToast = useCallback(
|
||||||
(description: string) => {
|
(description: string) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -143,6 +170,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<SelectSheetStep
|
<SelectSheetStep
|
||||||
sheetNames={state.workbook.SheetNames}
|
sheetNames={state.workbook.SheetNames}
|
||||||
|
workbook={state.workbook}
|
||||||
onContinue={async (sheetName) => {
|
onContinue={async (sheetName) => {
|
||||||
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
|
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
|
||||||
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
|
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ export const ValidationContainer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Convert rows to sanity check format
|
// Convert rows to sanity check format
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => {
|
||||||
|
const product: ProductForSanityCheck = {
|
||||||
name: row.name as string | undefined,
|
name: row.name as string | undefined,
|
||||||
supplier: row.supplier as string | undefined,
|
supplier: row.supplier as string | undefined,
|
||||||
supplier_name: getFieldLabel('supplier', row.supplier),
|
supplier_name: getFieldLabel('supplier', row.supplier),
|
||||||
@@ -167,7 +168,15 @@ export const ValidationContainer = ({
|
|||||||
length: row.length as string | number | undefined,
|
length: row.length as string | number | undefined,
|
||||||
width: row.width as string | number | undefined,
|
width: row.width as string | number | undefined,
|
||||||
height: row.height 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
|
// 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 { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
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
|
* Renders a column header for MSRP or Cost Each with a hover button
|
||||||
* that fills empty cells based on the other price field.
|
* 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
|
* - Cost Each: Fill with MSRP ÷ 2
|
||||||
*
|
*
|
||||||
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||||
@@ -1505,14 +1507,32 @@ interface PriceColumnHeaderProps {
|
|||||||
isRequired: boolean;
|
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 PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const [hasFillableCells, setHasFillableCells] = 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 isMsrp = fieldKey === 'msrp';
|
||||||
const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp';
|
|
||||||
const tooltipText = fieldKey === 'msrp'
|
// Determine the source field
|
||||||
? 'Fill empty cells with Cost Each × 2'
|
const sourceField = isMsrp ? 'cost_each' : 'msrp';
|
||||||
|
const tooltipText = isMsrp
|
||||||
|
? 'Fill empty MSRP from Cost'
|
||||||
: 'Fill empty cells with MSRP ÷ 2';
|
: 'Fill empty cells with MSRP ÷ 2';
|
||||||
|
|
||||||
// Check if there are any cells that can be filled (called on hover)
|
// 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());
|
setHasFillableCells(checkFillableCells());
|
||||||
}, [checkFillableCells]);
|
}, [checkFillableCells]);
|
||||||
|
|
||||||
const handleCalculate = useCallback(() => {
|
const handleCalculateMsrp = useCallback((multiplier: number, roundNine: boolean) => {
|
||||||
const updatedIndices: number[] = [];
|
const updatedIndices: number[] = [];
|
||||||
|
|
||||||
// Use setState() for efficient batch update with Immer
|
// Use setState() for efficient batch update with Immer
|
||||||
@@ -1553,26 +1573,65 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
if (isEmpty && hasSource) {
|
if (isEmpty && hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
// Calculate the new value
|
let msrp = sourceNum * multiplier;
|
||||||
let newValue: string;
|
|
||||||
if (fieldKey === 'msrp') {
|
if (multiplier === 2.0) {
|
||||||
let msrp = sourceNum * 2;
|
// For 2x: auto-adjust by ±1 cent to get to .99 if close
|
||||||
// Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99)
|
const cents = Math.round((msrp % 1) * 100);
|
||||||
if (msrp === Math.floor(msrp)) {
|
if (cents === 0) {
|
||||||
|
// .00 → subtract 1 cent to get .99
|
||||||
msrp -= 0.01;
|
msrp -= 0.01;
|
||||||
|
} else if (cents === 98) {
|
||||||
|
// .98 → add 1 cent to get .99
|
||||||
|
msrp += 0.01;
|
||||||
}
|
}
|
||||||
newValue = msrp.toFixed(2);
|
// Otherwise leave as-is
|
||||||
} else {
|
} else if (roundNine) {
|
||||||
newValue = (sourceNum / 2).toFixed(2);
|
// 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);
|
updatedIndices.push(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear validation errors for all updated cells (removes "required" error styling)
|
|
||||||
if (updatedIndices.length > 0) {
|
if (updatedIndices.length > 0) {
|
||||||
const { clearFieldError } = useValidationStore.getState();
|
const { clearFieldError } = useValidationStore.getState();
|
||||||
updatedIndices.forEach((rowIndex) => {
|
updatedIndices.forEach((rowIndex) => {
|
||||||
@@ -1587,13 +1646,92 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 truncate w-full group relative"
|
className="flex items-center gap-1 truncate w-full group relative"
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => {
|
||||||
|
if (!isPopoverOpen) setIsHovered(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="">{label}</span>
|
<span className="">{label}</span>
|
||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
)}
|
)}
|
||||||
{isHovered && hasFillableCells && (
|
{(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>
|
||||||
|
)}
|
||||||
|
{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>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1601,7 +1739,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCalculate();
|
handleCalculateCostEach();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||||
@@ -1619,6 +1757,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1626,6 +1765,169 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
|
|
||||||
PriceColumnHeader.displayName = 'PriceColumnHeader';
|
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
|
* Main table component
|
||||||
*
|
*
|
||||||
@@ -1731,23 +2033,41 @@ export const ValidationTable = () => {
|
|||||||
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => {
|
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => {
|
||||||
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||||
|
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
||||||
|
|
||||||
return {
|
// Determine which header component to render
|
||||||
id: field.key,
|
const renderHeader = () => {
|
||||||
header: () => isPriceColumn ? (
|
if (isPriceColumn) {
|
||||||
|
return (
|
||||||
<PriceColumnHeader
|
<PriceColumnHeader
|
||||||
fieldKey={field.key as 'msrp' | 'cost_each'}
|
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
isRequired={isRequired}
|
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">
|
<div className="flex items-center gap-1 truncate">
|
||||||
<span className="truncate">{field.label}</span>
|
<span className="truncate">{field.label}</span>
|
||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: field.key,
|
||||||
|
header: renderHeader,
|
||||||
size: field.width || 150,
|
size: field.width || 150,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface ProductForSanityCheck {
|
|||||||
length?: string | number;
|
length?: string | number;
|
||||||
width?: string | number;
|
width?: string | number;
|
||||||
height?: 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
|
__original?: Record<string, unknown>; // Original values before AI changes
|
||||||
__corrected?: Record<string, unknown>; // AI-corrected values
|
__corrected?: Record<string, unknown>; // AI-corrected values
|
||||||
__changes?: Record<string, boolean>; // Fields changed by AI
|
__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)
|
// Standard fields (from config.ts)
|
||||||
supplier?: string;
|
supplier?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Data, Fields, Info, RowHook, TableHook, Meta, Errors } from "../../../types"
|
import type { Data, Fields, Info, RowHook, TableHook, Meta, Errors } from "../../../types"
|
||||||
import { v4 } from "uuid"
|
import { v4 } from "uuid"
|
||||||
import { ErrorSources, ErrorType } from "../../../types"
|
import { ErrorSources, ErrorType } from "../../../types"
|
||||||
|
import { normalizeCountryCode } from "./countryUtils"
|
||||||
|
|
||||||
|
|
||||||
type DataWithMeta<T extends string> = Data<T> & Meta & {
|
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) => {
|
fields.forEach((field) => {
|
||||||
const fieldKey = field.key as string
|
const fieldKey = field.key as string
|
||||||
field.validations?.forEach((validation) => {
|
field.validations?.forEach((validation) => {
|
||||||
|
|||||||
@@ -185,11 +185,18 @@ export function buildDescriptionValidationPayload(
|
|||||||
fields: Field<string>[],
|
fields: Field<string>[],
|
||||||
overrides?: PayloadOverrides
|
overrides?: PayloadOverrides
|
||||||
): DescriptionValidationPayload {
|
): DescriptionValidationPayload {
|
||||||
return {
|
const payload: DescriptionValidationPayload = {
|
||||||
name: overrides?.name ?? String(row.name || ''),
|
name: overrides?.name ?? String(row.name || ''),
|
||||||
description: overrides?.description ?? String(row.description || ''),
|
description: overrides?.description ?? String(row.description || ''),
|
||||||
company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined,
|
company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined,
|
||||||
company_id: row.company ? String(row.company) : undefined, // For backend prompt loading
|
company_id: row.company ? String(row.company) : undefined, // For backend prompt loading
|
||||||
categories: row.categories as string | undefined,
|
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 = {
|
export type SubmitOptions = {
|
||||||
targetEnvironment: "dev" | "prod"
|
targetEnvironment: "dev" | "prod"
|
||||||
useTestDataSource: boolean
|
useTestDataSource: boolean
|
||||||
|
skipApiSubmission?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RsiProps<T extends string> = {
|
export type RsiProps<T extends string> = {
|
||||||
|
|||||||
@@ -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 useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
|
||||||
|
|
||||||
const liveDashboardConfig = {
|
const liveDashboardConfig = {
|
||||||
auth: isDev || useProxy ? '/dashboard-auth' : 'https://acob.acherryontop.com/auth',
|
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
||||||
aircall: isDev || useProxy ? '/api/aircall' : 'https://acob.acherryontop.com/api/aircall',
|
aircall: isDev || useProxy ? '/api/aircall' : 'https://tools.acherryontop.com/api/aircall',
|
||||||
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://acob.acherryontop.com/api/klaviyo',
|
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://tools.acherryontop.com/api/klaviyo',
|
||||||
meta: isDev || useProxy ? '/api/meta' : 'https://acob.acherryontop.com/api/meta',
|
meta: isDev || useProxy ? '/api/meta' : 'https://tools.acherryontop.com/api/meta',
|
||||||
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://acob.acherryontop.com/api/gorgias',
|
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://tools.acherryontop.com/api/gorgias',
|
||||||
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://acob.acherryontop.com/api/analytics',
|
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://tools.acherryontop.com/api/analytics',
|
||||||
typeform: isDev || useProxy ? '/api/typeform' : 'https://acob.acherryontop.com/api/typeform',
|
typeform: isDev || useProxy ? '/api/typeform' : 'https://tools.acherryontop.com/api/typeform',
|
||||||
acot: isDev || useProxy ? '/api/acot' : 'https://acob.acherryontop.com/api/acot',
|
acot: isDev || useProxy ? '/api/acot' : 'https://tools.acherryontop.com/api/acot',
|
||||||
clarity: isDev || useProxy ? '/api/clarity' : 'https://acob.acherryontop.com/api/clarity'
|
clarity: isDev || useProxy ? '/api/clarity' : 'https://tools.acherryontop.com/api/clarity'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default liveDashboardConfig;
|
export default liveDashboardConfig;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useContext } from "react";
|
import { useState, useContext, useMemo } from "react";
|
||||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||||
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Code } from "@/components/ui/code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import config from "@/config";
|
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 { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||||
import { AuthContext } from "@/contexts/AuthContext";
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||||
|
|
||||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
||||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||||
@@ -264,8 +265,11 @@ export function Import() {
|
|||||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
|
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
|
||||||
|
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(null);
|
||||||
const { user } = useContext(AuthContext);
|
const { user } = useContext(AuthContext);
|
||||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// ========== TEMPORARY TEST DATA ==========
|
// ========== TEMPORARY TEST DATA ==========
|
||||||
// Uncomment the useEffect below to test the results page without submitting actual 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 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, "");
|
const digitsOnly = expanded.replace(/[^0-9]/g, "");
|
||||||
return digitsOnly || expanded;
|
return digitsOnly || expanded;
|
||||||
};
|
};
|
||||||
@@ -732,6 +740,38 @@ export function Import() {
|
|||||||
} as NormalizedProduct;
|
} 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({
|
const response = await submitNewProducts({
|
||||||
products: formattedRows,
|
products: formattedRows,
|
||||||
environment: submitOptions?.targetEnvironment ?? "prod",
|
environment: submitOptions?.targetEnvironment ?? "prod",
|
||||||
@@ -824,6 +864,8 @@ export function Import() {
|
|||||||
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
|
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
|
||||||
url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null,
|
url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null,
|
||||||
pid: pidValue,
|
pid: pidValue,
|
||||||
|
// Store index to access full product data for template saving
|
||||||
|
submittedProductIndex: productIndex,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -918,6 +960,86 @@ export function Import() {
|
|||||||
setIsOpen(true);
|
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) {
|
if (isLoadingOptions) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-6">
|
<div className="container mx-auto py-6">
|
||||||
@@ -1084,12 +1206,28 @@ export function Import() {
|
|||||||
<span className="text-sm font-medium">{product.name}</span>
|
<span className="text-sm font-medium">{product.name}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
UPC: {product.upc}
|
UPC: {product.upc}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1167,6 +1305,19 @@ export function Import() {
|
|||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Template Save Dialog */}
|
||||||
|
<TemplateForm
|
||||||
|
isOpen={templateSaveDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setTemplateSaveDialogOpen(false);
|
||||||
|
setSelectedProductForTemplate(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handleTemplateSaveSuccess}
|
||||||
|
initialData={templateFormData}
|
||||||
|
mode="create"
|
||||||
|
fieldOptions={templateFieldOptions}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user