2 Commits

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

View File

@@ -0,0 +1,375 @@
# Product Import Module - Enhancement & Issues Outline
This document outlines the investigation and implementation requirements for each requested enhancement to the product import module.
---
## 1. UPC Import - Strip Quotes and Spaces ✅ IMPLEMENTED
**Issue:** When importing UPCs, strip `'`, `"` characters and any spaces, leaving only numbers.
**Implementation (Completed):**
- Modified `normalizeUpcValue()` in [Import.tsx:661-667](inventory/src/pages/Import.tsx#L661-L667)
- Strips single quotes, double quotes, smart quotes (`'"`), and whitespace before processing
- Then handles scientific notation and extracts only digits
**Files Modified:**
- `inventory/src/pages/Import.tsx` - `normalizeUpcValue()` function
---
## 2. AI Context Columns in Validation Payloads ✅ IMPLEMENTED
**Issue:** The match columns step has a setting to use a field only for AI context (`isAiSupplemental`). Update AI description validation to include any columns selected with this option in the payload. Also include in sanity check payload. Not needed for names.
**Current Implementation:**
- AI Supplemental toggle: [MatchColumnsStep.tsx:102-118](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L102-L118)
- AI supplemental data stored in `__aiSupplemental` field on each row
- Description payload builder: [inlineAiPayload.ts:183-195](inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts#L183-L195)
**Implementation:**
1. **Update `buildDescriptionValidationPayload()` in `inlineAiPayload.ts`** to include AI supplemental data:
```typescript
export const buildDescriptionValidationPayload = (
row: Data<string>,
fieldOptions: FieldOptionsMap,
productLinesCache: Map<string, SelectOption[]>,
sublinesCache: Map<string, SelectOption[]>
) => {
const payload: Record<string, unknown> = {
name: row.name,
description: row.description,
company_name: getFieldOptionLabel(row.company, fieldOptions, 'company'),
company_id: row.company,
categories: getFieldOptionLabel(row.category, fieldOptions, 'category'),
};
// Add AI supplemental context if present
if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') {
payload.additional_context = row.__aiSupplemental;
}
return payload;
};
```
2. **Update sanity check payload** - Locate sanity check submission logic and include `__aiSupplemental` data
3. **Verify `__aiSupplemental` is properly populated** from MatchColumnsStep when columns are marked as AI context only
**Files to Modify:**
- `inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts`
- Backend sanity check endpoint (if separate from description validation)
- Verify data flow in `MatchColumnsStep.tsx` → `ValidationStep`
---
## 3. Fresh Taxonomy Data Per Session ✅ IMPLEMENTED
**Issue:** Ensure taxonomy data is brought in fresh with each session - cache should be invalidated if we exit the import flow and start again.
**Current Implementation:**
- Field options cached 5 minutes: [ValidationStep/index.tsx:128-133](inventory/src/components/product-import/steps/ValidationStep/index.tsx#L128-L133)
- Product lines cache: `productLinesCache` in Zustand store
- Sublines cache: `sublinesCache` in Zustand store
- Caches set to 10-minute stale time
**Implementation:**
1. **Add cache invalidation on import flow mount/unmount** in `UploadFlow.tsx`:
```typescript
useEffect(() => {
// On mount - invalidate import-related query cache
queryClient.invalidateQueries({ queryKey: ['import-field-options'] });
return () => {
// On unmount - clear caches
queryClient.removeQueries({ queryKey: ['import-field-options'] });
queryClient.removeQueries({ queryKey: ['product-lines'] });
queryClient.removeQueries({ queryKey: ['sublines'] });
};
}, []);
```
2. **Clear Zustand store caches** when exiting import flow:
- Add action to `validationStore.ts` to clear `productLinesCache` and `sublinesCache`
- Call this action on unmount of `UploadFlow` or when navigating away
3. **Consider adding a `sessionId`** that changes on each import flow start, used as part of cache keys
**Files to Modify:**
- `inventory/src/components/product-import/steps/UploadFlow.tsx` - Add cleanup effect
- `inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts` - Add cache clear action
- Potentially `inventory/src/components/product-import/steps/ValidationStep/index.tsx` - Query key updates
---
## 4. Save Template from Confirmation Page ✅ IMPLEMENTED
**Issue:** Add option to save rows of submitted data as a new template on the confirmation page after completing the import flow. Verify this works with new validation step changes.
**Current Implementation:**
- **Import Results section already exists** inline in [Import.tsx:968-1150](inventory/src/pages/Import.tsx#L968-L1150)
- Shows created products (lines 1021-1097) with image, name, UPC, item number
- Shows errored products (lines 1100-1138) with error details
- "Fix products with errors" button resumes validation flow for failed items
- Template saving logic in ValidationStep: [useTemplateManagement.ts:204-266](inventory/src/components/product-import/steps/ValidationStep/hooks/useTemplateManagement.ts#L204-L266)
- Saves via `POST /api/templates`
- `importOutcome.submittedProducts` contains the full product data for each row
**Implementation:**
1. **Add "Save as Template" button** to each created product row in the results section (around line 1087-1092 in Import.tsx):
```typescript
// Add button after the item number display
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveAsTemplate(index)}
>
<BookmarkPlus className="h-4 w-4" />
</Button>
```
2. **Add state and dialog** for template saving in Import.tsx:
```typescript
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(null);
```
3. **Extract/reuse template save logic** from `useTemplateManagement.ts`:
- The `saveNewTemplate()` function (lines 204-266) can be extracted into a shared utility
- Or create a `SaveTemplateDialog` component that can be used in both places
- Key fields needed: `company` (for template name), `product_type`, and all product field values
4. **Data mapping consideration:**
- `importOutcome.submittedProducts` uses `NormalizedProduct` type
- Templates expect raw field values - may need to map back from normalized format
- Exclude metadata fields: `['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes', '__aiSupplemental']`
**Files to Modify:**
- `inventory/src/pages/Import.tsx` - Add save template button, state, and dialog
- Consider creating `inventory/src/components/product-import/SaveTemplateDialog.tsx` for reusability
- Potentially extract core save logic from `useTemplateManagement.ts` into shared utility
---
## 5. Sheet Preview on Select Sheet Step ✅ IMPLEMENTED
**Issue:** On the select sheet step, show a preview of the first 10 lines or so of each sheet underneath the options.
**Implementation (Completed):**
- Added `workbook` prop to `SelectSheetStep` component
- Added `sheetPreviews` memoized computation using `XLSXLib.utils.sheet_to_json()`
- Shows first 10 rows, 8 columns max per sheet
- Added `truncateCell()` helper to limit cell content to 30 characters with ellipsis
- Each sheet option is now a clickable card with:
- Radio button and sheet name
- Row count indicator
- Scrollable preview table with horizontal scroll
- Selected state highlighted with primary border
- Updated `UploadFlow.tsx` to pass workbook prop
**Files Modified:**
- `inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx`
- `inventory/src/components/product-import/steps/UploadFlow.tsx`
---
## 6. Empty Row Removal ✅ IMPLEMENTED
**Issue:** When importing a sheet, automatically remove completely empty rows.
**Current Implementation:**
- Empty columns are filtered: [MatchColumnsStep.tsx:616-634](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L616-L634)
- A "Remove empty/duplicates" button exists that removes empty rows, single-value rows, AND duplicates
- The automatic removal should ONLY remove completely empty rows, not duplicates or single-value rows
**Implementation (Completed):**
- Added `isRowCompletelyEmpty()` helper function to [SelectHeaderStep.tsx](inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx)
- Added `useMemo` to filter empty rows on initial data load
- Uses `Object.values(row)` to check all cell values (matches existing button logic)
- Only removes rows where ALL values are undefined, null, or whitespace-only strings
- Manual "Remove Empty/Duplicates" button still available for additional cleanup (duplicates, single-value rows)
**Files Modified:**
- `inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx`
---
## 7. Unit Conversion for Weight/Dimensions ✅ IMPLEMENTED
**Issue:** Add unit conversion feature for weight and dimensions columns - similar to calculator button on cost/msrp, add button that opens popover with options to convert grams → oz, lbs → oz for the whole column at once.
**Current Implementation:**
- Calculator button on price columns: [ValidationTable.tsx:1491-1627](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1491-L1627)
- `PriceColumnHeader` component shows calculator icon on hover
- Weight field defined in config with validation
**Implementation:**
1. **Create `UnitConversionColumnHeader` component** (similar to `PriceColumnHeader`):
```typescript
const UnitConversionColumnHeader = ({ field, table }) => {
const [showPopover, setShowPopover] = useState(false);
const conversions = {
weight: [
{ label: 'Grams → Ounces', factor: 0.035274 },
{ label: 'Pounds → Ounces', factor: 16 },
{ label: 'Kilograms → Ounces', factor: 35.274 },
],
dimensions: [
{ label: 'Centimeters → Inches', factor: 0.393701 },
{ label: 'Millimeters → Inches', factor: 0.0393701 },
]
};
const applyConversion = (factor: number) => {
// Batch update all cells in column
table.rows.forEach((row, index) => {
const currentValue = parseFloat(row[field.key]);
if (!isNaN(currentValue)) {
updateCell(index, field.key, (currentValue * factor).toFixed(2));
}
});
};
return (
<Popover open={showPopover} onOpenChange={setShowPopover}>
<PopoverTrigger>
<Scale className="h-4 w-4" /> {/* or similar icon */}
</PopoverTrigger>
<PopoverContent>
{conversions[fieldType].map(conv => (
<Button key={conv.label} onClick={() => applyConversion(conv.factor)}>
{conv.label}
</Button>
))}
</PopoverContent>
</Popover>
);
};
```
2. **Identify weight/dimension fields** in config:
- `weight_oz`, `length_in`, `width_in`, `height_in` (check actual field keys)
3. **Add to column header render logic** in ValidationTable
**Files to Modify:**
- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx`
- Potentially create new component file for `UnitConversionColumnHeader`
- Update column header rendering to use new component for weight/dimension fields
---
## 8. Expanded MSRP Auto-Fill from Cost ✅ IMPLEMENTED
**Issue:** Expand auto-fill functionality for MSRP from cost - open small popover with options for 2x, 2.1x, 2.2x, 2.3x, 2.4x, 2.5x multipliers, plus checkbox to round up to nearest 9.
**Current Implementation:**
- Calculator on MSRP column: [ValidationTable.tsx:1540-1584](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1540-L1584)
- Currently only does `Cost × 2` then subtracts 0.01 if whole number
**Implementation:**
1. **Replace simple click with popover** in `PriceColumnHeader`:
```typescript
const [selectedMultiplier, setSelectedMultiplier] = useState(2.0);
const [roundToNine, setRoundToNine] = useState(false);
const multipliers = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
const roundUpToNine = (value: number): number => {
// 1.41 → 1.49, 2.78 → 2.79, 12.32 → 12.39
const wholePart = Math.floor(value);
const decimal = value - wholePart;
if (decimal <= 0.09) return wholePart + 0.09;
if (decimal <= 0.19) return wholePart + 0.19;
// ... continue pattern, or:
const lastDigit = Math.floor(decimal * 10);
return wholePart + (lastDigit / 10) + 0.09;
};
const calculateMsrp = (cost: number): number => {
let result = cost * selectedMultiplier;
if (roundToNine) {
result = roundUpToNine(result);
}
return result;
};
```
2. **Create popover UI**:
```tsx
<Popover>
<PopoverTrigger><Calculator className="h-4 w-4" /></PopoverTrigger>
<PopoverContent className="w-48">
<div className="space-y-2">
<Label>Multiplier</Label>
<div className="grid grid-cols-3 gap-1">
{multipliers.map(m => (
<Button
key={m}
variant={selectedMultiplier === m ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMultiplier(m)}
>
{m}x
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Checkbox checked={roundToNine} onCheckedChange={setRoundToNine} />
<Label>Round to .X9</Label>
</div>
<Button onClick={applyCalculation} className="w-full">
Apply
</Button>
</div>
</PopoverContent>
</Popover>
```
**Files to Modify:**
- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx` - `PriceColumnHeader` component
---
## 9. Debug Mode - Skip API Submission ✅ IMPLEMENTED
**Issue:** Add a third switch in the footer of image upload step (visible only to users with `admin:debug` permission) that will not submit data to any API, only complete the process and show results page as if it had worked.
**Implementation (Completed):**
- Added `skipApiSubmission` state to `ImageUploadStep.tsx`
- Added amber-colored "Skip API (Debug)" switch (visible only with `admin:debug` permission)
- When skip is active, "Use Test API" and "Use Test Database" switches are hidden
- Added `skipApiSubmission?: boolean` to `SubmitOptions` type in `types.ts`
- In `Import.tsx`, when `skipApiSubmission` is true:
- Skips the actual API call entirely
- Generates mock success response with mock PIDs
- Shows `[DEBUG]` prefix in toast and result message
- Displays results page as if submission succeeded
**Files Modified:**
- `inventory/src/components/product-import/types.ts` - Added `skipApiSubmission` to `SubmitOptions`
- `inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx` - Added switch UI
- `inventory/src/pages/Import.tsx` - Added skip logic in `handleData()`
---
## Summary
| # | Enhancement | Complexity | Status |
|---|-------------|------------|--------|
| 1 | Strip UPC quotes/spaces | Low | ✅ Implemented |
| 2 | AI context in validation | Medium | ✅ Implemented |
| 3 | Fresh taxonomy per session | Medium | ✅ Implemented |
| 4 | Save template from confirmation | Medium-High | ✅ Implemented |
| 5 | Sheet preview | Low-Medium | ✅ Implemented |
| 6 | Remove empty rows | Low | ✅ Implemented |
| 7 | Unit conversion | Medium | ✅ Implemented |
| 8 | MSRP multiplier options | Medium | ✅ Implemented |
| 9 | Debug skip API | Low-Medium | ✅ Implemented |
**Implemented:** 9 of 9 items - All enhancements complete!
---
*Document generated: 2026-01-25*

View File

@@ -35,7 +35,7 @@ global.pool = pool;
app.use(express.json()); app.use(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
})); }));

View File

@@ -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
})); }));

View File

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

View File

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

View File

@@ -635,7 +635,7 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
// Create URL for the uploaded file - using an absolute URL with domain // 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

View File

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

View File

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

View File

@@ -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,27 +299,43 @@ 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">
<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"> <div className="flex items-center gap-1">
<Switch <Switch
id="product-import-api-environment" id="product-import-skip-api"
checked={targetEnvironment === "dev"} checked={skipApiSubmission}
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")} onCheckedChange={(checked) => setSkipApiSubmission(checked)}
/> />
<div> <div>
<Label htmlFor="product-import-api-environment" className="text-sm font-medium"> <Label htmlFor="product-import-skip-api" className="text-sm font-medium text-amber-600">
Use test API Skip API (Debug)
</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> </Label>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -1,21 +1,63 @@
import { useCallback, useState } from "react" import { useCallback, useState, useMemo } from "react"
import type XLSX from "xlsx"
import * as XLSXLib from "xlsx"
import { useRsi } from "../../hooks/useRsi" import { 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] || []
<RadioGroupItem value={sheetName} id={sheetName} /> const isSelected = value === sheetName
<Label
htmlFor={sheetName} return (
className="text-base" <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} <div className="flex items-center space-x-2 mb-3">
</Label> <RadioGroupItem value={sheetName} id={sheetName} />
</div> <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> </RadioGroup>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from "react" import { useCallback, useState, useEffect } from "react"
import type XLSX from "xlsx" import 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()))

View File

@@ -146,28 +146,37 @@ export const ValidationContainer = ({
}; };
// Convert rows to sanity check format // Convert rows to sanity check format
return rows.map((row) => ({ return rows.map((row) => {
name: row.name as string | undefined, const product: ProductForSanityCheck = {
supplier: row.supplier as string | undefined, name: row.name as string | undefined,
supplier_name: getFieldLabel('supplier', row.supplier), supplier: row.supplier as string | undefined,
company: row.company as string | undefined, supplier_name: getFieldLabel('supplier', row.supplier),
company_name: getFieldLabel('company', row.company), company: row.company as string | undefined,
supplier_no: row.supplier_no as string | undefined, company_name: getFieldLabel('company', row.company),
msrp: row.msrp as string | number | undefined, supplier_no: row.supplier_no as string | undefined,
cost_each: row.cost_each as string | number | undefined, msrp: row.msrp as string | number | undefined,
qty_per_unit: row.qty_per_unit as string | number | undefined, cost_each: row.cost_each as string | number | undefined,
case_qty: row.case_qty as string | number | undefined, qty_per_unit: row.qty_per_unit as string | number | undefined,
tax_cat: row.tax_cat as string | number | undefined, case_qty: row.case_qty as string | number | undefined,
tax_cat_name: getFieldLabel('tax_cat', row.tax_cat), tax_cat: row.tax_cat as string | number | undefined,
size_cat: row.size_cat as string | number | undefined, tax_cat_name: getFieldLabel('tax_cat', row.tax_cat),
size_cat_name: getFieldLabel('size_cat', row.size_cat), size_cat: row.size_cat as string | number | undefined,
themes: row.themes as string | undefined, size_cat_name: getFieldLabel('size_cat', row.size_cat),
categories: row.categories as string | undefined, themes: row.themes as string | undefined,
weight: row.weight as string | number | undefined, categories: row.categories as string | undefined,
length: row.length as string | number | undefined, weight: row.weight as string | number | undefined,
width: row.width as string | number | undefined, length: row.length as string | number | undefined,
height: row.height 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 // Handle viewing cached sanity check results

View File

@@ -16,7 +16,9 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { 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,38 +1646,118 @@ 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 && (
<TooltipProvider> isMsrp ? (
<Tooltip> // MSRP: Show popover with multiplier options
<TooltipTrigger asChild> <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<button <TooltipProvider>
type="button" <Tooltip>
onClick={(e) => { <TooltipTrigger asChild>
e.stopPropagation(); <PopoverTrigger asChild>
handleCalculate(); <button
}} type="button"
className={cn( onClick={(e) => e.stopPropagation()}
'absolute right-1 top-1/2 -translate-y-1/2', className={cn(
'flex items-center gap-0.5', 'absolute right-1 top-1/2 -translate-y-1/2',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm', 'flex items-center gap-0.5',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground', 'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'transition-opacity' '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 && (
<Calculator className="h-3 w-3" /> <p className="text-xs text-muted-foreground">
</button> Auto-adjusts ±1¢ for .99 pricing
</TooltipTrigger> </p>
<TooltipContent side="bottom"> )}
<p>{tooltipText}</p> <Button
</TooltipContent> size="sm"
</Tooltip> className="w-full h-7 text-xs"
</TooltipProvider> 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> </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) {
<PriceColumnHeader return (
fieldKey={field.key as 'msrp' | 'cost_each'} <PriceColumnHeader
label={field.label} fieldKey={field.key as 'msrp' | 'cost_each'}
isRequired={isRequired} 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"> <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,
}; };
}); });

View File

@@ -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
} }
/** /**

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -5,15 +5,15 @@ const isLocal = window.location.hostname === 'localhost' || window.location.host
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site'); const 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;

View File

@@ -1,4 +1,4 @@
import { useState, useContext } from "react"; import { useState, useContext, useMemo } from "react";
import { ReactSpreadsheetImport, StepType } from "@/components/product-import"; import { 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,11 +1206,27 @@ 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 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> </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>
</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