46 Commits

Author SHA1 Message Date
387e7e5e73 Clean up 2025-03-22 21:05:24 -04:00
a51a48ce89 Fix item number not getting updated when applying template 2025-03-22 20:55:34 -04:00
aacb3a2fd0 Fix validating required cells when applying template 2025-03-22 17:21:27 -04:00
35d2f0df7c Refactor validation hooks into smaller files 2025-03-21 00:33:06 -04:00
7d46ebd6ba Add skeleton loading state to template field, remove duplicated or unused code in validate step hooks 2025-03-19 14:30:39 -04:00
1496aa57b1 Remove remaining chakra-ui dependencies, clean up files, clean up build errors, move react-spreadsheet-import directory into main component structure 2025-03-19 12:56:56 -04:00
fc9ef2f0d7 Style tweaks, fix image uploads, refactor image upload step into smaller files 2025-03-19 11:27:26 -04:00
af067f7360 Fix line and subline showing as inputs instead of selects 2025-03-18 15:27:55 -04:00
949b543d1f Fix issues with validation errors showing and problems with concurrent editing, improve scroll position saving 2025-03-18 12:38:23 -04:00
8fdb68fb19 Move UPC validation table adapter out of ValidationContainer 2025-03-17 16:36:26 -04:00
136f767309 Move product line fetching out of ValidationContainer, clean up some unused files 2025-03-17 16:24:47 -04:00
aa9664c459 Move UPC validation out of ValidationContainer, add code lines tracking 2025-03-17 16:03:21 -04:00
f60f0b1b5c Merge separate itemNumberCell in to ValidationCell 2025-03-17 14:18:49 -04:00
676cd44d9d Clean up linter errors and add sequential thinking 2025-03-17 14:13:22 -04:00
1d081bb218 Optimize error processing and re-rendering in ValidationCell component. Implemented a centralized processErrors function, improved memoization, and enhanced batch updates to reduce redundancy and improve performance. 2025-03-16 15:25:23 -04:00
52ae7e10aa Refactor validation error handling to use a single source of truth (validationErrors Map), speed up item number generation and loading lines/sublines 2025-03-16 14:09:58 -04:00
153bbecc44 Add standardized error handling with new enums and interfaces for validation errors 2025-03-15 22:11:36 -04:00
cb46970808 Restore line and subline fields 2025-03-15 18:50:33 -04:00
97fa7f3495 Update doc 2025-03-14 19:25:36 -04:00
a88dbb8486 Remove artificial delays from copydown function, fix issues with select components, ensure pointer cursor shows in copydown state, ensure table scroll position is reset on unmount 2025-03-14 19:23:47 -04:00
d0a83c04ca Improve copy down functionality with loading state and ability to select end cell instead of defaulting to the bottom 2025-03-14 16:59:07 -04:00
f95c1f2d43 Set UPC validation loading state to only show on item number field 2025-03-14 01:32:27 -04:00
0ef27a3229 Fix text overflowing template dropdown trigger, add new MultilineInput component with popover for editing, remove MultiInputCell component except for code to create new MultiSelectCell component 2025-03-14 00:44:44 -04:00
0f89373d11 Fix horizontal scrollbar, rearrange error and copy icons in cells 2025-03-13 00:27:36 -04:00
f55d35e301 Fix row highlighting, header alignment, make header sticky 2025-03-11 21:08:02 -04:00
1aee18a025 More validation table optimizations + create doc to track remaining fixes 2025-03-11 16:21:17 -04:00
0068d77ad9 Optimize validation table 2025-03-10 21:59:24 -04:00
b69182e2c7 Fix validation table scroll location saving issues 2025-03-10 00:17:55 -04:00
1c8709f520 Rearrange docs 2025-03-09 22:07:14 -04:00
de1408bd58 Fix validation indicators on validation step table 2025-03-09 17:44:03 -04:00
c295c330ff Add copy down functionality to validate table 2025-03-09 16:30:11 -04:00
7cc723ce83 Fix creating template from validate table row 2025-03-09 16:11:49 -04:00
c3c48669ad Fix data coming in correctly when copying template from an existing product, automatically strip out deals and black friday categories 2025-03-09 15:38:13 -04:00
78a0018940 Fix total sold count in search-products endpoint 2025-03-09 14:12:32 -04:00
851cc3c4cc Fix product search dialog for adding templates, pull out component to use independently, add to template management settings page 2025-03-09 13:42:33 -04:00
74454cdc7f Show templates from all brands when selected brand has no templates 2025-03-08 14:34:49 -05:00
31c838197a Optimize validation table 2025-03-08 14:30:11 -05:00
45fa583ce8 Fix validation again I hope? 2025-03-08 12:23:43 -05:00
c96f514bcd Fix dropdown scrolling and keep multi-selects open 2025-03-08 11:12:42 -05:00
6a5e6d2bfb Style tweaks, fix section hiding in map columns step 2025-03-07 22:23:42 -05:00
875d0b8f55 Fix applying templates to or discarding multiple rows 2025-03-07 16:16:57 -05:00
b15387041b Fix validation timing issues with templates 2025-03-07 15:30:09 -05:00
60cdb1cee3 Fix navigating between steps on start from scratch flow 2025-03-06 18:55:02 -05:00
52fd47a921 Fix templates loading on page load 2025-03-06 13:38:11 -05:00
b723ec3c0f Clean up old validationstep, clean up various type errors 2025-03-06 12:04:35 -05:00
68ca7e93a1 Fix dropdown values saving, add back checkbox column, mostly fix validation, fix some field types 2025-03-06 01:45:05 -05:00
171 changed files with 14592 additions and 40330 deletions

View File

@@ -0,0 +1,41 @@
# Details
Date : 2025-03-17 16:24:17
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 26 files, 6193 codes, 1008 comments, 1017 blanks, all 8218 lines
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md) | Markdown | 39 | 0 | 19 | 58 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx) | TypeScript JSX | 230 | 10 | 8 | 248 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx) | TypeScript JSX | 18 | 0 | 3 | 21 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx) | TypeScript JSX | 273 | 19 | 37 | 329 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx) | TypeScript JSX | 374 | 42 | 44 | 460 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | 730 | 126 | 106 | 962 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx) | TypeScript JSX | 499 | 48 | 54 | 601 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx) | TypeScript JSX | 112 | 12 | 21 | 145 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx) | TypeScript JSX | 232 | 31 | 32 | 295 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx) | TypeScript JSX | 407 | 56 | 52 | 515 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx) | TypeScript JSX | 193 | 23 | 22 | 238 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx) | TypeScript JSX | 289 | 36 | 31 | 356 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx) | TypeScript JSX | 500 | 75 | 89 | 664 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx) | TypeScript JSX | 248 | 69 | 74 | 391 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx) | TypeScript JSX | 204 | 26 | 33 | 263 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx) | TypeScript JSX | 209 | 49 | 50 | 308 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx) | TypeScript JSX | 219 | 39 | 47 | 305 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx) | TypeScript JSX | 1,060 | 228 | 229 | 1,517 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx) | TypeScript JSX | 20 | 6 | 2 | 28 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts) | TypeScript | 4 | 0 | 1 | 5 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts) | TypeScript | 16 | 4 | 4 | 24 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts) | TypeScript | 124 | 4 | 14 | 142 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts) | TypeScript | 21 | 15 | 5 | 41 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts) | TypeScript | 43 | 24 | 7 | 74 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js) | JavaScript | 28 | 7 | 9 | 44 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts) | TypeScript | 101 | 59 | 24 | 184 |
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,20 @@
# Diff Details
Date : 2025-03-17 16:24:17
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 5 files, -358 codes, -15 comments, -33 blanks, all -406 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SaveTemplateDialog.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SaveTemplateDialog.tsx) | TypeScript JSX | -83 | 0 | -4 | -87 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx) | TypeScript JSX | -193 | -4 | -15 | -212 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | -241 | -68 | -72 | -381 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useFilters.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useFilters.tsx) | TypeScript JSX | -89 | -12 | -16 | -117 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx) | TypeScript JSX | 248 | 69 | 74 | 391 |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@@ -0,0 +1,23 @@
# Diff Summary
Date : 2025-03-17 16:24:17
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 5 files, -358 codes, -15 comments, -33 blanks, all -406 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 5 | -358 | -15 | -33 | -406 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 5 | -358 | -15 | -33 | -406 |
| components | 3 | -517 | -72 | -91 | -680 |
| hooks | 2 | 159 | 57 | 58 | 274 |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,31 @@
Date : 2025-03-17 16:24:17
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 5 files, -358 codes, -15 comments, -33 blanks, all -406 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 5 | -358 | -15 | -33 | -406 |
+----------------+------------+------------+------------+------------+------------+
Directories
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 5 | -358 | -15 | -33 | -406 |
| components | 3 | -517 | -72 | -91 | -680 |
| hooks | 2 | 159 | 57 | 58 | 274 |
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SaveTemplateDialog.tsx | TypeScript JSX | -83 | 0 | -4 | -87 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx | TypeScript JSX | -193 | -4 | -15 | -212 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | -241 | -68 | -72 | -381 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useFilters.tsx | TypeScript JSX | -89 | -12 | -16 | -117 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx | TypeScript JSX | 248 | 69 | 74 | 391 |
| Total | | -358 | -15 | -33 | -406 |
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
# Summary
Date : 2025-03-17 16:24:17
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 26 files, 6193 codes, 1008 comments, 1017 blanks, all 8218 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 18 | 5,817 | 895 | 934 | 7,646 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 26 | 6,193 | 1,008 | 1,017 | 8,218 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 11 | 3,357 | 403 | 410 | 4,170 |
| components (Files) | 6 | 2,124 | 245 | 252 | 2,621 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,440 | 486 | 522 | 3,448 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,60 @@
Date : 2025-03-17 16:24:17
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 26 files, 6193 codes, 1008 comments, 1017 blanks, all 8218 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 18 | 5,817 | 895 | 934 | 7,646 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
+----------------+------------+------------+------------+------------+------------+
Directories
+------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 26 | 6,193 | 1,008 | 1,017 | 8,218 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 11 | 3,357 | 403 | 410 | 4,170 |
| components (Files) | 6 | 2,124 | 245 | 252 | 2,621 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,440 | 486 | 522 | 3,448 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
+------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md | Markdown | 39 | 0 | 19 | 58 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx | TypeScript JSX | 230 | 10 | 8 | 248 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx | TypeScript JSX | 18 | 0 | 3 | 21 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx | TypeScript JSX | 273 | 19 | 37 | 329 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx | TypeScript JSX | 374 | 42 | 44 | 460 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | 730 | 126 | 106 | 962 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx | TypeScript JSX | 499 | 48 | 54 | 601 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx | TypeScript JSX | 112 | 12 | 21 | 145 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx | TypeScript JSX | 232 | 31 | 32 | 295 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx | TypeScript JSX | 407 | 56 | 52 | 515 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx | TypeScript JSX | 193 | 23 | 22 | 238 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx | TypeScript JSX | 289 | 36 | 31 | 356 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx | TypeScript JSX | 500 | 75 | 89 | 664 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx | TypeScript JSX | 248 | 69 | 74 | 391 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx | TypeScript JSX | 204 | 26 | 33 | 263 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx | TypeScript JSX | 209 | 49 | 50 | 308 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx | TypeScript JSX | 219 | 39 | 47 | 305 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx | TypeScript JSX | 1,060 | 228 | 229 | 1,517 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx | TypeScript JSX | 20 | 6 | 2 | 28 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts | TypeScript | 4 | 0 | 1 | 5 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts | TypeScript | 16 | 4 | 4 | 24 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts | TypeScript | 124 | 4 | 14 | 142 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts | TypeScript | 21 | 15 | 5 | 41 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts | TypeScript | 43 | 24 | 7 | 74 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js | JavaScript | 28 | 7 | 9 | 44 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts | TypeScript | 101 | 59 | 24 | 184 |
| Total | | 6,193 | 1,008 | 1,017 | 8,218 |
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

View File

@@ -0,0 +1,42 @@
# Details
Date : 2025-03-18 12:39:04
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6925 codes, 1247 comments, 1248 blanks, all 9420 lines
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md) | Markdown | 39 | 0 | 19 | 58 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx) | TypeScript JSX | 230 | 10 | 8 | 248 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx) | TypeScript JSX | 18 | 0 | 3 | 21 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx) | TypeScript JSX | 273 | 19 | 37 | 329 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx) | TypeScript JSX | 113 | 17 | 10 | 140 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx) | TypeScript JSX | 377 | 49 | 54 | 480 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | 969 | 182 | 158 | 1,309 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx) | TypeScript JSX | 509 | 50 | 57 | 616 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx) | TypeScript JSX | 112 | 12 | 21 | 145 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx) | TypeScript JSX | 233 | 34 | 33 | 300 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx) | TypeScript JSX | 420 | 66 | 59 | 545 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx) | TypeScript JSX | 193 | 23 | 22 | 238 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx) | TypeScript JSX | 227 | 36 | 32 | 295 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx) | TypeScript JSX | 500 | 75 | 89 | 664 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx) | TypeScript JSX | 264 | 75 | 81 | 420 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx) | TypeScript JSX | 204 | 26 | 33 | 263 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx) | TypeScript JSX | 337 | 88 | 92 | 517 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx) | TypeScript JSX | 360 | 78 | 85 | 523 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx) | TypeScript JSX | 1,190 | 288 | 289 | 1,767 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx) | TypeScript JSX | 20 | 6 | 2 | 28 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts) | TypeScript | 4 | 0 | 1 | 5 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts) | TypeScript | 16 | 4 | 4 | 24 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts) | TypeScript | 124 | 4 | 14 | 142 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts) | TypeScript | 21 | 15 | 5 | 41 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts) | TypeScript | 43 | 24 | 7 | 74 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js) | JavaScript | 28 | 7 | 9 | 44 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts) | TypeScript | 101 | 59 | 24 | 184 |
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,26 @@
# Diff Details
Date : 2025-03-18 12:39:04
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 11 files, 732 codes, 239 comments, 231 blanks, all 1202 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx) | TypeScript JSX | 113 | 17 | 10 | 140 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx) | TypeScript JSX | 3 | 7 | 10 | 20 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | 239 | 56 | 52 | 347 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx) | TypeScript JSX | 10 | 2 | 3 | 15 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx) | TypeScript JSX | 1 | 3 | 1 | 5 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx) | TypeScript JSX | 13 | 10 | 7 | 30 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx) | TypeScript JSX | -62 | 0 | 1 | -61 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx) | TypeScript JSX | 16 | 6 | 7 | 29 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx) | TypeScript JSX | 128 | 39 | 42 | 209 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx) | TypeScript JSX | 141 | 39 | 38 | 218 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx) | TypeScript JSX | 130 | 60 | 60 | 250 |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@@ -0,0 +1,25 @@
# Diff Summary
Date : 2025-03-18 12:39:04
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 11 files, 732 codes, 239 comments, 231 blanks, all 1202 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 11 | 732 | 239 | 231 | 1,202 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 11 | 732 | 239 | 231 | 1,202 |
| components | 7 | 317 | 95 | 84 | 496 |
| components (Files) | 4 | 365 | 82 | 75 | 522 |
| components/cells | 3 | -48 | 13 | 9 | -26 |
| hooks | 4 | 415 | 144 | 147 | 706 |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,39 @@
Date : 2025-03-18 12:39:04
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 11 files, 732 codes, 239 comments, 231 blanks, all 1202 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 11 | 732 | 239 | 231 | 1,202 |
+----------------+------------+------------+------------+------------+------------+
Directories
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 11 | 732 | 239 | 231 | 1,202 |
| components | 7 | 317 | 95 | 84 | 496 |
| components (Files) | 4 | 365 | 82 | 75 | 522 |
| components/cells | 3 | -48 | 13 | 9 | -26 |
| hooks | 4 | 415 | 144 | 147 | 706 |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx | TypeScript JSX | 113 | 17 | 10 | 140 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx | TypeScript JSX | 3 | 7 | 10 | 20 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | 239 | 56 | 52 | 347 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx | TypeScript JSX | 10 | 2 | 3 | 15 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx | TypeScript JSX | 1 | 3 | 1 | 5 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx | TypeScript JSX | 13 | 10 | 7 | 30 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx | TypeScript JSX | -62 | 0 | 1 | -61 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx | TypeScript JSX | 16 | 6 | 7 | 29 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx | TypeScript JSX | 128 | 39 | 42 | 209 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx | TypeScript JSX | 141 | 39 | 38 | 218 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx | TypeScript JSX | 130 | 60 | 60 | 250 |
| Total | | 732 | 239 | 231 | 1,202 |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
# Summary
Date : 2025-03-18 12:39:04
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6925 codes, 1247 comments, 1248 blanks, all 9420 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 19 | 6,549 | 1,134 | 1,165 | 8,848 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 27 | 6,925 | 1,247 | 1,248 | 9,420 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 12 | 3,674 | 498 | 494 | 4,666 |
| components (Files) | 7 | 2,489 | 327 | 327 | 3,143 |
| components/cells | 5 | 1,185 | 171 | 167 | 1,523 |
| hooks | 6 | 2,855 | 630 | 669 | 4,154 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,61 @@
Date : 2025-03-18 12:39:04
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6925 codes, 1247 comments, 1248 blanks, all 9420 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 19 | 6,549 | 1,134 | 1,165 | 8,848 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
+----------------+------------+------------+------------+------------+------------+
Directories
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 27 | 6,925 | 1,247 | 1,248 | 9,420 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 12 | 3,674 | 498 | 494 | 4,666 |
| components (Files) | 7 | 2,489 | 327 | 327 | 3,143 |
| components/cells | 5 | 1,185 | 171 | 167 | 1,523 |
| hooks | 6 | 2,855 | 630 | 669 | 4,154 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md | Markdown | 39 | 0 | 19 | 58 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx | TypeScript JSX | 230 | 10 | 8 | 248 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx | TypeScript JSX | 18 | 0 | 3 | 21 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx | TypeScript JSX | 273 | 19 | 37 | 329 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx | TypeScript JSX | 113 | 17 | 10 | 140 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx | TypeScript JSX | 377 | 49 | 54 | 480 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | 969 | 182 | 158 | 1,309 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx | TypeScript JSX | 509 | 50 | 57 | 616 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx | TypeScript JSX | 112 | 12 | 21 | 145 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx | TypeScript JSX | 233 | 34 | 33 | 300 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx | TypeScript JSX | 420 | 66 | 59 | 545 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx | TypeScript JSX | 193 | 23 | 22 | 238 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx | TypeScript JSX | 227 | 36 | 32 | 295 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx | TypeScript JSX | 500 | 75 | 89 | 664 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx | TypeScript JSX | 264 | 75 | 81 | 420 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx | TypeScript JSX | 204 | 26 | 33 | 263 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx | TypeScript JSX | 337 | 88 | 92 | 517 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx | TypeScript JSX | 360 | 78 | 85 | 523 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx | TypeScript JSX | 1,190 | 288 | 289 | 1,767 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx | TypeScript JSX | 20 | 6 | 2 | 28 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts | TypeScript | 4 | 0 | 1 | 5 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts | TypeScript | 16 | 4 | 4 | 24 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts | TypeScript | 124 | 4 | 14 | 142 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts | TypeScript | 21 | 15 | 5 | 41 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts | TypeScript | 43 | 24 | 7 | 74 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js | JavaScript | 28 | 7 | 9 | 44 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts | TypeScript | 101 | 59 | 24 | 184 |
| Total | | 6,925 | 1,247 | 1,248 | 9,420 |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

View File

@@ -0,0 +1,42 @@
# Details
Date : 2025-03-18 13:49:23
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6961 codes, 1254 comments, 1252 blanks, all 9467 lines
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md) | Markdown | 39 | 0 | 19 | 58 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx) | TypeScript JSX | 230 | 10 | 8 | 248 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx) | TypeScript JSX | 18 | 0 | 3 | 21 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx) | TypeScript JSX | 273 | 19 | 37 | 329 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx) | TypeScript JSX | 113 | 17 | 10 | 140 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx) | TypeScript JSX | 395 | 51 | 55 | 501 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | 969 | 182 | 158 | 1,309 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx) | TypeScript JSX | 527 | 55 | 60 | 642 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx) | TypeScript JSX | 112 | 12 | 21 | 145 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx) | TypeScript JSX | 233 | 34 | 33 | 300 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx) | TypeScript JSX | 420 | 66 | 59 | 545 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx) | TypeScript JSX | 193 | 23 | 22 | 238 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx) | TypeScript JSX | 227 | 36 | 32 | 295 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx) | TypeScript JSX | 500 | 75 | 89 | 664 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx) | TypeScript JSX | 264 | 75 | 81 | 420 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx) | TypeScript JSX | 204 | 26 | 33 | 263 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx) | TypeScript JSX | 337 | 88 | 92 | 517 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx) | TypeScript JSX | 360 | 78 | 85 | 523 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx) | TypeScript JSX | 1,190 | 288 | 289 | 1,767 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx) | TypeScript JSX | 20 | 6 | 2 | 28 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts) | TypeScript | 4 | 0 | 1 | 5 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts) | TypeScript | 16 | 4 | 4 | 24 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts) | TypeScript | 124 | 4 | 14 | 142 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts) | TypeScript | 21 | 15 | 5 | 41 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts) | TypeScript | 43 | 24 | 7 | 74 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js) | JavaScript | 28 | 7 | 9 | 44 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts) | TypeScript | 101 | 59 | 24 | 184 |
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,17 @@
# Diff Details
Date : 2025-03-18 13:49:23
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, 36 codes, 7 comments, 4 blanks, all 47 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx) | TypeScript JSX | 18 | 2 | 1 | 21 |
| [inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx) | TypeScript JSX | 18 | 5 | 3 | 26 |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@@ -0,0 +1,22 @@
# Diff Summary
Date : 2025-03-18 13:49:23
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, 36 codes, 7 comments, 4 blanks, all 47 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 2 | 36 | 7 | 4 | 47 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 2 | 36 | 7 | 4 | 47 |
| components | 2 | 36 | 7 | 4 | 47 |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,27 @@
Date : 2025-03-18 13:49:23
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, 36 codes, 7 comments, 4 blanks, all 47 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 2 | 36 | 7 | 4 | 47 |
+----------------+------------+------------+------------+------------+------------+
Directories
+---------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+---------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 2 | 36 | 7 | 4 | 47 |
| components | 2 | 36 | 7 | 4 | 47 |
+---------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+---------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+---------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx | TypeScript JSX | 18 | 2 | 1 | 21 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx | TypeScript JSX | 18 | 5 | 3 | 26 |
| Total | | 36 | 7 | 4 | 47 |
+---------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
# Summary
Date : 2025-03-18 13:49:23
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6961 codes, 1254 comments, 1252 blanks, all 9467 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 19 | 6,585 | 1,141 | 1,169 | 8,895 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 27 | 6,961 | 1,254 | 1,252 | 9,467 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 12 | 3,710 | 505 | 498 | 4,713 |
| components (Files) | 7 | 2,525 | 334 | 331 | 3,190 |
| components/cells | 5 | 1,185 | 171 | 167 | 1,523 |
| hooks | 6 | 2,855 | 630 | 669 | 4,154 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@@ -0,0 +1,61 @@
Date : 2025-03-18 13:49:23
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 27 files, 6961 codes, 1254 comments, 1252 blanks, all 9467 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 19 | 6,585 | 1,141 | 1,169 | 8,895 |
| TypeScript | 6 | 309 | 106 | 55 | 470 |
| Markdown | 1 | 39 | 0 | 19 | 58 |
| JavaScript | 1 | 28 | 7 | 9 | 44 |
+----------------+------------+------------+------------+------------+------------+
Directories
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 27 | 6,961 | 1,254 | 1,252 | 9,467 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 12 | 3,710 | 505 | 498 | 4,713 |
| components (Files) | 7 | 2,525 | 334 | 331 | 3,190 |
| components/cells | 5 | 1,185 | 171 | 167 | 1,523 |
| hooks | 6 | 2,855 | 630 | 669 | 4,154 |
| types | 1 | 16 | 4 | 4 | 24 |
| utils | 5 | 317 | 109 | 59 | 485 |
+-------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/README.md | Markdown | 39 | 0 | 19 | 58 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx | TypeScript JSX | 230 | 10 | 8 | 248 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/BaseCellContent.tsx | TypeScript JSX | 18 | 0 | 3 | 21 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx | TypeScript JSX | 273 | 19 | 37 | 329 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/UpcValidationTableAdapter.tsx | TypeScript JSX | 113 | 17 | 10 | 140 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx | TypeScript JSX | 395 | 51 | 55 | 501 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | 969 | 182 | 158 | 1,309 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx | TypeScript JSX | 527 | 55 | 60 | 642 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx | TypeScript JSX | 112 | 12 | 21 | 145 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx | TypeScript JSX | 233 | 34 | 33 | 300 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx | TypeScript JSX | 420 | 66 | 59 | 545 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultilineInput.tsx | TypeScript JSX | 193 | 23 | 22 | 238 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx | TypeScript JSX | 227 | 36 | 32 | 295 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx | TypeScript JSX | 500 | 75 | 89 | 664 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx | TypeScript JSX | 264 | 75 | 81 | 420 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx | TypeScript JSX | 204 | 26 | 33 | 263 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx | TypeScript JSX | 337 | 88 | 92 | 517 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx | TypeScript JSX | 360 | 78 | 85 | 523 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx | TypeScript JSX | 1,190 | 288 | 289 | 1,767 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx | TypeScript JSX | 20 | 6 | 2 | 28 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts | TypeScript | 4 | 0 | 1 | 5 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts | TypeScript | 16 | 4 | 4 | 24 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts | TypeScript | 124 | 4 | 14 | 142 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts | TypeScript | 21 | 15 | 5 | 41 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts | TypeScript | 43 | 24 | 7 | 74 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js | JavaScript | 28 | 7 | 9 | 44 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts | TypeScript | 101 | 59 | 24 | 184 |
| Total | | 6,961 | 1,254 | 1,252 | 9,467 |
+-------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

4
.gitignore vendored
View File

@@ -64,3 +64,7 @@ csv/**/*
!csv/.gitkeep
inventory/tsconfig.tsbuildinfo
inventory-server/scripts/.fuse_hidden00000fa20000000a
.VSCodeCounter/
.VSCodeCounter/*
.VSCodeCounter/**/*

72
docs/fix-multi-select.md Normal file
View File

@@ -0,0 +1,72 @@
# Solution: Keeping Dropdowns Open During Multiple Selections
## The Problem
When implementing a multi-select dropdown in React, a common issue occurs:
1. You select an item in the dropdown
2. The `onChange` handler is called, updating the data
3. This triggers a re-render of the parent component (in this case, the entire table)
4. During the re-render, the dropdown is unmounted and remounted
5. This causes the dropdown to close before you can make multiple selections
## The Solution: Deferred State Updates
The key insight is to **separate local state management from parent state updates**:
```typescript
// Step 1: Add local state to track selections
const [internalValue, setInternalValue] = useState<string[]>(value)
// Step 2: Handle popover open state changes
const handleOpenChange = useCallback((newOpen: boolean) => {
if (open && !newOpen) {
// Only update parent state when dropdown closes
if (JSON.stringify(internalValue) !== JSON.stringify(value)) {
onChange(internalValue);
}
}
setOpen(newOpen);
if (newOpen) {
// Sync internal state with external state when opening
setInternalValue(value);
}
}, [open, internalValue, value, onChange]);
// Step 3: Toggle selection only updates internal state
const toggleSelection = useCallback((selectedValue: string) => {
setInternalValue(prev => {
if (prev.includes(selectedValue)) {
return prev.filter(v => v !== selectedValue);
} else {
return [...prev, selectedValue];
}
});
}, []);
```
## Why This Works
1. **No parent re-renders during selection**: Since we're only updating local state, the parent component doesn't re-render during selection.
2. **Consistent UI**: The dropdown shows accurate selected states using the internal value.
3. **Data integrity**: The final selections are properly synchronized back to the parent when done.
4. **Resilient to external changes**: Initial state is synchronized when opening the dropdown.
## Implementation Steps
1. Create a local state variable to track selections inside the component
2. Only make selections against this local state while the dropdown is open
3. Defer updating the parent until the dropdown is explicitly closed
4. When opening, synchronize the internal state with the external value
## Benefits
This pattern:
- Avoids re-render cycles that would unmount the dropdown
- Maintains UI consistency during multi-selection
- Simplifies the component's interaction with parent components
- Works with existing component lifecycles rather than fighting against them
This solution is much simpler than trying to prevent event propagation or manipulating DOM events, and addresses the root cause of the issue: premature re-rendering.

View File

@@ -0,0 +1,239 @@
# Validation Display Issue Implementation
## Issue Being Addressed
**Validation Display Issue**: Validation isn't happening beyond checking if a cell is required or not. All validation rules defined in import.tsx need to be respected.
* Required fields correctly show a red border when empty (✅ ALREADY WORKING)
* Non-empty fields with validation errors (regex, unique, etc.) should show a red border AND an alert circle icon with tooltip explaining the error (❌ NOT WORKING)
## Implementation Attempts
!!!!**NOTE** All previous attempts have been reverted and are no longer part of the code, please take this into account when trying a new solution. !!!!
### Attempt 1: Fix Validation Display Logic
**Approach**: Modified `processErrors` function to separate required errors from validation errors and show alert icons only for non-empty fields with validation errors.
**Changes Made**:
```typescript
function processErrors(value: any, errors: ErrorObject[]) {
// ...existing code...
// Separate required errors from other validation errors
const requiredErrors = errors.filter(error =>
error.message?.toLowerCase().includes('required')
);
const validationErrors = errors.filter(error =>
!error.message?.toLowerCase().includes('required')
);
const isRequiredButEmpty = valueIsEmpty && requiredErrors.length > 0;
const hasValidationErrors = validationErrors.length > 0;
const shouldShowErrorIcon = hasValidationErrors && !valueIsEmpty;
// ...more code...
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 2: Comprehensive Fix for Validation Display
**Approach**: Completely rewrote `processErrors` function with consistent empty value detection, clear error separation, and improved error message extraction.
**Changes Made**:
```typescript
function processErrors(value: any, errors: ErrorObject[]) {
if (!errors || errors.length === 0) {
return { filteredErrors: [], hasError: false, isRequiredButEmpty: false,
shouldShowErrorIcon: false, errorMessages: '' };
}
const valueIsEmpty = isEmpty(value);
const requiredErrors = errors.filter(error =>
error.message?.toLowerCase().includes('required')
);
const validationErrors = errors.filter(error =>
!error.message?.toLowerCase().includes('required')
);
let filteredErrors = valueIsEmpty ? requiredErrors : validationErrors;
const isRequiredButEmpty = valueIsEmpty && requiredErrors.length > 0;
const hasValidationErrors = validationErrors.length > 0;
const hasError = isRequiredButEmpty || hasValidationErrors;
const shouldShowErrorIcon = hasValidationErrors && !valueIsEmpty;
let errorMessages = '';
if (shouldShowErrorIcon) {
errorMessages = validationErrors.map(getErrorMessage).join('\n');
}
return { filteredErrors, hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 3: Simplified Error Processing Logic
**Approach**: Refactored `processErrors` to use shared `isEmpty` function, simplified error icon logic, and made error message extraction more direct.
**Changes Made**:
```typescript
function processErrors(value: any, errors: ErrorObject[]) {
if (!errors || errors.length === 0) {
return { filteredErrors: [], hasError: false, isRequiredButEmpty: false,
shouldShowErrorIcon: false, errorMessages: '' };
}
const valueIsEmpty = isEmpty(value);
const requiredErrors = errors.filter(error =>
error.message?.toLowerCase().includes('required')
);
const validationErrors = errors.filter(error =>
!error.message?.toLowerCase().includes('required')
);
let filteredErrors = valueIsEmpty ? requiredErrors : validationErrors;
const isRequiredButEmpty = valueIsEmpty && requiredErrors.length > 0;
const hasValidationErrors = !valueIsEmpty && validationErrors.length > 0;
const hasError = isRequiredButEmpty || hasValidationErrors;
const shouldShowErrorIcon = hasValidationErrors;
let errorMessages = '';
if (shouldShowErrorIcon) {
errorMessages = validationErrors.map(getErrorMessage).join('\n');
}
return { filteredErrors, hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 4: Consistent Error Processing Across Components
**Approach**: Updated both `processErrors` function and `ValidationCell` component to ensure consistent error handling between components.
**Changes Made**:
```typescript
// In processErrors function
function processErrors(value: any, errors: ErrorObject[]) {
// Similar to Attempt 3 with consistent error handling
}
// In ValidationCell component
const ValidationCell = ({ field, value, onChange, errors, /* other props */ }) => {
// ...existing code...
// Use the processErrors function to handle validation errors
const { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages } =
React.useMemo(() => processErrors(value, errors), [value, errors]);
// ...rest of the component...
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 5: Unified Error Processing with ItemNumberCell
**Approach**: Replaced custom error processing in `ValidationCell` with the same `processErrors` utility used by `ItemNumberCell`.
**Changes Made**:
```typescript
const ValidationCell = ({ field, value, onChange, errors, /* other props */ }) => {
// State and context setup...
// For item_number fields, use the specialized component
if (fieldKey === 'item_number') {
return <ItemNumberCell {...props} />;
}
// Use the same processErrors utility function that ItemNumberCell uses
const { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages } =
React.useMemo(() => processErrors(value, errors), [value, errors]);
// Rest of component...
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 6: Standardize Error Processing Across Cell Types
**Approach**: Standardized error handling across all cell types using the shared `processErrors` utility function.
**Changes Made**: Similar to Attempt 5, with focus on standardizing the approach for determining when to show validation error icons.
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 7: Replace Custom Error Processing with Shared Utility
**Approach**: Ensured consistent error handling between `ItemNumberCell` and regular `ValidationCell` components.
**Changes Made**: Similar to Attempts 5 and 6, with focus on using the shared utility function consistently.
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
### Attempt 8: Improved Error Normalization and Deep Comparison
**Approach**: Modified `MemoizedCell` in `ValidationTable.tsx` to use deep comparison for error objects and improved error normalization.
**Changes Made**:
```typescript
// Create a memoized cell component
const MemoizedCell = React.memo(({ field, value, onChange, errors, /* other props */ }) => {
return <ValidationCell {...props} />;
}, (prev, next) => {
// Basic prop comparison
if (prev.value !== next.value) return false;
if (prev.isValidating !== next.isValidating) return false;
if (prev.itemNumber !== next.itemNumber) return false;
// Deep compare errors - critical for validation display
if (!prev.errors && next.errors) return false;
if (prev.errors && !next.errors) return false;
if (prev.errors && next.errors) {
if (prev.errors.length !== next.errors.length) return false;
// Compare each error object
for (let i = 0; i < prev.errors.length; i++) {
if (prev.errors[i].message !== next.errors[i].message) return false;
if (prev.errors[i].level !== next.errors[i].level) return false;
if (prev.errors[i].source !== next.errors[i].source) return false;
}
}
// Compare options...
return true;
});
// In the field columns definition:
cell: ({ row }) => {
const rowErrors = validationErrors.get(row.index);
const cellErrors = rowErrors?.[fieldKey] || [];
// Ensure cellErrors is always an array
const normalizedErrors = Array.isArray(cellErrors) ? cellErrors : [cellErrors];
return <MemoizedCell {...props} errors={normalizedErrors} />;
}
```
**Result**: Non-empty fields with validation errors still aren't displaying the alert icon with tooltip.
## Root Causes (Revised Hypothesis)
After multiple attempts, the issue appears more complex than initially thought. Possible root causes:
1. **Error Object Structure**: Error objects might not have the expected structure or properties
2. **Error Propagation**: Errors might be getting filtered out before reaching cell components
3. **Validation Rules Configuration**: Validation rules in import.tsx might be incorrectly configured
4. **Error State Management**: Error state might not be properly updated or might be reset incorrectly
5. **Component Rendering Logic**: Components might not re-render when validation state changes
6. **CSS/Styling Issues**: Validation icons might be rendered but hidden due to styling issues
7. **Validation Timing**: Validation might be happening at the wrong time or getting overridden

View File

@@ -0,0 +1,138 @@
# Multiple Cell Edit Issue Implementation
## Issue Being Addressed
**Multiple Cell Edit Issue**: When you enter values in 2+ cells before validation finishes, contents from all edited cells get erased when validation finishes.
## Implementation Attempts
### Attempt 1: Fix Multiple Cell Edit Issue (First Approach)
**Approach**:
- Added a tracking mechanism using a Set to keep track of cells that are currently being edited
- Modified the `flushPendingUpdates` function to preserve values of cells being edited
- Added cleanup of editing state after validation completes
**Changes Made**:
```typescript
// Add ref to track cells currently being edited
const currentlyEditingCellsRef = useRef(new Set<string>());
// Update a row's field value
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
// Add this cell to currently editing cells
const cellKey = `${rowIndex}-${key}`;
currentlyEditingCellsRef.current.add(cellKey);
// ...existing code...
// After validation completes, remove this cell from currently editing list
setTimeout(() => {
currentlyEditingCellsRef.current.delete(cellKey);
}, 100);
}, []);
// Modify flushPendingUpdates to respect currently editing cells
const flushPendingUpdates = useCallback(() => {
// ...existing code...
if (dataUpdates.length > 0) {
setData(prev => {
// ...existing code...
dataUpdates.forEach((row, index) => {
if (index < newData.length) {
const updatedRow = { ...row };
// Check if any fields in this row are currently being edited
// If so, preserve their current values in the previous data
Object.keys(prev[index] || {}).forEach(key => {
const cellKey = `${index}-${key}`;
if (currentlyEditingCellsRef.current.has(cellKey)) {
// Keep the value from the previous state for this field
updatedRow[key] = prev[index][key];
}
});
newData[index] = updatedRow;
}
});
return newData;
});
}
}, []);
```
**Result**:
- Slight improvement - the first value entered was saved, but any subsequent values still got erased
### Attempt 2: Fix Multiple Cell Edit Issue (Second Approach)
**Approach**:
- Completely revised the cell editing tracking system
- Used a Map with timestamps to track editing cells more accurately
- Added proper Promise-based tracking for cell validation
- Increased timeout from 100ms to 1000ms
- Made cleanup more robust by checking if it's still the same editing session
**Changes Made**:
```typescript
// Add ref to track cells currently being edited with timestamps
const currentlyEditingCellsRef = useRef(new Map<string, number>());
// Add ref to track validation promises
const validationPromisesRef = useRef<Map<string, Promise<void>>>(new Map());
// Update a row's field value
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
// Mark this cell as being edited with the current timestamp
const cellKey = `${rowIndex}-${key}`;
currentlyEditingCellsRef.current.set(cellKey, Date.now());
// ...existing code...
// Create a validation promise
const validationPromise = new Promise<void>((resolve) => {
setTimeout(() => {
try {
validateRow(rowIndex);
} finally {
resolve();
}
}, 0);
});
validationPromisesRef.current.set(cellKey, validationPromise);
// When validation is complete, remove from validating cells
validationPromise.then(() => {
// ...existing code...
// Keep this cell in the editing state for a longer time
setTimeout(() => {
if (currentlyEditingCellsRef.current.has(cellKey)) {
currentlyEditingCellsRef.current.delete(cellKey);
}
}, 1000); // Keep as "editing" for 1 second
});
}, []);
```
**Result**:
- Worse than the first approach - now all values get erased, including the first one
## Root Causes (Hypothesized)
- The validation process might be updating the entire data state, causing race conditions with cell edits
- The timing of validation completions might be problematic
- State updates might be happening in a way that overwrites user changes
- The cell state tracking system is not robust enough to prevent overwrites
## Next Steps
The issue requires a more fundamental approach than just tweaking the editing logic. We need to:
1. Implement a more robust state management system for cell edits that can survive validation cycles
2. Consider disabling validation during active editing
3. Implement a proper "dirty state" tracking system for cells

View File

@@ -0,0 +1,305 @@
# Current Issues to Address
4. Validation isn't happening beyond checking if a cell is required or not - needs to respect rules in import.tsx
* Red cell outline if cell is required and it's empty
* Red outline + alert circle icon with tooltip if cell is NOT empty and isn't valid
8. When you enter a value in 2+ cells before validation finishes, contents from all edited cells get erased when validation finishes
## Do NOT change or edit
* Anything related to AI validation
* Anything about how templates or UPC validation work (only focus on specific issues described above)
* Anything outside of the ValidationStepNew folder
## Issues already fixed - do not work on these
✅FIXED 1. The red row background should go away when all cells in the row are valid and all required cells are populated
✅FIXED 2. Columns alignment with header is slightly off, gets worse the further right you go
✅FIXED 3. The copy down button is in the way of the validation error icon and the select open trigger - all three need to be in unique locations
✅FIXED 5. Description column needs to have an expanded view of some sort, maybe a popover to allow for easier editing
* Don't distort table to make it happen
✅FIXED 6. Need to ensure all cell's contents don't overflow the input (truncate). COO does this currently, probably more
✅FIXED 7. The template select cell is expanding, needs to be fixed size and truncate
✅FIXED 9. Import dialog state not fully reset when closing? (validate data step appears scrolled to the middle of the table where I left it)
✅FIXED 10. UPC column doesn't need to show loading state when Item Number is being processed, only show on item number column
✅FIXED 11. Copy down needs to show a loading state on the cells that it will copy to
✅FIXED 12. Shipping restrictions/tax category should default to ID 0 if we didn't get it elsewhere
✅FIXED 13. Header row should be sticky (both up/down and left/right)
✅FIXED 14. Need a way to scroll around table if user doesn't have mouse wheel for left/right
✅FIXED 15. Enhance copy down feature by allowing user to choose the last cell to copy to, instead of going all the way to the bottom
---------
# Validation Step Components Overview
## Core Components
### ValidationContainer
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx`
- Main wrapper component for the validation step
- Manages global state and coordinates between subcomponents
- Handles navigation events (next, back)
- Manages template application and validation state
- Coordinates UPC validation and product line loading
- Manages row selection and filtering
- Contains cache management for UPC validation results
- Maintains item number references separate from main data
### ValidationTable
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx`
- Handles data display and column configuration
- Uses TanStack Table for core functionality
- Features:
- Sticky header (both vertical and horizontal) - currently doesn't work properly
- Row selection with checkboxes
- Template selection column
- Dynamic column widths based on field types - specified in import.tsx component
- Copy down functionality for cell values
- Error highlighting for rows and cells
- Loading states for cells being validated
### ValidationCell
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx`
- Base cell component that renders different cell types based on field configuration
- Handles error display with tooltips
- Manages copy down button visibility
- Supports loading states during validation
- Cell Types:
1. InputCell: For single-value text input
2. SelectCell: For dropdown selection
3. MultiInputCell: For multiple value inputs
4. Template selection cells with SearchableTemplateSelect component
### SearchableTemplateSelect
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx`
- Advanced template selection component with search functionality
- Features:
- Real-time search filtering of templates
- Customizable display text for templates
- Support for default brand selection
- Accessible popover interface
- Keyboard navigation support
- Custom styling through className props
- Scroll event handling for nested scrollable areas
### TemplateManager
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx`
- Comprehensive template management interface
- Features:
- Template selection with search functionality
- Save template dialog with name and type inputs
- Batch template application to selected rows
- Template count tracking
- Toast notifications for user feedback
- Dialog-based interface for template operations
### AiValidationDialogs
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx`
- Manages AI-assisted validation dialogs and interactions
### SaveTemplateDialog
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SaveTemplateDialog.tsx`
- Dialog component for saving new templates
## Cell Components
### InputCell
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx`
- Handles single value text input
- Features:
- Inline/edit mode switching
- Multiline support
- Price formatting
- Error state display
- Loading state during validation
- Width constraints
- Automated cleanPriceFields processing for "$" formatting
### SelectCell
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx`
- Handles dropdown selection
- Features:
- Searchable dropdown
- Custom option rendering
- Error state display
- Loading state during validation
- Width constraints
- Disabled state support
- Deferred search query handling for performance
### MultiInputCell
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx`
- Handles multiple value inputs
- Features:
- Comma-separated input support
- Multi-select dropdown for predefined options
- Custom separators
- Badge display for selected count
- Truncation for long values
- Width constraints
- Price formatting support
- Internal state management to avoid excessive re-renders
## Validation System
### useValidation Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx`
- Provides core validation logic
- Validates at multiple levels:
1. Field-level validation (required, regex, unique)
2. Row-level validation (supplier, company fields)
3. Table-level validation
4. Custom validation hooks support
- Error object structure includes message, level, and source properties
- Handles debounced validation updates to avoid UI freezing
### useAiValidation Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx`
- Manages AI-assisted validation logic and state
- Features:
- Tracks detailed changes per product
- Manages validation progress with estimated completion time
- Handles warnings and change suggestions
- Supports diff generation for changes
- Progress tracking with step indicators
- Prompt management for AI interactions
- Timer management for long-running operations
### useTemplates Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx`
- Comprehensive template management system
- Features:
- Template CRUD operations
- Template application logic
- Default value handling
- Template search and filtering
- Batch template operations
- Template validation
### useUpcValidation Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx`
- Dedicated UPC validation management
- Features:
- UPC format validation
- Supplier data validation
- Cache management for validation results
- Batch processing of UPC validations
- Item number generation logic
- Loading state management
### useFilters Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useFilters.tsx`
- Advanced filtering system for table data
- Features:
- Multiple filter criteria support
- Dynamic filter updates
- Filter persistence
- Filter combination logic
- Performance optimized filtering
### useValidationState Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx`
- Manages global validation state
- Handles:
- Data updates
- Template management
- Error tracking using Map objects
- Row selection
- Filtering
- UPC validation with caching to prevent duplicate API calls
- Product line loading
- Batch processing of updates
- Default value application for tax_cat and ship_restrictions (defaulting to "0")
- Price field auto-formatting to remove "$" symbols
### Utility Files
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts`
- Core validation utility functions
- Includes:
- Field validation logic
- Error message formatting
- Validation rule processing
- Type checking utilities
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts`
- Error handling and formatting utilities
- Includes:
- Error object creation
- Error message formatting
- Error source tracking
- Error level management
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts`
- Data transformation and mutation utilities
- Includes:
- Row data updates
- Batch data processing
- Data structure conversions
- Change tracking
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js`
- Helper functions for validation
- Includes:
- Common validation patterns
- Validation state management
- Validation result processing
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts`
- UPC-specific validation utilities
- Includes:
- UPC format checking
- Checksum validation
- Supplier data matching
- Cache management
### Types
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts`
- Core type definitions for the validation step
### Validation Types
1. Required field validation
2. Regex pattern validation
3. Unique value validation
4. Custom field validation
5. Row-level validation
6. Table-level validation
## State Management
### useValidationState Hook
`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx`
- Manages global validation state
- Handles:
- Data updates
- Template management
- Error tracking using Map objects
- Row selection
- Filtering
- UPC validation with caching to prevent duplicate API calls
- Product line loading
- Batch processing of updates
- Default value application for tax_cat and ship_restrictions (defaulting to "0")
- Price field auto-formatting to remove "$" symbols
## UPC Validation System
### UPC Processing
- Validates UPCs against supplier data
- Cache system for UPC validation results
- Batch processing of UPC validation requests
- Auto-generation of item numbers based on UPC
- Special loading states for UPC/item number fields
- Separate state tracking to avoid unnecessary data structure updates
## Template System
### Template Management
- Supports saving and loading templates
- Template application to single/multiple rows
- Default template values
- Template search and filtering
## Performance Optimizations
1. Memoized components to prevent unnecessary renders
2. Virtualized table for large datasets
3. Deferred value updates for search inputs
4. Efficient error state management
5. Optimized cell update handling

View File

@@ -0,0 +1,131 @@
# Refactoring Plan for Validation Code
## Current Structure Analysis
- **useValidationState.tsx**: ~1650 lines - Core validation state management
- **useValidation.tsx**: ~425 lines - Field/data validation utility
- **useUpcValidation.tsx**: ~410 lines - UPC-specific validation
## Proposed New Structure
### 1. Core Types & Utilities (150-200 lines)
**File: `validation/types.ts`**
- All interfaces and types (RowData, ValidationError, FilterState, Template, etc.)
- Shared utility functions (isEmpty, getCellKey, etc.)
**File: `validation/utils.ts`**
- Generic validation utility functions
- Caching mechanism and cache clearing helpers
- API URL helpers
### 2. Field Validation (300-350 lines)
**File: `validation/hooks/useFieldValidation.ts`**
- `validateField` function
- Field-level validation logic
- Required, regex, and other field validations
### 3. Uniqueness Validation (250-300 lines)
**File: `validation/hooks/useUniquenessValidation.ts`**
- `validateUniqueField` function
- `validateUniqueItemNumbers` function
- All uniqueness checking logic
### 4. UPC Validation (300-350 lines)
**File: `validation/hooks/useUpcValidation.ts`**
- `fetchProductByUpc` function
- `validateUpc` function
- `applyItemNumbersToData` function
- UPC validation state management
### 5. Validation Status Management (300-350 lines)
**File: `validation/hooks/useValidationStatus.ts`**
- Error state management
- Row validation status tracking
- Validation indicators and refs
- Batch validation processing
### 6. Data Management (300-350 lines)
**File: `validation/hooks/useValidationData.ts`**
- Data state management
- Row updates
- Data filtering
- Initial data processing
### 7. Template Management (250-300 lines)
**File: `validation/hooks/useTemplateManagement.ts`**
- Template saving
- Template application
- Template loading
- Template display helpers
### 8. Main Validation Hook (300-350 lines)
**File: `validation/hooks/useValidation.ts`**
- Main hook that composes all other hooks
- Public API export
- Initialization logic
- Core validation flow
## Function Distribution
### Core Types & Utilities
- All interfaces (InfoWithSource, ValidationState, etc.)
- `isEmpty` utility
- `getApiUrl` helper
### Field Validation
- `validateField`
- `validateRow`
- `validateData` (partial)
- All validation result caching
### Uniqueness Validation
- `validateUniqueField`
- `validateUniqueItemNumbers`
- Uniqueness caching mechanisms
### UPC Validation
- `fetchProductByUpc`
- `validateUpc`
- `validateAllUPCs`
- `applyItemNumbersToData`
- UPC validation state tracking (cells, rows)
### Validation Status Management
- `startValidatingCell`/`stopValidatingCell`
- `startValidatingRow`/`stopValidatingRow`
- `isValidatingCell`/`isRowValidatingUpc`
- Error state management
- `revalidateRows`
### Data Management
- Initial data cleaning/processing
- `updateRow`
- `copyDown`
- Search/filter functionality
- `filteredData` calculation
### Template Management
- `saveTemplate`
- `applyTemplate`
- `applyTemplateToSelected`
- `getTemplateDisplayText`
- `loadTemplates`/`refreshTemplates`
### Main Validation Hook
- Composition of all other hooks
- Initialization logic
- Button/navigation handling
- Field options management
## Implementation Approach
1. **Start with Types**: Create the types file first, as all other files will depend on it
2. **Create Utility Functions**: Move shared utilities next
3. **Build Core Validation**: Extract the field validation and uniqueness validation
4. **Separate UPC Logic**: Move all UPC-specific code to its own module
5. **Extract State Management**: Move data and status management to separate files
6. **Move Template Logic**: Extract template functionality
7. **Create Composition Hook**: Build the main hook that uses all other hooks
This approach will give you more maintainable code with clearer separation of concerns, making it easier to understand, test, and modify each component independently.

View File

@@ -0,0 +1,354 @@
## 1. ✅ Error Filtering Logic Inconsistency (RESOLVED)
> **Note: This issue has been resolved by implementing a type-based error system.**
The filtering logic in `ValidationCell.tsx` previously relied on string matching, which was fragile:
```typescript
// Old implementation (string-based matching)
const filteredErrors = React.useMemo(() => {
return !isEmpty(value)
? errors.filter(error => !error.message?.toLowerCase().includes('required'))
: errors;
}, [value, errors]);
// New implementation (type-based filtering)
const filteredErrors = React.useMemo(() => {
return !isEmpty(value)
? errors.filter(error => error.type !== ErrorType.Required)
: errors;
}, [value, errors]);
```
The solution implemented:
- Added an `ErrorType` enum in `types.ts` to standardize error categorization
- Updated all error creation to include the appropriate error type
- Modified error filtering to use the type property instead of string matching
- Ensured consistent error handling across the application
**Guidelines for future development:**
- Always use the `ErrorType` enum when creating errors
- Never rely on string matching for error filtering
- Ensure all error objects include the `type` property
- Use the appropriate error type for each validation rule:
- `ErrorType.Required` for required field validations
- `ErrorType.Regex` for regex validations
- `ErrorType.Unique` for uniqueness validations
- `ErrorType.Custom` for custom validations
- `ErrorType.Api` for API-based validations
## 2. ⚠️ Redundant Error Processing (PARTIALLY RESOLVED)
> **Note: This issue has been partially resolved by the re-rendering optimizations.**
The system still processes errors in multiple places:
- In `ValidationCell.tsx`, errors are filtered by the optimized `processErrors` function
- In `useValidation.tsx`, errors are generated at the field level
- In `ValidationContainer.tsx`, errors are manipulated at the container level
While the error processing has been optimized to be more efficient, there is still some redundancy in how errors are handled across components. However, the current implementation has mitigated the performance impact.
**Improvements made:**
- Created a central `processErrors` function in ValidationCell that efficiently handles error filtering
- Implemented a batched update system to reduce redundant error processing
- Added better memoization to avoid reprocessing errors when not needed
**Future improvement opportunities:**
- Further consolidate error processing logic into a single location
- Create a dedicated error handling service or hook
- Implement a more declarative approach to error handling
## 3. Race Conditions in Async Validation
async validations could create race conditions:
- If a user types quickly, multiple validation requests might be in flight
- Later responses could overwrite more recent ones if they complete out of order
- The debouncing helps but doesn't fully solve this issue
## 4. Memory Leaks in Timeout Management
The validation timeouts are stored in refs:
```typescript
const validationTimeoutsRef = useRef<Record<number, NodeJS.Timeout>>({});
```
While there is cleanup on unmount, if rows are added/removed dynamically, timeouts for deleted rows might not be properly cleared.
## 5. ✅ Inefficient Error Storage (RESOLVED)
**Status: RESOLVED**
### Problem
Previously, validation errors were stored in multiple locations:
- In the `validationErrors` Map in `useValidationState`
- In the row data itself as `__errors`
This redundancy caused several issues:
- Inconsistent error states between the two storage locations
- Increased memory usage by storing the same information twice
- Complex state management to keep both sources in sync
- Difficulty reasoning about where errors should be accessed from
### Solution
We've implemented a unified error storage approach by:
- Making the `validationErrors` Map in `useValidationState` the single source of truth for all validation errors
- Removed the `__errors` property from row data
- Updated all validation functions to interact with the central error store instead of modifying row data
- Modified UPC validation to use the central error store
- Updated all components to read errors from the `validationErrors` Map instead of row data
### Key Changes
1. Modified `dataMutations.ts` to stop storing errors in row data
2. Updated the `Meta` type to remove the `__errors` property
3. Modified the `RowData` type to remove the `__errors` property
4. Updated the `useValidation` hook to return errors separately from row data
5. Modified the `useAiValidation` hook to work with the central error store
6. Updated the `useFilters` hook to check for errors in the `validationErrors` Map
7. Modified the `ValidationTable` and `ValidationCell` components to read errors from the `validationErrors` Map
### Benefits
- **Single Source of Truth**: All validation errors are now stored in one place
- **Reduced Memory Usage**: No duplicate storage of error information
- **Simplified State Management**: Only one state to update when errors change
- **Cleaner Data Structure**: Row data no longer contains validation metadata
- **More Maintainable Code**: Clearer separation of concerns between data and validation
### Future Improvements
While this refactoring addresses the core issue of inefficient error storage, there are still opportunities for further optimization:
1.**Redundant Error Processing**: ~~The validation process still performs some redundant calculations that could be optimized.~~ This has been largely addressed by the re-rendering optimizations.
2. **Race Conditions**: Async validation can lead to race conditions when multiple validations are triggered in quick succession.
3. **Memory Leaks**: The timeout management for validation could be improved to prevent potential memory leaks.
4. **Tight Coupling**: Components are still tightly coupled to the validation state structure.
5. **Error Prioritization**: The system doesn't prioritize errors well, showing all errors at once rather than focusing on the most critical ones first.
### Validation Flow
The validation process now works as follows:
1. **Error Generation**:
- Field-level validations generate errors based on validation rules
- Row-level hooks add custom validation errors
- Table-level validations (like uniqueness checks) add errors across rows
2. **Error Storage**:
- All errors are stored in the `validationErrors` Map in `useValidationState`
- The Map uses row indices as keys and objects of field errors as values
3. **Error Display**:
- The `ValidationTable` component checks the `validationErrors` Map to highlight rows with errors
- The `ValidationCell` component receives errors for specific fields from the `validationErrors` Map
- Errors are filtered in the UI to avoid showing "required" errors for fields with values
This focused refactoring approach has successfully addressed a critical issue while keeping changes manageable and targeted.
## 6. ✅ Excessive Re-rendering (RESOLVED)
**Status: RESOLVED**
### Problem
The validation system was suffering from excessive re-renders due to several key issues:
- **Inefficient Error Filtering**: The ValidationCell component was filtering errors on every render
- **Redundant Error Processing**: The same validation work was repeated in multiple components
- **Poor Memoization**: Components were inadequately memoized, causing unnecessary re-renders
- **Inefficient Batch Updates**: The state update system wasn't optimally batching changes
These issues led to performance problems, especially with large datasets, and affected the user experience.
### Solution
We've implemented a comprehensive optimization approach:
- **Optimized Error Processing**: Created an efficient `processErrors` function in ValidationCell that calculates all derived state in one pass
- **Enhanced Memoization**: Improved memo comparison functions to avoid unnecessary rerenders
- **Improved Batch Updates**: Redesigned the batching system to aggregate multiple changes before state updates
- **Single Update Pattern**: Implemented a queue-based update mechanism that applies multiple state changes at once
### Key Changes
1. Added a more efficient error processing function in ValidationCell
2. Created an enhanced error comparison function to properly compare error arrays
3. Improved the memo comparison function in ValidationCell
4. Added a batch update system in useValidationState
5. Implemented a queue-based update mechanism for row modifications
### Benefits
- **Improved Performance**: Reduced render cycles = faster UI response
- **Better User Experience**: Less lag when editing large datasets
- **Reduced Memory Usage**: Fewer component instantiations and temporary objects
- **Increased Scalability**: The application can now handle larger datasets without slowdown
- **Maintainable Code**: More predictable update flow that's easier to debug and extend
### Guidelines for future development
- Use the `processErrors` function for error filtering and processing
- Ensure React.memo components have proper comparison functions
- Use the batched update system for state changes
- Maintain stable references to objects and functions
- Use appropriate React hooks (useMemo, useCallback) with correct dependencies
- Avoid unnecessary recreations of arrays, objects, and functions
## 7. Complex Error Merging Logic
When merging errors from different sources, the logic is complex and potentially error-prone:
```typescript
// Merge field errors and row hook errors
const mergedErrors: Record<string, InfoWithSource> = {}
// Convert field errors to InfoWithSource
Object.entries(fieldErrors).forEach(([key, errors]) => {
if (errors.length > 0) {
mergedErrors[key] = {
message: errors[0].message,
level: errors[0].level,
source: ErrorSources.Row,
type: errors[0].type || ErrorType.Custom
}
}
})
```
This only takes the first error for each field, potentially hiding important validation issues.
## 8. ✅ Inconsistent Error Handling for Empty Values (PARTIALLY RESOLVED)
> **Note: This issue has been partially resolved by standardizing the isEmpty function and error type system.**
The system previously had different approaches to handling empty values:
- Some validations skipped empty values unless they're required
- Others processed empty values differently
- The `isEmpty` function was defined multiple times with slight variations
The solution implemented:
- Standardized the `isEmpty` function implementation
- Ensured consistent error type usage for required field validations
- Made error filtering consistent across the application
**Guidelines for future development:**
- Always use the shared `isEmpty` function for checking empty values
- Ensure consistent handling of empty values across all validation rules
- Use the `ErrorType.Required` type for all required field validations
## 9. Tight Coupling Between Components
The validation system is tightly coupled across components:
- `ValidationCell` needs to understand the structure of errors
- `ValidationTable` needs to extract and pass the right errors
- `ValidationContainer` directly manipulates the error structure
This makes it harder to refactor or reuse components independently.
## 10. Limited Error Prioritization
There's no clear prioritization of errors:
- When multiple errors exist for a field, which one should be shown first?
- Are some errors more important than others?
- The current system mostly shows the first error it finds
A more robust approach would be to have a consistent error source identification system and a clear prioritization strategy for displaying errors.
------------
Let me explain how these hooks fit together to create the validation errors that eventually get filtered in the `ValidationCell` component:
## The Validation Flow
1. **useValidationState Hook**:
This is the main state management hook used by the `ValidationContainer` component. It:
- Manages the core data state (`data`)
- Tracks validation errors in a Map (`validationErrors`)
- Provides functions to update and validate rows
2. **useValidation Hook**:
This is a utility hook that provides the core validation logic:
- `validateField`: Validates a single field against its validation rules
- `validateRow`: Validates an entire row, field by field
- `validateTable`: Runs table-level validations
- `validateUnique`: Checks for uniqueness constraints
- `validateData`: Orchestrates the complete validation process
## How Errors Are Generated
Validation errors come from multiple sources:
1. **Field-Level Validations**:
In `useValidation.tsx`, the `validateField` function checks individual fields against rules like:
- `required`: Field must have a value
- `regex`: Value must match a pattern
- `min`/`max`: Numeric constraints
2. **Row-Level Validations**:
The `validateRow` function in `useValidation.tsx` runs:
- Field validations for each field in the row
- Special validations for required fields like supplier and company
- Custom row hooks provided by the application
3. **Table-Level Validations**:
- `validateUnique` checks for duplicate values in fields marked as unique
- `validateTable` runs custom table hooks for cross-row validations
4. **API-Based Validations**:
In `useValidationState.tsx` and `ValidationContainer.tsx`:
- UPC validation via API calls
- Item number uniqueness checks
## The Error Flow
1. Errors are collected in the `validationErrors` Map in `useValidationState`
2. This Map is passed to `ValidationTable` as a prop
3. `ValidationTable` extracts the relevant errors for each cell and passes them to `ValidationCell`
4. In `ValidationCell`, the errors are filtered based on whether the cell has a value:
```typescript
// Updated implementation using type-based filtering
const filteredErrors = React.useMemo(() => {
return !isEmpty(value)
? errors.filter(error => error.type !== ErrorType.Required)
: errors;
}, [value, errors]);
```
## Key Insights
1. **Error Structure**:
Errors now have a consistent structure with type information:
```typescript
type ErrorObject = {
message: string;
level: string; // 'error', 'warning', etc.
source?: ErrorSources; // Where the error came from
type: ErrorType; // The type of error (Required, Regex, Unique, etc.)
}
```
2. **Error Sources**:
Errors can come from:
- Field validations (required, regex, etc.)
- Row validations (custom business logic)
- Table validations (uniqueness checks)
- API validations (UPC checks)
3. **Error Types**:
Errors are now categorized by type:
- `ErrorType.Required`: Field is required but empty
- `ErrorType.Regex`: Value doesn't match the regex pattern
- `ErrorType.Unique`: Value must be unique across rows
- `ErrorType.Custom`: Custom validation errors
- `ErrorType.Api`: Errors from API calls
4. **Error Filtering**:
The filtering in `ValidationCell` is now more robust:
- When a field has a value, errors of type `ErrorType.Required` are filtered out
- When a field is empty, all errors are shown
5. **Performance Optimizations**:
- Batch processing of validations
- Debounced updates to avoid excessive re-renders
- Memoization of computed values

View File

@@ -0,0 +1,538 @@
# ValidationTable Scroll Position Issue
## Problem Description
The `ValidationTable` component in the inventory application suffers from a persistent scroll position issue. When the table content updates or re-renders, the scroll position resets to the top left corner. This creates a poor user experience, especially when users are working with large datasets and need to maintain their position while making edits or filtering data.
Specific behaviors:
- Scroll position resets to the top left corner during re-renders
- User loses their place in the table when data is updated
- The table does not preserve vertical or horizontal scroll position
## Relevant Files
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx`**
- Main component that renders the validation table
- Handles scroll position management
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx`**
- Parent component that wraps ValidationTable
- Creates an EnhancedValidationTable wrapper component
- Manages data and state for the validation table
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx`**
- Provides state management and data manipulation functions
- Contains scroll-related code in the `updateRow` function
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx`**
- Renders individual cells in the table
- May influence re-renders that affect scroll position
## Failed Attempts
We've tried multiple approaches to fix the scroll position issue, none of which have been successful:
### 1. Using Refs for Scroll Position
```typescript
const scrollPosition = useRef({
left: 0,
top: 0
});
// Capture position on scroll
const handleScroll = useCallback(() => {
if (tableContainerRef.current) {
scrollPosition.current = {
left: tableContainerRef.current.scrollLeft,
top: tableContainerRef.current.scrollTop
};
}
}, []);
// Restore in useLayoutEffect
useLayoutEffect(() => {
const container = tableContainerRef.current;
if (container) {
const { left, top } = scrollPosition.current;
if (left || top) {
container.scrollLeft = left;
container.scrollTop = top;
}
}
});
```
Result: Scroll position was still lost during updates.
### 2. Multiple Restoration Attempts with Timeouts
```typescript
// Multiple timeouts at different intervals
setTimeout(() => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollTop = savedPosition.top;
tableContainerRef.current.scrollLeft = savedPosition.left;
}
}, 0);
setTimeout(() => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollTop = savedPosition.top;
tableContainerRef.current.scrollLeft = savedPosition.left;
}
}, 50);
// Additional timeouts at 100ms, 300ms
```
Result: Still not reliable, scroll position would reset between timeouts or after all timeouts completed.
### 3. Using MutationObserver and ResizeObserver
```typescript
// Create a mutation observer to detect DOM changes
const mutationObserver = new MutationObserver(() => {
if (shouldPreserveScroll) {
restoreScrollPosition();
}
});
// Start observing the table for DOM changes
mutationObserver.observe(scrollableContainer, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
// Create a resize observer
const resizeObserver = new ResizeObserver(() => {
if (shouldPreserveScroll) {
restoreScrollPosition();
}
});
// Observe the table container
resizeObserver.observe(scrollableContainer);
```
Result: Did not reliably maintain scroll position, and sometimes caused other rendering issues.
### 4. Recursive Restoration Approach
```typescript
let attempts = 0;
const maxAttempts = 5;
const restore = () => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollTop = y;
tableContainerRef.current.scrollLeft = x;
attempts++;
if (attempts < maxAttempts) {
setTimeout(restore, 50 * attempts);
}
}
};
restore();
```
Result: No improvement, scroll position still reset.
### 5. Using React State for Scroll Position
```typescript
const [scrollPos, setScrollPos] = useState<{top: number; left: number}>({top: 0, left: 0});
// Track the scroll event
useEffect(() => {
const handleScroll = () => {
if (scrollContainerRef.current) {
setScrollPos({
top: scrollContainerRef.current.scrollTop,
left: scrollContainerRef.current.scrollLeft
});
}
};
// Add scroll listener...
}, []);
// Restore scroll position
useLayoutEffect(() => {
const container = scrollContainerRef.current;
const { top, left } = scrollPos;
if (top > 0 || left > 0) {
requestAnimationFrame(() => {
if (container) {
container.scrollTop = top;
container.scrollLeft = left;
}
});
}
}, [scrollPos, data]);
```
Result: Caused the screen to shake violently when scrolling and did not preserve position.
### 6. Using Key Attribute for Stability
```typescript
return (
<div
key="validation-table-container"
ref={scrollContainerRef}
className="overflow-auto max-h-[calc(100vh-300px)]"
>
{/* Table content */}
</div>
);
```
Result: Did not resolve the issue and may have contributed to rendering instability.
### 7. Removing Scroll Management from Other Components
We removed scroll position management code from:
- `useValidationState.tsx` (in the updateRow function)
- `ValidationContainer.tsx` (in the enhancedUpdateRow function)
Result: This did not fix the issue either.
### 8. Simple Scroll Position Management with Event Listeners
```typescript
// Create a ref to store scroll position
const scrollPosition = useRef({ left: 0, top: 0 });
const tableContainerRef = useRef<HTMLDivElement>(null);
// Save scroll position when scrolling
const handleScroll = useCallback(() => {
if (tableContainerRef.current) {
scrollPosition.current = {
left: tableContainerRef.current.scrollLeft,
top: tableContainerRef.current.scrollTop
};
}
}, []);
// Add scroll listener
useEffect(() => {
const container = tableContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
// Restore scroll position after data changes
useLayoutEffect(() => {
const container = tableContainerRef.current;
if (container) {
const { left, top } = scrollPosition.current;
if (left > 0 || top > 0) {
container.scrollLeft = left;
container.scrollTop = top;
}
}
}, [data]);
```
Result: Still did not maintain scroll position during updates.
### 9. Memoized Scroll Container Component
```typescript
// Create a stable scroll container that won't re-render with the table
const ScrollContainer = React.memo(({ children }: { children: React.ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useRef({ left: 0, top: 0 });
const handleScroll = useCallback(() => {
if (containerRef.current) {
scrollPosition.current = {
left: containerRef.current.scrollLeft,
top: containerRef.current.scrollTop
};
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (container) {
// Set initial scroll position if it exists
if (scrollPosition.current.left > 0 || scrollPosition.current.top > 0) {
container.scrollLeft = scrollPosition.current.left;
container.scrollTop = scrollPosition.current.top;
}
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
return (
<div ref={containerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
{children}
</div>
);
});
```
Result: Still did not maintain scroll position during updates, even with a memoized container.
### 10. Using TanStack Table State Management
```typescript
// Track scroll state in the table instance
const [scrollState, setScrollState] = useState({ scrollLeft: 0, scrollTop: 0 });
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: {
rowSelection,
// Include scroll position in table state
scrollLeft: scrollState.scrollLeft,
scrollTop: scrollState.scrollTop
},
onStateChange: (updater) => {
if (typeof updater === 'function') {
const newState = updater({
rowSelection,
scrollLeft: scrollState.scrollLeft,
scrollTop: scrollState.scrollTop
});
if ('scrollLeft' in newState || 'scrollTop' in newState) {
setScrollState({
scrollLeft: newState.scrollLeft ?? scrollState.scrollLeft,
scrollTop: newState.scrollTop ?? scrollState.scrollTop
});
}
}
}
});
// Handle scroll events
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const target = event.target as HTMLDivElement;
setScrollState({
scrollLeft: target.scrollLeft,
scrollTop: target.scrollTop
});
}, []);
// Restore scroll position after updates
useLayoutEffect(() => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollLeft = scrollState.scrollLeft;
tableContainerRef.current.scrollTop = scrollState.scrollTop;
}
}, [data, scrollState]);
```
Result: Still did not maintain scroll position during updates, even with table state management.
### 11. Using CSS Sticky Positioning
```typescript
return (
<div className="relative max-h-[calc(100vh-300px)] overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
position: 'sticky',
top: 0,
backgroundColor: 'inherit'
}}
>
{/* Header content */}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* Table body content */}
</TableBody>
</Table>
</div>
);
```
Result: Still did not maintain scroll position during updates, even with native CSS scrolling.
### 12. Optimized Memoization with Object.is
```typescript
// Memoize data structures to prevent unnecessary re-renders
const memoizedData = useMemo(() => data, [data]);
const memoizedValidationErrors = useMemo(() => validationErrors, [validationErrors]);
const memoizedValidatingCells = useMemo(() => validatingCells, [validatingCells]);
const memoizedItemNumbers = useMemo(() => itemNumbers, [itemNumbers]);
// Use Object.is for more efficient comparisons
export default React.memo(ValidationTable, (prev, next) => {
if (!Object.is(prev.data.length, next.data.length)) return false;
if (prev.validationErrors.size !== next.validationErrors.size) return false;
for (const [key, value] of prev.validationErrors) {
if (!next.validationErrors.has(key)) return false;
if (!Object.is(value, next.validationErrors.get(key))) return false;
}
// ... more optimized comparisons ...
});
```
Result: Caused the page to crash with "TypeError: undefined has no properties" in the MemoizedCell component.
### 13. Simplified Component Structure
```typescript
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
// ... other props
}) => {
const tableContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
// Simple scroll position management
const handleScroll = useCallback(() => {
if (tableContainerRef.current) {
lastScrollPosition.current = {
left: tableContainerRef.current.scrollLeft,
top: tableContainerRef.current.scrollTop
};
}
}, []);
useEffect(() => {
const container = tableContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
useLayoutEffect(() => {
const container = tableContainerRef.current;
if (container) {
const { left, top } = lastScrollPosition.current;
if (left > 0 || top > 0) {
requestAnimationFrame(() => {
if (container) {
container.scrollLeft = left;
container.scrollTop = top;
}
});
}
}
}, [data]);
return (
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<Table>
{/* ... table content ... */}
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
)}
>
{/* ... row content ... */}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
```
Result: Still did not maintain scroll position during updates. However, this implementation restored the subtle red highlight on rows with validation errors, which is a useful visual indicator that should be preserved in future attempts.
### 14. Portal-Based Scroll Container
```typescript
// Create a stable container outside of React's control
const createStableContainer = () => {
const containerId = 'validation-table-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'overflow-auto';
container.style.maxHeight = 'calc(100vh - 300px)';
document.body.appendChild(container);
}
return container;
};
const ValidationTable = <T extends string>({...props}) => {
const [container] = useState(createStableContainer);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
};
}, [container]);
// ... table configuration ...
return createPortal(content, container);
};
```
Result: The table contents failed to render at all. The portal-based approach to maintain scroll position by moving the scroll container outside of React's control was unsuccessful.
## Current Understanding
The scroll position issue appears to be complex and likely stems from multiple factors:
1. React's virtual DOM reconciliation may be replacing the scroll container element during updates
2. The table uses complex memo patterns with custom equality checks that may not be working as expected
3. The data structure may be changing in ways that cause complete re-renders
4. The component hierarchy (with EnhancedValidationTable wrapper) may be affecting DOM stability
## Next Steps to Consider
At this point, we have tried multiple approaches without success:
1. Various scroll position management techniques
2. Memoization and optimization strategies
3. Different component structures
4. Portal-based rendering
Given that none of these approaches have fully resolved the issue, it may be worth:
1. Investigating if there are any parent component updates forcing re-renders
2. Profiling the application to identify the exact timing of scroll position resets
3. Considering if the current table implementation could be simplified
4. Exploring if the data update patterns could be optimized to reduce re-renders
## Conclusion
The scroll position issue has proven resistant to multiple solution attempts. Each approach has either failed to maintain scroll position, introduced new issues, or in some cases (like the portal-based approach) prevented the table from rendering entirely. A deeper investigation into the component lifecycle and data flow may be necessary to identify the root cause.

View File

@@ -742,7 +742,7 @@ router.post("/validate", async (req, res) => {
console.log("🤖 Sending request to OpenAI...");
const completion = await openai.chat.completions.create({
model: "gpt-4o",
model: "o3-mini",
messages: [
{
role: "user",

View File

@@ -526,7 +526,7 @@ router.get('/field-options', async (req, res) => {
// Fetch tax categories
const [taxCategories] = await connection.query(`
SELECT tax_code_id as value, name as label
SELECT CAST(tax_code_id AS CHAR) as value, name as label
FROM product_tax_codes
ORDER BY tax_code_id = 0 DESC, name
`);
@@ -820,6 +820,8 @@ router.get('/search-products', async (req, res) => {
s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference,
sid.notions_itemnumber AS notions_reference,
sid.supplier_id AS supplier,
sid.notions_case_pack AS case_qty,
pc1.name AS brand,
p.company AS brand_id,
pc2.name AS line,
@@ -837,9 +839,12 @@ router.get('/search-products', async (req, res) => {
p.width,
p.height,
p.country_of_origin,
p.totalsold AS total_sold,
ci.totalsold AS total_sold,
p.datein AS first_received,
pls.date_sold AS date_last_sold
pls.date_sold AS date_last_sold,
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
CAST(p.size_cat AS CHAR) AS size_cat,
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions
FROM products p
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
@@ -849,6 +854,7 @@ router.get('/search-products', async (req, res) => {
LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
LEFT JOIN current_inventory ci ON p.pid = ci.pid
${whereClause}
GROUP BY p.pid
${isWildcardSearch ? 'ORDER BY p.datein DESC' : `
@@ -892,6 +898,21 @@ router.get('/search-products', async (req, res) => {
const [results] = await connection.query(query, queryParams);
// Debug log to check values
if (results.length > 0) {
console.log('Product search result sample fields:', {
pid: results[0].pid,
tax_code: results[0].tax_code,
tax_code_type: typeof results[0].tax_code,
tax_code_value: `Value: '${results[0].tax_code}'`,
size_cat: results[0].size_cat,
shipping_restrictions: results[0].shipping_restrictions,
supplier: results[0].supplier,
case_qty: results[0].case_qty,
moq: results[0].moq
});
}
res.json(results);
} catch (error) {
console.error('Error searching products:', error);
@@ -924,16 +945,16 @@ router.get('/check-upc-and-generate-sku', async (req, res) => {
});
}
// Step 2: Generate item number - supplierId-last6DigitsOfUPC minus last digit
// Step 2: Generate item number - supplierId-last5DigitsOfUPC minus last digit
let itemNumber = '';
const upcStr = String(upc);
// Extract the last 6 digits of the UPC, removing the last digit (checksum)
// So we get 5 digits from positions: length-7 to length-2
if (upcStr.length >= 7) {
const lastSixMinusOne = upcStr.substring(upcStr.length - 7, upcStr.length - 1);
itemNumber = `${supplierId}-${lastSixMinusOne}`;
} else if (upcStr.length >= 6) {
// Extract the last 5 digits of the UPC, removing the last digit (checksum)
// So we get 5 digits from positions: length-6 to length-2
if (upcStr.length >= 6) {
const lastFiveMinusOne = upcStr.substring(upcStr.length - 6, upcStr.length - 1);
itemNumber = `${supplierId}-${lastFiveMinusOne}`;
} else if (upcStr.length >= 5) {
// If UPC is shorter, use as many digits as possible
const digitsToUse = upcStr.substring(0, upcStr.length - 1);
itemNumber = `${supplierId}-${digitsToUse}`;
@@ -1007,7 +1028,7 @@ router.get('/product-categories/:pid', async (req, res) => {
// Query to get categories for a specific product
const query = `
SELECT pc.cat_id, pc.name, pc.type, pc.combined_name
SELECT pc.cat_id, pc.name, pc.type, pc.combined_name, pc.master_cat_id
FROM product_category_index pci
JOIN product_categories pc ON pci.cat_id = pc.cat_id
WHERE pci.pid = ?
@@ -1022,8 +1043,61 @@ router.get('/product-categories/:pid', async (req, res) => {
console.log(`Product ${pid} has ${rows.length} categories with types: ${uniqueTypes.join(', ')}`);
console.log('Categories:', rows.map(row => ({ id: row.cat_id, name: row.name, type: row.type })));
// Check for parent categories to filter out deals and black friday
const sectionQuery = `
SELECT pc.cat_id, pc.name
FROM product_categories pc
WHERE pc.type = 10 AND (LOWER(pc.name) LIKE '%deal%' OR LOWER(pc.name) LIKE '%black friday%')
`;
const [dealSections] = await connection.query(sectionQuery);
const dealSectionIds = dealSections.map(section => section.cat_id);
console.log('Filtering out categories from deal sections:', dealSectionIds);
// Filter out categories from deals and black friday sections
const filteredCategories = rows.filter(category => {
// Direct check for top-level deal sections
if (category.type === 10) {
return !dealSectionIds.some(id => id === category.cat_id);
}
// For categories (type 11), check if their parent is a deal section
if (category.type === 11) {
return !dealSectionIds.some(id => id === category.master_cat_id);
}
// For subcategories (type 12), get their parent category first
if (category.type === 12) {
const parentId = category.master_cat_id;
// Find the parent category in our rows
const parentCategory = rows.find(c => c.cat_id === parentId);
// If parent not found or parent's parent is not a deal section, keep it
return !parentCategory || !dealSectionIds.some(id => id === parentCategory.master_cat_id);
}
// For subsubcategories (type 13), check their hierarchy manually
if (category.type === 13) {
const parentId = category.master_cat_id;
// Find the parent subcategory
const parentSubcategory = rows.find(c => c.cat_id === parentId);
if (!parentSubcategory) return true;
// Find the grandparent category
const grandparentId = parentSubcategory.master_cat_id;
const grandparentCategory = rows.find(c => c.cat_id === grandparentId);
// If grandparent not found or grandparent's parent is not a deal section, keep it
return !grandparentCategory || !dealSectionIds.some(id => id === grandparentCategory.master_cat_id);
}
// Keep all other category types
return true;
});
console.log(`Filtered out ${rows.length - filteredCategories.length} deal/black friday categories`);
// Format the response to match the expected format in the frontend
const categories = rows.map(category => ({
const categories = filteredCategories.map(category => ({
value: category.cat_id.toString(),
label: category.name,
type: category.type,

File diff suppressed because it is too large Load Diff

View File

@@ -10,22 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/button": "^2.1.0",
"@chakra-ui/checkbox": "^2.3.2",
"@chakra-ui/form-control": "^2.2.0",
"@chakra-ui/hooks": "^2.4.3",
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/input": "^2.1.2",
"@chakra-ui/layout": "^2.3.1",
"@chakra-ui/modal": "^2.3.1",
"@chakra-ui/popper": "^3.1.0",
"@chakra-ui/react": "^2.8.1",
"@chakra-ui/select": "^2.1.2",
"@chakra-ui/system": "^2.6.2",
"@chakra-ui/theme": "^3.4.7",
"@chakra-ui/theme-tools": "^2.2.7",
"@chakra-ui/utils": "^2.2.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
@@ -60,8 +45,6 @@
"@types/js-levenshtein": "^1.1.3",
"@types/uuid": "^10.0.0",
"axios": "^1.8.1",
"chakra-react-select": "^4.7.5",
"chakra-ui-steps": "^2.0.4",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",

View File

@@ -1,25 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
async function copyBuild() {
const sourcePath = path.resolve(__dirname, '../build');
const targetPath = path.resolve(__dirname, '../../inventory-server/frontend/build');
try {
// Ensure the target directory exists
await fs.ensureDir(path.dirname(targetPath));
// Remove old build if it exists
await fs.remove(targetPath);
// Copy new build
await fs.copy(sourcePath, targetPath);
console.log('Build files copied successfully to server directory!');
} catch (error) {
console.error('Error copying build files:', error);
process.exit(1);
}
}
copyBuild();

View File

@@ -16,7 +16,6 @@ import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/Import';
import { ChakraProvider } from '@chakra-ui/react';
import { AiValidationDebug } from "@/pages/AiValidationDebug"
const queryClient = new QueryClient();
@@ -53,30 +52,28 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<ChakraProvider>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
<Route element={
<RequireAuth>
<MainLayout />
</RequireAuth>
}>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} />
<Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
<Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="/ai-validation/debug" element={<AiValidationDebug />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ChakraProvider>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
<Route element={
<RequireAuth>
<MainLayout />
</RequireAuth>
}>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} />
<Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
<Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="/ai-validation/debug" element={<AiValidationDebug />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</QueryClientProvider>
);
}

View File

@@ -99,7 +99,7 @@ export function CategoryPerformance() {
))}
</Pie>
<Tooltip
formatter={(value: number, name: string, props: any) => [
formatter={(value: number, _: string, props: any) => [
`$${value.toLocaleString()}`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
@@ -143,7 +143,7 @@ export function CategoryPerformance() {
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, name: string, props: any) => [
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>

View File

@@ -96,7 +96,7 @@ export function ProfitAnalysis() {
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, name: string, props: any) => [
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>

View File

@@ -33,15 +33,6 @@ interface BestSellerBrand {
growth_rate: string
}
interface BestSellerCategory {
cat_id: number;
name: string;
units_sold: number;
revenue: string;
profit: string;
growth_rate: string;
}
interface BestSellersData {
products: Product[]
brands: BestSellerBrand[]

View File

@@ -9,8 +9,8 @@ import {
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { AlertCircle, AlertTriangle } from "lucide-react"
import config from "@/config"
import { format } from "date-fns"
interface Product {
pid: number;
@@ -24,6 +24,24 @@ interface Product {
lead_time_status: string;
}
// Helper functions
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'MMM dd, yyyy')
}
const getLeadTimeVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'critical':
return 'destructive'
case 'warning':
return 'secondary'
case 'good':
return 'secondary'
default:
return 'secondary'
}
}
export function LowStockAlerts() {
const { data: products } = useQuery<Product[]>({
queryKey: ["low-stock"],

View File

@@ -5,7 +5,6 @@ import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
import { useState } from "react"
import { PurchaseOrderStatus, ReceivingStatus } from "@/types/status-codes"
interface PurchaseMetricsData {
activePurchaseOrders: number // Orders that are not canceled, done, or fully received

View File

@@ -41,14 +41,6 @@ export function TrendingProducts() {
signDisplay: "exceptZero",
}).format(value / 100)
const formatCurrency = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
return (
<>
<CardHeader>

View File

@@ -169,7 +169,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
{products.map((product: Product) => (
<TableRow key={product.pid}>
<TableCell>
<a

View File

@@ -1,13 +1,13 @@
import merge from "lodash/merge"
import { Steps } from "./steps/Steps"
import { rtlThemeSupport, themeOverrides } from "./theme"
import { Providers } from "./components/Providers"
import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps"
export const defaultTheme = themeOverrides
// Simple empty theme placeholder
export const defaultTheme = {}
export const defaultRSIProps: Partial<RsiProps<any>> = {
autoMapHeaders: true,
@@ -27,12 +27,9 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
const props = merge({}, defaultRSIProps, propsWithoutDefaults)
const mergedTranslations =
props.translations !== translations ? merge(translations, props.translations) : translations
const mergedThemes = props.rtl
? merge(defaultTheme, rtlThemeSupport, props.customTheme)
: merge(defaultTheme, props.customTheme)
return (
<Providers theme={mergedThemes} rsiValues={{ ...props, translations: mergedTranslations }}>
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
</ModalWrapper>

View File

@@ -20,7 +20,7 @@ import {
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi"
import { useState } from "react"
import { useState, useCallback } from "react"
type Props = {
children: React.ReactNode
@@ -32,6 +32,22 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl, translations } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false)
// Create a handler that resets scroll positions before closing
const handleClose = useCallback(() => {
// Reset all scroll positions in the dialog
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
scrollContainers.forEach(container => {
if (container instanceof HTMLElement) {
// Reset scroll position to top-left
container.scrollTop = 0;
container.scrollLeft = 0;
}
});
// Call the original onClose handler
onClose();
}, [onClose]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
@@ -76,7 +92,7 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
{translations.alerts.confirmClose.cancelButtonTitle}
</AlertDialogCancel>
<AlertDialogAction onClick={onClose}>
<AlertDialogAction onClick={handleClose}>
{translations.alerts.confirmClose.exitButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>

View File

@@ -0,0 +1,24 @@
import { createContext } from "react"
import type { RsiProps } from "../types"
export const RsiContext = createContext({} as any)
type ProvidersProps<T extends string> = {
children: React.ReactNode
rsiValues: RsiProps<T>
}
// No need for a root ID as we're not using Chakra anymore
export const rootId = "rsi-modal-root"
export const Providers = <T extends string>({ children, rsiValues }: ProvidersProps<T>) => {
if (!rsiValues.fields) {
throw new Error("Fields must be provided to react-spreadsheet-import")
}
return (
<RsiContext.Provider value={{ ...rsiValues }}>
{children}
</RsiContext.Provider>
)
}

View File

@@ -1,2 +1,3 @@
export { StepType } from "./steps/UploadFlow"
export { ReactSpreadsheetImport } from "./ReactSpreadsheetImport"
export * from "./types"

View File

@@ -0,0 +1,294 @@
import { useCallback, useState, useRef, useEffect, createRef } from "react";
import { useRsi } from "../../hooks/useRsi";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay
} from '@dnd-kit/core';
import {
sortableKeyboardCoordinates} from '@dnd-kit/sortable';
import { Product } from "./types";
import { GenericDropzone } from "./components/GenericDropzone";
import { UnassignedImagesSection } from "./components/UnassignedImagesSection";
import { ProductCard } from "./components/ProductCard/ProductCard";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
import { useProductImagesInit } from "./hooks/useProductImagesInit";
import { useProductImageOperations } from "./hooks/useProductImageOperations";
import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
interface Props {
data: Product[];
file: File;
onBack?: () => void;
onSubmit: (data: Product[], file: File) => void | Promise<any>;
}
export const ImageUploadStep = ({
data,
file,
onBack,
onSubmit
}: Props) => {
useRsi();
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRefs = useRef<{ [key: number]: React.RefObject<HTMLInputElement> }>({});
// Use our hook for product images initialization
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
// Use our hook for product image operations
const {
addImageToProduct,
handleImageUpload,
removeImage
} = useProductImageOperations({
data,
productImages,
setProductImages
});
// Use our hook for URL image uploads
const {
urlInputs,
processingUrls,
handleAddImageFromUrl,
updateUrlInput
} = useUrlImageUpload({
data,
setProductImages,
addImageToProduct
});
// Use our hook for bulk image uploads
const {
unassignedImages,
processingBulk,
showUnassigned,
setShowUnassigned,
handleBulkUpload,
assignImageToProduct,
removeUnassignedImage,
cleanupPreviewUrls
} = useBulkImageUpload({
data,
handleImageUpload
});
// Set up sensors for drag and drop with enhanced configuration
const sensors = useSensors(
useSensor(PointerSensor, {
// Make it responsive with less restrictive constraints
activationConstraint: {
distance: 1, // Reduced distance for more responsive drag
delay: 0, // No delay
tolerance: 5
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Use the drag and drop hook
const {
activeId,
activeImage,
activeDroppableId,
customCollisionDetection,
findContainer,
getProductContainerClasses,
handleDragStart,
handleDragOver,
handleDragEnd
} = useDragAndDrop({
productImages,
setProductImages,
data
});
// Initialize refs for each product
useEffect(() => {
// Create refs for each product's file input
data.forEach((_: Product, index: number) => {
if (!fileInputRefs.current[index]) {
fileInputRefs.current[index] = createRef<HTMLInputElement>();
}
});
}, [data]);
// Add this CSS for preventing browser drag behavior
useEffect(() => {
// Add a custom style element to the document head
const styleEl = document.createElement('style');
styleEl.textContent = `
.no-native-drag {
-webkit-user-drag: none;
user-select: none;
}
.no-native-drag img {
-webkit-user-drag: none;
}
`;
document.head.appendChild(styleEl);
return () => {
// Clean up on unmount
document.head.removeChild(styleEl);
// Clean up preview URLs
cleanupPreviewUrls();
};
}, []);
// Handle calling onSubmit with the current data
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
// First, we need to ensure product_images is properly formatted for each product
const updatedData = [...data].map((product, index) => {
// Get all images for this product
const images = productImages
.filter(img => img.productIndex === index)
.map(img => img.imageUrl)
.filter(Boolean);
// Update the product with the formatted image URLs
return {
...product,
// Store as comma-separated string to ensure compatibility
product_images: images.join(',')
};
});
await onSubmit(updatedData, file);
} catch (error) {
console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
}
}, [data, file, onSubmit, productImages]);
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Header - fixed at top */}
<div className="px-8 py-6 bg-background shrink-0">
<h2 className="text-2xl font-semibold">Add Product Images</h2>
<p className="text-sm text-muted-foreground mt-1">
Drag images to reorder them or move them between products.
</p>
</div>
{/* Content area - only this part scrolls */}
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col overflow-auto">
<div className="px-8 py-4 shrink-0">
<GenericDropzone
processingBulk={processingBulk}
unassignedImages={unassignedImages}
showUnassigned={showUnassigned}
onDrop={handleBulkUpload}
onShowUnassigned={() => setShowUnassigned(true)}
/>
</div>
<div className="px-8 py-2 shrink-0">
<UnassignedImagesSection
showUnassigned={showUnassigned}
unassignedImages={unassignedImages}
data={data}
onHide={() => setShowUnassigned(false)}
onAssign={assignImageToProduct}
onRemove={removeUnassignedImage}
/>
</div>
{/* Scrollable product cards */}
<div className="px-8 py-2 flex-1">
<DndContext
sensors={sensors}
collisionDetection={customCollisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
autoScroll={{
threshold: {
x: 0,
y: 0.2,
}
}}
>
<div className="space-y-2">
{data.map((product: Product, index: number) => (
<ProductCard
key={index}
product={product}
index={index}
urlInput={urlInputs[index] || ''}
processingUrl={processingUrls[index] || false}
activeDroppableId={activeDroppableId}
activeId={activeId}
productImages={productImages}
fileInputRef={fileInputRefs.current[index] || createRef()}
onUrlInputChange={(value: string) => updateUrlInput(index, value)}
onUrlSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (urlInputs[index]) {
handleAddImageFromUrl(index, urlInputs[index]);
}
}}
onImageUpload={(files: FileList | File[]) => handleImageUpload(files, index)}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}}
onRemoveImage={(imageId: string) =>
removeImage(productImages.findIndex(img => img.id === imageId))
}
getProductContainerClasses={() => getProductContainerClasses(index)}
findContainer={findContainer}
/>
))}
</div>
<DragOverlay>
{activeImage && (
<div className="relative border rounded-md overflow-hidden shadow-md bg-white">
<img
src={getFullImageUrl(activeImage.imageUrl)}
alt={activeImage.fileName}
className="w-24 h-24 object-contain "
/>
</div>
)}
</DragOverlay>
</DndContext>
</div>
</div>
</div>
{/* Footer - fixed at bottom */}
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-1 shrink-0">
{onBack && (
<Button variant="outline" onClick={onBack}>
Back
</Button>
)}
<Button
className="ml-auto"
onClick={handleSubmit}
disabled={isSubmitting || unassignedImages.length > 0}
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{unassignedImages.length > 0 ? 'Assign all images first' : 'Submit'}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,30 @@
import { useDroppable } from '@dnd-kit/core';
interface DroppableContainerProps {
id: string;
children: React.ReactNode;
isEmpty: boolean;
}
export const DroppableContainer = ({ id, children, isEmpty }: DroppableContainerProps) => {
const { setNodeRef } = useDroppable({
id,
data: {
type: 'container',
isEmpty
}
});
return (
<div
ref={setNodeRef}
id={id}
data-droppable="true"
data-empty={isEmpty ? "true" : "false"}
className="w-full h-full flex flex-row flex-wrap gap-2"
style={{ minHeight: '100px' }} // Ensure minimum height for empty containers
>
{children}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import { Button } from "@/components/ui/button";
import { Loader2, Upload } from "lucide-react";
import { useDropzone } from "react-dropzone";
import { cn } from "@/lib/utils";
interface GenericDropzoneProps {
processingBulk: boolean;
unassignedImages: { previewUrl: string; file: File }[];
showUnassigned: boolean;
onDrop: (files: File[]) => void;
onShowUnassigned: () => void;
}
export const GenericDropzone = ({
processingBulk,
unassignedImages,
showUnassigned,
onDrop,
onShowUnassigned
}: GenericDropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
},
onDrop,
multiple: true
});
return (
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md w-full py-6 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/70 transition-colors",
isDragActive && "border-primary bg-muted"
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center h-32 py-2">
{processingBulk ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<p className="text-base text-muted-foreground">Processing images...</p>
</>
) : isDragActive ? (
<>
<Upload className="h-8 w-8 mb-2 text-primary" />
<p className="text-base text-muted-foreground mb-2">Drop images here</p>
<p className="text-sm text-muted-foreground">&nbsp;</p>
</>
) : (
<>
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
<p className="text-base text-muted-foreground mb-2">Drop images here or click to select</p>
<p className="text-sm text-muted-foreground">Images dropped here will be automatically assigned to products based on filename</p>
{unassignedImages.length > 0 && !showUnassigned && (
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
onShowUnassigned();
}}
className="mt-2 text-amber-600 dark:text-amber-400"
>
Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'}
</Button>
)}
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { toast } from "sonner";
interface CopyButtonProps {
text: string;
itemKey: string;
}
export const CopyButton = ({ text }: CopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const canCopy = text && text !== 'N/A';
const copyToClipboard = () => {
if (!canCopy) return;
navigator.clipboard.writeText(text)
.then(() => {
// Show success state
setIsCopied(true);
// Show toast notification
toast.success(`Copied: ${text}`);
// Reset after 2 seconds
setTimeout(() => {
setIsCopied(false);
}, 2000);
})
.catch(err => {
console.error('Failed to copy:', err);
toast.error('Failed to copy to clipboard');
});
};
return (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard();
}}
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
canCopy
? isCopied
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400"
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!canCopy}
title={canCopy ? "Copy to clipboard" : "Nothing to copy"}
>
{isCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
);
};

View File

@@ -0,0 +1,39 @@
import { Upload } from "lucide-react";
import { useDropzone } from "react-dropzone";
import { cn } from "@/lib/utils";
interface ImageDropzoneProps {
productIndex: number;
onDrop: (files: File[]) => void;
}
export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
},
onDrop: (acceptedFiles) => {
onDrop(acceptedFiles);
},
});
return (
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md h-24 w-24 flex flex-col items-center justify-center self-center cursor-pointer hover:bg-muted/70 transition-colors shrink-0",
isDragActive && "border-primary bg-muted"
)}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="text-xs text-center text-muted-foreground p-1">Drop images here</div>
) : (
<>
<Upload className="h-5 w-5 mb-1 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Add Images</span>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,163 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, Link as LinkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ImageDropzone } from "./ImageDropzone";
import { SortableImage } from "./SortableImage";
import { CopyButton } from "./CopyButton";
import { ProductImageSortable, Product } from "../../types";
import { DroppableContainer } from "../DroppableContainer";
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
interface ProductCardProps {
product: Product;
index: number;
urlInput: string;
processingUrl: boolean;
activeDroppableId: string | null;
activeId: string | null;
productImages: ProductImageSortable[];
fileInputRef: React.RefObject<HTMLInputElement>;
onUrlInputChange: (value: string) => void;
onUrlSubmit: (e: React.FormEvent) => void;
onImageUpload: (files: FileList | File[]) => void;
onDragOver: (e: React.DragEvent) => void;
onRemoveImage: (id: string) => void;
getProductContainerClasses: () => string;
findContainer: (id: string) => string | null;
}
export const ProductCard = ({
product,
index,
urlInput,
processingUrl,
activeDroppableId,
activeId,
productImages,
fileInputRef,
onUrlInputChange,
onUrlSubmit,
onImageUpload,
onDragOver,
onRemoveImage,
getProductContainerClasses,
findContainer
}: ProductCardProps) => {
// Function to get images for this product
const getProductImages = () => {
return productImages.filter(img => img.productIndex === index);
};
// Convert string container to number for internal comparison
const getContainerAsNumber = (id: string): number | null => {
const result = findContainer(id);
return result !== null ? parseInt(result) : null;
};
return (
<Card
className={cn(
"p-3 transition-colors",
activeDroppableId === `product-${index}` && activeId &&
getContainerAsNumber(activeId) !== index &&
"ring-2 ring-primary bg-primary/5"
)}
>
<CardContent className="p-0">
<div className="flex flex-col gap-2">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2">
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
<div className="text-xs lg:text-sm text-muted-foreground">
<span className="font-medium">UPC:</span> {product.upc || 'N/A'}
<CopyButton text={product.upc || ''} itemKey={`upc-${index}`} />
{' | '}
<span className="font-medium">Supplier #:</span> {product.supplier_no || 'N/A'}
<CopyButton text={product.supplier_no || ''} itemKey={`supplier-${index}`} />
</div>
</div>
<div className="flex-shrink-0">
<form
className="flex items-center gap-2"
onSubmit={onUrlSubmit}
>
<Input
placeholder="Add image from URL"
value={urlInput}
onChange={(e) => onUrlInputChange(e.target.value)}
className="!text-xs h-8 w-[180px]"
/>
<Button
type="submit"
size="sm"
className="h-8 whitespace-nowrap flex gap-1 items-center text-xs"
disabled={processingUrl || !urlInput}
>
{processingUrl ?
<Loader2 className="h-3.5 w-3.5 animate-spin" /> :
<LinkIcon className="h-3.5 w-3.5" />}
Add
</Button>
</form>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex flex-row gap-2 items-start">
<ImageDropzone
productIndex={index}
onDrop={onImageUpload}
/>
</div>
<div
className={getProductContainerClasses()}
style={{
pointerEvents: 'auto',
touchAction: 'none',
minHeight: '100px',
}}
onDragOver={onDragOver}
>
<DroppableContainer
id={`product-${index}`}
isEmpty={getProductImages().length === 0}
>
{getProductImages().length > 0 ? (
<SortableContext
items={getProductImages().map(img => img.id)}
strategy={horizontalListSortingStrategy}
>
{getProductImages().map((image, imgIndex) => (
<SortableImage
key={image.id}
image={image}
productIndex={index}
imgIndex={imgIndex}
productName={product.name}
removeImage={onRemoveImage}
/>
))}
</SortableContext>
) : (
<div className="w-full h-full" data-empty-placeholder="true"></div>
)}
</DroppableContainer>
</div>
</div>
<Input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
multiple
onChange={(e) => e.target.files && onImageUpload(e.target.files)}
/>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,198 @@
import { useState, useRef } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Trash2, Maximize2, GripVertical, X } from "lucide-react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
// Define the ProductImage interface
interface ProductImage {
id: string;
url?: string;
imageUrl?: string;
fileName?: string;
loading?: boolean;
isLoading?: boolean;
// Optional fields from the full ProductImage type
productIndex?: number;
pid?: number;
iid?: number;
type?: number;
order?: number;
width?: number;
height?: number;
hidden?: number;
}
// Define the SortableImageProps interface
interface SortableImageProps {
image: ProductImage;
productIndex: number;
imgIndex: number;
productName?: string; // Make this optional
removeImage: (id: string) => void; // Changed to match ProductCard
}
// Function to ensure URLs are properly formatted with absolute paths
const getFullImageUrl = (url: string): string => {
// If the URL is already absolute (starts with http:// or https://) return it as is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://inventory.acot.site';
// Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`;
};
export const SortableImage = ({
image,
productIndex,
imgIndex,
productName,
removeImage
}: SortableImageProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({
id: image.id,
data: {
productIndex,
image,
type: 'image'
}
});
// Create a new style object with fixed dimensions to prevent distortion
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 999 : 1, // Higher z-index when dragging
touchAction: 'none', // Prevent touch scrolling during drag
userSelect: 'none', // Prevent text selection during drag
cursor: isDragging ? 'grabbing' : 'grab',
width: '96px',
height: '96px',
flexShrink: 0,
flexGrow: 0,
position: 'relative',
};
// Create a ref for the buttons to exclude them from drag listeners
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const zoomButtonRef = useRef<HTMLButtonElement>(null);
const displayName = productName || `Product #${productIndex + 1}`;
return (
<div
ref={setNodeRef}
style={style}
className="relative border rounded-md overflow-hidden flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing select-none no-native-drag group hover:ring-2 hover:ring-primary/30 transition-all"
{...attributes}
{...listeners}
onDragStart={(e) => {
// This ensures the native drag doesn't interfere
e.preventDefault();
e.stopPropagation();
}}
>
{image.loading ? (
<div className="flex flex-col items-center justify-center p-2">
<Loader2 className="h-5 w-5 animate-spin mb-1" />
<span className="text-xs text-center truncate w-full">{image.fileName}</span>
</div>
) : (
<>
<img
src={getFullImageUrl(image.url || image.imageUrl || '')}
alt={`${displayName} - Image ${imgIndex + 1}`}
className="h-full w-full object-cover select-none no-native-drag"
draggable={false}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
<div className="absolute right-0 top-0 p-1 opacity-0 group-hover:opacity-90 transition-opacity">
<GripVertical className="h-3 w-3 text-white drop-shadow-md" />
</div>
<button
ref={deleteButtonRef}
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 top-1 right-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation(); // Prevent triggering drag listeners
removeImage(image.id);
}}
onPointerDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onTouchStart={(e) => {
e.stopPropagation(); // Prevent drag from starting on touch
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<button
ref={zoomButtonRef}
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 bottom-1 left-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation(); // Prevent triggering drag listeners
setDialogOpen(true);
}}
onPointerDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onTouchStart={(e) => {
e.stopPropagation(); // Prevent drag from starting on touch
}}
>
<Maximize2 className="h-3 w-3" />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
<div className="relative flex flex-col items-center justify-center">
<Button
variant="ghost"
size="icon"
className="absolute -top-2 -right-2 bg-black/60 text-white hover:bg-black/80 rounded-full z-10 h-8 w-8"
onClick={() => setDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
<img
src={getFullImageUrl(image.url || image.imageUrl || '')}
alt={`${displayName} - Image ${imgIndex + 1}`}
className="max-h-[70vh] max-w-full object-contain"
/>
</div>
<div className="mt-2 text-sm text-muted-foreground text-center">
{`${displayName} - Image ${imgIndex + 1}`}
</div>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UnassignedImage, Product } from "../types";
import { UnassignedImageItem } from "./UnassignedImagesSection/UnassignedImageItem";
interface UnassignedImagesSectionProps {
showUnassigned: boolean;
unassignedImages: UnassignedImage[];
data: Product[];
onHide: () => void;
onAssign: (imageIndex: number, productIndex: number) => void;
onRemove: (index: number) => void;
}
export const UnassignedImagesSection = ({
showUnassigned,
unassignedImages,
data,
onHide,
onAssign,
onRemove
}: UnassignedImagesSectionProps) => {
if (!showUnassigned || unassignedImages.length === 0) return null;
return (
<div className="mb-4 px-4">
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-400">
Unassigned Images ({unassignedImages.length})
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={onHide}
className="h-8 text-muted-foreground"
>
Hide
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{unassignedImages.map((image, index) => (
<UnassignedImageItem
key={index}
image={image}
index={index}
data={data}
onAssign={onAssign}
onRemove={onRemove}
/>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Maximize2, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UnassignedImage, Product } from "../../types";
interface UnassignedImageItemProps {
image: UnassignedImage;
index: number;
data: Product[];
onAssign: (imageIndex: number, productIndex: number) => void;
onRemove: (index: number) => void;
}
export const UnassignedImageItem = ({
image,
index,
data,
onAssign,
onRemove
}: UnassignedImageItemProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="relative border rounded-md overflow-hidden">
<img
src={image.previewUrl}
alt={`Unassigned image ${index + 1}`}
className="h-full w-full object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2">
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
<div className="flex gap-2">
<Select onValueChange={(value) => onAssign(index, parseInt(value))}>
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
<SelectValue placeholder="Assign to..." />
</SelectTrigger>
<SelectContent>
{data.map((product: Product, productIndex: number) => (
<SelectItem key={productIndex} value={productIndex.toString()}>
{product.name || `Product #${productIndex + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
onClick={(e) => {
e.stopPropagation();
onRemove(index);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Zoom button for unassigned images */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<button
className="absolute top-1 left-1 bg-black/60 rounded-full p-1 text-white z-10 hover:bg-black/80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
<div className="relative flex flex-col items-center justify-center">
<Button
variant="ghost"
size="icon"
className="absolute -top-2 -right-2 bg-black/60 text-white hover:bg-black/80 rounded-full z-10 h-8 w-8"
onClick={() => setDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
<img
src={image.previewUrl}
alt={`Unassigned image: ${image.file.name}`}
className="max-h-[70vh] max-w-full object-contain"
/>
</div>
<div className="mt-2 text-sm text-muted-foreground text-center">
{`Unassigned image: ${image.file.name}`}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { toast } from "sonner";
import { UnassignedImage, Product } from "../types";
type HandleImageUploadFn = (files: FileList | File[], productIndex: number) => Promise<void>;
interface UseBulkImageUploadProps {
data: Product[];
handleImageUpload: HandleImageUploadFn;
}
export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUploadProps) => {
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
const [processingBulk, setProcessingBulk] = useState(false);
const [showUnassigned, setShowUnassigned] = useState(false);
// Function to extract identifiers from a filename
const extractIdentifiers = (filename: string): string[] => {
// Remove file extension and convert to lowercase
const nameWithoutExt = filename.split('.').slice(0, -1).join('.').toLowerCase();
// Split by common separators
const parts = nameWithoutExt.split(/[-_\s.]+/);
// Add the full name without extension as a possible identifier
const identifiers = [nameWithoutExt];
// Add parts with at least 3 characters
identifiers.push(...parts.filter(part => part.length >= 3));
// Look for potential UPC or product codes (digits only)
const digitOnlyParts = parts.filter(part => /^\d+$/.test(part) && part.length >= 5);
identifiers.push(...digitOnlyParts);
// Look for product codes (mix of letters and digits)
const productCodes = parts.filter(part =>
/^[a-z0-9]+$/.test(part) &&
/\d/.test(part) &&
/[a-z]/.test(part) &&
part.length >= 4
);
identifiers.push(...productCodes);
return [...new Set(identifiers)]; // Remove duplicates
};
// Function to find product index by identifier
const findProductByIdentifier = (identifier: string): number => {
// Try to match against supplier_no, upc, SKU, or name
return data.findIndex((product: Product) => {
// Skip if product is missing all identifiers
if (!product.supplier_no && !product.upc && !product.sku && !product.name) {
return false;
}
const supplierNo = String(product.supplier_no || '').toLowerCase();
const upc = String(product.upc || '').toLowerCase();
const sku = String(product.sku || '').toLowerCase();
const name = String(product.name || '').toLowerCase();
const model = String(product.model || '').toLowerCase();
// For exact matches, prioritize certain fields
if (
(supplierNo && identifier === supplierNo) ||
(upc && identifier === upc) ||
(sku && identifier === sku)
) {
return true;
}
// For partial matches, check if the identifier is contained within the field
// or if the field is contained within the identifier
return (
(supplierNo && (supplierNo.includes(identifier) || identifier.includes(supplierNo))) ||
(upc && (upc.includes(identifier) || identifier.includes(upc))) ||
(sku && (sku.includes(identifier) || identifier.includes(sku))) ||
(model && (model.includes(identifier) || identifier.includes(model))) ||
(name && name.includes(identifier))
);
});
};
// Function to create preview URLs for files
const createPreviewUrl = (file: File): string => {
return URL.createObjectURL(file);
};
// Function to handle bulk image upload
const handleBulkUpload = async (files: File[]) => {
if (!files.length) return;
setProcessingBulk(true);
const unassigned: UnassignedImage[] = [];
for (const file of files) {
// Extract identifiers from filename
const identifiers = extractIdentifiers(file.name);
let assigned = false;
// Try to match each identifier
for (const identifier of identifiers) {
const productIndex = findProductByIdentifier(identifier);
if (productIndex !== -1) {
// Found a match, upload to this product
await handleImageUpload([file], productIndex);
assigned = true;
break;
}
}
// If no match was found, add to unassigned
if (!assigned) {
unassigned.push({
file,
previewUrl: createPreviewUrl(file)
});
}
}
// Update unassigned images
setUnassignedImages(prev => [...prev, ...unassigned]);
setProcessingBulk(false);
// Show summary toast
const assignedCount = files.length - unassigned.length;
if (assignedCount > 0) {
toast.success(`Auto-assigned ${assignedCount} ${assignedCount === 1 ? 'image' : 'images'} to products`);
}
if (unassigned.length > 0) {
toast.warning(`Could not auto-assign ${unassigned.length} ${unassigned.length === 1 ? 'image' : 'images'}`);
setShowUnassigned(true);
}
};
// Function to manually assign an unassigned image
const assignImageToProduct = async (imageIndex: number, productIndex: number) => {
const image = unassignedImages[imageIndex];
if (!image) return;
// Upload the image to the selected product
await handleImageUpload([image.file], productIndex);
// Remove from unassigned list
setUnassignedImages(prev => prev.filter((_, idx) => idx !== imageIndex));
// Revoke the preview URL to free memory
URL.revokeObjectURL(image.previewUrl);
toast.success(`Image assigned to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
};
// Function to remove an unassigned image
const removeUnassignedImage = (index: number) => {
const image = unassignedImages[index];
if (!image) return;
// Revoke the preview URL to free memory
URL.revokeObjectURL(image.previewUrl);
// Remove from state
setUnassignedImages(prev => prev.filter((_, idx) => idx !== index));
};
// Cleanup function for preview URLs
const cleanupPreviewUrls = () => {
unassignedImages.forEach(image => {
URL.revokeObjectURL(image.previewUrl);
});
};
return {
unassignedImages,
setUnassignedImages,
processingBulk,
showUnassigned,
setShowUnassigned,
handleBulkUpload,
assignImageToProduct,
removeUnassignedImage,
cleanupPreviewUrls
};
};

View File

@@ -0,0 +1,340 @@
import { useState, useEffect } from "react";
import {
DragEndEvent,
DragStartEvent,
DragMoveEvent,
CollisionDetection,
pointerWithin,
rectIntersection
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { toast } from "sonner";
import { ProductImageSortable } from "../types";
type UseDragAndDropProps = {
productImages: ProductImageSortable[];
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
data: any[];
};
type UseDragAndDropReturn = {
activeId: string | null;
activeImage: ProductImageSortable | null;
activeDroppableId: string | null;
customCollisionDetection: CollisionDetection;
findContainer: (id: string) => string | null;
getProductImages: (productIndex: number) => ProductImageSortable[];
getProductContainerClasses: (index: number) => string;
handleDragStart: (event: DragStartEvent) => void;
handleDragOver: (event: DragMoveEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
};
export const useDragAndDrop = ({
productImages,
setProductImages,
data
}: UseDragAndDropProps): UseDragAndDropReturn => {
const [activeId, setActiveId] = useState<string | null>(null);
const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null);
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(null);
// Custom collision detection algorithm that prioritizes product containers
const customCollisionDetection: CollisionDetection = (args) => {
// Use the built-in pointerWithin algorithm first for better performance
const pointerCollisions = pointerWithin(args);
if (pointerCollisions.length > 0) {
return pointerCollisions;
}
// Fall back to rectIntersection if no pointer collisions
return rectIntersection(args);
};
// Function to find container (productIndex) an image belongs to
const findContainer = (id: string) => {
const image = productImages.find(img => img.id === id);
return image ? image.productIndex.toString() : null;
};
// Function to get images for a specific product
const getProductImages = (productIndex: number) => {
return productImages.filter(img => img.productIndex === productIndex);
};
// Handle drag start to set active image and prevent default behavior
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const activeImageItem = productImages.find(img => img.id === active.id);
setActiveId(active.id.toString());
if (activeImageItem) {
setActiveImage(activeImageItem);
}
};
// Handle drag over to track which product container is being hovered
const handleDragOver = (event: DragMoveEvent) => {
const { over } = event;
if (!over) {
setActiveDroppableId(null);
return;
}
let overContainer = null;
// Check if we're over a product container directly
if (typeof over.id === 'string' && over.id.toString().startsWith('product-')) {
overContainer = over.id.toString();
setActiveDroppableId(overContainer);
}
// Otherwise check if we're over another image
else {
const overImage = productImages.find(img => img.id === over.id);
if (overImage) {
overContainer = `product-${overImage.productIndex}`;
setActiveDroppableId(overContainer);
} else {
setActiveDroppableId(null);
}
}
};
// Update handleDragEnd to work with the updated product data structure
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
// Reset active droppable
setActiveDroppableId(null);
if (!over) {
setActiveId(null);
setActiveImage(null);
return;
}
const activeId = active.id;
const overId = over.id;
// Find the containers (product indices) for the active element
const activeContainer = findContainer(activeId.toString());
let overContainer = null;
// Check if overId is a product container directly
if (typeof overId === 'string' && overId.toString().startsWith('product-')) {
overContainer = overId.toString().split('-')[1];
}
// Otherwise check if it's an image, so find its container
else {
overContainer = findContainer(overId.toString());
}
// If we couldn't determine active container, do nothing
if (!activeContainer) {
setActiveId(null);
setActiveImage(null);
return;
}
// If we couldn't determine the over container, do nothing
if (!overContainer) {
setActiveId(null);
setActiveImage(null);
return;
}
// Convert containers to numbers
const sourceProductIndex = parseInt(activeContainer);
const targetProductIndex = parseInt(overContainer);
// Find the active image
const activeImage = productImages.find(img => img.id === activeId);
if (!activeImage) {
setActiveId(null);
setActiveImage(null);
return;
}
// IMPORTANT: If source and target are different products, ALWAYS prioritize moving over reordering
if (sourceProductIndex !== targetProductIndex) {
// Create a copy of the image with the new product index
const newImage: ProductImageSortable = {
...activeImage,
productIndex: targetProductIndex,
// Generate a new ID for the image in its new location
id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
};
// Remove the image from the source product and add to target product
setProductImages(items => {
// Remove the image from its current product
const filteredItems = items.filter(item => item.id !== activeId);
// Add the image to the target product
filteredItems.push(newImage);
// Show notification
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
return filteredItems;
});
}
// Source and target are the same product - this is a reordering operation
else {
// Only attempt reordering if we have at least 2 images in this container
const productImages = getProductImages(sourceProductIndex);
if (productImages.length >= 2) {
// Handle reordering regardless of whether we're over a container or another image
setProductImages(items => {
// Filter to get only the images for this product
const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex);
// If dropping onto the container itself, put at the end
if (overId.toString().startsWith('product-')) {
// Find active index
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
if (activeIndex === -1) {
return items; // No change needed
}
// Move active item to end (remove and push to end)
const newFilteredItems = [...productFilteredItems];
const [movedItem] = newFilteredItems.splice(activeIndex, 1);
newFilteredItems.push(movedItem);
// Create a new full list replacing the items for this product with the reordered ones
const newItems = items.filter(item => item.productIndex !== sourceProductIndex);
newItems.push(...newFilteredItems);
return newItems;
}
// Find indices within the filtered list
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
const overIndex = productFilteredItems.findIndex(item => item.id === overId);
// If one of the indices is not found or they're the same, do nothing
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) {
return items;
}
// Reorder the filtered items
const newFilteredItems = arrayMove(productFilteredItems, activeIndex, overIndex);
// Create a new full list replacing the items for this product with the reordered ones
const newItems = items.filter(item => item.productIndex !== sourceProductIndex);
newItems.push(...newFilteredItems);
return newItems;
});
}
}
setActiveId(null);
setActiveImage(null);
};
// Monitor drag events to prevent browser behaviors
useEffect(() => {
// Add a global event listener to prevent browser's native drag behavior
const preventDefaultDragImage = (event: DragEvent) => {
if (activeId) {
event.preventDefault();
}
};
document.addEventListener('dragstart', preventDefaultDragImage);
return () => {
document.removeEventListener('dragstart', preventDefaultDragImage);
};
}, [activeId]);
// Add product IDs to the valid droppable elements
useEffect(() => {
// Add data-droppable attributes to make product containers easier to identify
data.forEach((_, index) => {
const container = document.getElementById(`product-${index}`);
if (container) {
container.setAttribute('data-droppable', 'true');
container.setAttribute('aria-dropeffect', 'move');
// Check if the container has images
const hasImages = getProductImages(index).length > 0;
// Set data-empty attribute for tracking purposes
container.setAttribute('data-empty', hasImages ? 'false' : 'true');
// Ensure the container has sufficient size to be a drop target
if (container.offsetHeight < 100) {
container.style.minHeight = '100px';
}
}
});
}, [data, productImages]); // Add productImages as a dependency to re-run when images change
// Effect to register browser-level drag events on product containers
useEffect(() => {
// For each product container
data.forEach((_, index) => {
const container = document.getElementById(`product-${index}`);
if (container) {
// Define handlers for native browser drag events
const handleNativeDragOver = (e: DragEvent) => {
e.preventDefault();
setActiveDroppableId(`product-${index}`);
};
const handleNativeDragLeave = () => {
if (activeDroppableId === `product-${index}`) {
setActiveDroppableId(null);
}
};
// Add these handlers
container.addEventListener('dragover', handleNativeDragOver);
container.addEventListener('dragleave', handleNativeDragLeave);
// Return cleanup function
return () => {
container.removeEventListener('dragover', handleNativeDragOver);
container.removeEventListener('dragleave', handleNativeDragLeave);
};
}
});
}, [data, productImages, activeDroppableId]); // Re-run when data or productImages change
// Function to add more visual indication when dragging
const getProductContainerClasses = (index: number) => {
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
const isActiveDropTarget = activeDroppableId === `product-${index}`;
return [
"flex-1 min-h-[6rem] rounded-md p-2 transition-all",
// Only show borders during active drag operations
isValidDropTarget && isActiveDropTarget
? "border-2 border-dashed border-primary bg-primary/10"
: isValidDropTarget
? "border border-dashed border-muted-foreground/30"
: ""
].filter(Boolean).join(" ");
};
return {
activeId,
activeImage,
activeDroppableId,
customCollisionDetection,
findContainer,
getProductImages,
getProductContainerClasses,
handleDragStart,
handleDragOver,
handleDragEnd
};
};

View File

@@ -0,0 +1,195 @@
import { toast } from "sonner";
import config from "@/config";
import { Product, ProductImageSortable } from "../types";
interface UseProductImageOperationsProps {
data: Product[];
productImages: ProductImageSortable[];
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
}
export const useProductImageOperations = ({
data,
productImages,
setProductImages,
}: UseProductImageOperationsProps) => {
// Function to remove an image URL from a product
const removeImageFromProduct = (productIndex: number, imageUrl: string) => {
// Create a copy of the data
const newData = [...data];
// Get the current product
const product = newData[productIndex];
// We need to update product_images array directly instead of the image_url field
if (!product.product_images) {
product.product_images = [];
} else if (typeof product.product_images === 'string') {
// Handle case where it might be a comma-separated string
product.product_images = product.product_images.split(',').filter(Boolean);
}
// Filter out the image URL we're removing
if (Array.isArray(product.product_images)) {
product.product_images = product.product_images.filter((url: string) => url && url !== imageUrl);
}
return newData;
};
// Function to add an image URL to a product
const addImageToProduct = (productIndex: number, imageUrl: string) => {
// Create a copy of the data
const newData = [...data];
// Get the current product
const product = newData[productIndex];
// Initialize product_images array if it doesn't exist
if (!product.product_images) {
product.product_images = [];
} else if (typeof product.product_images === 'string') {
// Handle case where it might be a comma-separated string
product.product_images = product.product_images.split(',').filter(Boolean);
}
// Ensure it's an array
if (!Array.isArray(product.product_images)) {
product.product_images = [product.product_images].filter(Boolean);
}
// Only add if the URL doesn't already exist
if (!product.product_images.includes(imageUrl)) {
product.product_images.push(imageUrl);
}
return newData;
};
// Function to handle image upload - update product data
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
if (!files || files.length === 0) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Add placeholder for this image
const newImage: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
productIndex,
imageUrl: '',
loading: true,
fileName: file.name,
// Add required schema fields for ProductImageSortable
pid: data[productIndex].id || 0,
iid: 0,
type: 0,
order: 0,
width: 0,
height: 0,
hidden: 0
};
setProductImages(prev => [...prev, newImage]);
// Create form data for upload
const formData = new FormData();
formData.append('image', file);
formData.append('productIndex', productIndex.toString());
formData.append('upc', data[productIndex].upc || '');
formData.append('supplier_no', data[productIndex].supplier_no || '');
try {
// Upload the image
const response = await fetch(`${config.apiUrl}/import/upload-image`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload image');
}
const result = await response.json();
// Update the image URL in our state
setProductImages(prev =>
prev.map(img =>
(img.loading && img.productIndex === productIndex && img.fileName === file.name)
? { ...img, imageUrl: result.imageUrl, loading: false }
: img
)
);
// Update the product data with the new image URL
addImageToProduct(productIndex, result.imageUrl);
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
} catch (error) {
console.error('Upload error:', error);
// Remove the failed image from our state
setProductImages(prev =>
prev.filter(img =>
!(img.loading && img.productIndex === productIndex && img.fileName === file.name)
)
);
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
};
// Function to remove an image - update to work with product_images
const removeImage = async (imageIndex: number) => {
const image = productImages[imageIndex];
if (!image) return;
try {
// Check if this is an external URL-based image or an uploaded image
const isExternalUrl = image.imageUrl.startsWith('http') &&
!image.imageUrl.includes(config.apiUrl.replace(/^https?:\/\//, ''));
// Only call the API to delete the file if it's an uploaded image
if (!isExternalUrl) {
// Extract the filename from the URL
const urlParts = image.imageUrl.split('/');
const filename = urlParts[urlParts.length - 1];
// Call API to delete the image
const response = await fetch(`${config.apiUrl}/import/delete-image`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
imageUrl: image.imageUrl,
filename
}),
});
if (!response.ok) {
throw new Error('Failed to delete image');
}
}
// Remove the image from our state
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
// Remove the image URL from the product data
removeImageFromProduct(image.productIndex, image.imageUrl);
toast.success('Image removed successfully');
} catch (error) {
console.error('Delete error:', error);
toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return {
removeImageFromProduct,
addImageToProduct,
handleImageUpload,
removeImage,
};
};

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { ProductImageSortable, Product } from "../types";
export const useProductImagesInit = (data: Product[]) => {
// Initialize product images from data
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
// Convert existing product_images to ProductImageSortable objects
const initialImages: ProductImageSortable[] = [];
data.forEach((product: Product, productIndex: number) => {
if (product.product_images) {
let images: any[] = [];
// Handle different formats of product_images
if (typeof product.product_images === 'string') {
try {
// Try to parse as JSON
images = JSON.parse(product.product_images);
} catch (e) {
// If not JSON, split by comma if it's a string
images = product.product_images.split(',').filter(Boolean).map((url: string) => ({
imageUrl: url.trim(),
pid: product.id || 0,
iid: 0,
type: 0,
order: 255,
width: 0,
height: 0,
hidden: 0
}));
}
} else if (Array.isArray(product.product_images)) {
// Use the array directly
images = product.product_images;
} else if (product.product_images) {
// Handle case where it might be a single value
images = [product.product_images];
}
// Create ProductImageSortable objects for each image
images.forEach((img, i) => {
// Handle both URL strings and structured image objects
const imageUrl = typeof img === 'string' ? img : img.imageUrl;
if (imageUrl && imageUrl.trim()) {
initialImages.push({
id: `image-${productIndex}-initial-${i}`,
productIndex,
imageUrl: imageUrl.trim(),
loading: false,
fileName: `Image ${i + 1}`,
// Add schema fields
pid: product.id || 0,
iid: typeof img === 'object' && img.iid ? img.iid : i,
type: typeof img === 'object' && img.type !== undefined ? img.type : 0,
order: typeof img === 'object' && img.order !== undefined ? img.order : i,
width: typeof img === 'object' && img.width ? img.width : 0,
height: typeof img === 'object' && img.height ? img.height : 0,
hidden: typeof img === 'object' && img.hidden !== undefined ? img.hidden : 0
});
}
});
}
});
return initialImages;
});
// Function to ensure URLs are properly formatted with absolute paths
const getFullImageUrl = (url: string): string => {
// If the URL is already absolute (starts with http:// or https://) return it as is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://inventory.acot.site';
// Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`;
};
return {
productImages,
setProductImages,
getFullImageUrl
};
};

View File

@@ -0,0 +1,98 @@
import { useState } from "react";
import { toast } from "sonner";
import { Product, ProductImageSortable } from "../types";
type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
interface UseUrlImageUploadProps {
data: Product[];
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
addImageToProduct: AddImageToProductFn;
}
export const useUrlImageUpload = ({
data,
setProductImages,
addImageToProduct
}: UseUrlImageUploadProps) => {
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
// Handle adding an image from a URL - simplified to skip server
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
if (!url || !url.trim()) {
toast.error("Please enter a valid URL");
return;
}
try {
// Set processing state
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
// Validate URL format
let validatedUrl = url.trim();
// Add protocol if missing
if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) {
validatedUrl = `https://${validatedUrl}`;
}
// Basic URL validation
try {
new URL(validatedUrl);
} catch (e) {
toast.error("Invalid URL format. Please enter a valid URL");
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
return;
}
// Create a unique ID for this image
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Create the new image object with the URL
const newImage: ProductImageSortable = {
id: imageId,
productIndex,
imageUrl: validatedUrl,
loading: false, // We're not loading from server, so it's ready immediately
fileName: "From URL",
// Add required schema fields
pid: data[productIndex].id || 0,
iid: 0,
type: 0,
order: 0,
width: 0,
height: 0,
hidden: 0
};
// Add the image directly to the product images list
setProductImages(prev => [...prev, newImage]);
// Update the product data with the new image URL
addImageToProduct(productIndex, validatedUrl);
// Clear the URL input field on success
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
} catch (error) {
console.error('Add image from URL error:', error);
toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
}
};
// Update the URL input value
const updateUrlInput = (productIndex: number, value: string) => {
setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
};
return {
urlInputs,
processingUrls,
handleAddImageFromUrl,
updateUrlInput
};
};

View File

@@ -0,0 +1,35 @@
export type ProductImage = {
productIndex: number;
imageUrl: string;
loading: boolean;
fileName: string;
// Schema fields
pid: number;
iid: number;
type: number;
order: number;
width: number;
height: number;
hidden: number;
}
export type UnassignedImage = {
file: File;
previewUrl: string;
}
// Product ID type to handle the sortable state
export type ProductImageSortable = ProductImage & {
id: string;
};
// Shared Product interface
export interface Product {
id?: number;
name?: string;
upc?: string;
supplier_no?: string;
sku?: string;
model?: string;
product_images?: string | string[];
}

View File

@@ -143,7 +143,10 @@ const ColumnActions = memo(({
className="h-7 px-2"
>
{isExpanded ?
"Hide values" :
<>
<EyeOffIcon className="h-3.5 w-3.5 mr-1" />
Hide values
</> :
<>
<LinkIcon className="h-3.5 w-3.5 mr-1" />
Map values
@@ -535,7 +538,6 @@ const SubLineSelector = React.memo(({
// Add this new component before the MatchColumnsStep component
const FieldSelector = React.memo(({
column,
isUnmapped = false,
fieldCategories,
allFields,
onChange,
@@ -631,27 +633,21 @@ export const MatchColumnsStep = React.memo(<T extends string>({
)
const [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {})
const [showAllColumns, setShowAllColumns] = useState(true)
const [expandedValueMappings, setExpandedValueMappings] = useState<number[]>([])
const [expandedValues, setExpandedValues] = useState<number[]>([])
const [userCollapsedColumns, setUserCollapsedColumns] = useState<number[]>([])
// Use debounce for expensive operations
const [expandedValues, setExpandedValues] = useState<number[]>([]);
useEffect(() => {
const timeoutId = setTimeout(() => {
setExpandedValueMappings(expandedValues);
}, 50);
return () => clearTimeout(timeoutId);
}, [expandedValues]);
// Toggle with immediate visual feedback but debounced actual state change
// Toggle with immediate visual feedback
const toggleValueMappingOptimized = useCallback((columnIndex: number) => {
setExpandedValues(prev =>
prev.includes(columnIndex)
? prev.filter(idx => idx !== columnIndex)
: [...prev, columnIndex]
);
}, []);
if (expandedValues.includes(columnIndex)) {
// User is collapsing this column - add to userCollapsedColumns
setUserCollapsedColumns(prev => [...prev, columnIndex]);
setExpandedValues(prev => prev.filter(idx => idx !== columnIndex));
} else {
// User is expanding this column - remove from userCollapsedColumns
setUserCollapsedColumns(prev => prev.filter(idx => idx !== columnIndex));
setExpandedValues(prev => [...prev, columnIndex]);
}
}, [expandedValues]);
// Check if column is expandable (has value mappings)
const isExpandable = useCallback((column: Column<T>) => {
@@ -794,19 +790,11 @@ export const MatchColumnsStep = React.memo(<T extends string>({
const fieldOptions = fieldOptionsData || { suppliers: [], companies: [] };
// Create a stable identity for these queries to avoid re-renders
const stableFieldOptions = useMemo(() => fieldOptionsData || { suppliers: [], companies: [] }, [fieldOptionsData]);
const stableProductLines = useMemo(() => productLines || [], [productLines]);
const stableSublines = useMemo(() => sublines || [], [sublines]);
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
// Type guard for suppliers and companies
const hasSuppliers = (options: any): options is { suppliers: any[] } =>
options && Array.isArray(options.suppliers);
const hasCompanies = (options: any): options is { companies: any[] } =>
options && Array.isArray(options.companies);
// Check if a field is covered by global selections
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
return (key === 'supplier' && !!globalSelections.supplier) ||
@@ -976,20 +964,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
}, [availableFields]);
// Group all fields by category (for editing mapped columns)
const allFieldCategories = useMemo(() => {
return [
{ name: "Basic Info", fields: getFieldsByKeyPrefix('basic', allFields) },
{ name: "Product", fields: getFieldsByKeyPrefix('product', allFields) },
{ name: "Inventory", fields: getFieldsByKeyPrefix('inventory', allFields) },
{ name: "Pricing", fields: getFieldsByKeyPrefix('pricing', allFields) },
{ name: "Other", fields: allFields.filter(f =>
!f.key.startsWith('basic') &&
!f.key.startsWith('product') &&
!f.key.startsWith('inventory') &&
!f.key.startsWith('pricing')
) }
].filter(category => category.fields.length > 0);
}, [allFields, getFieldsByKeyPrefix]);
// Group available fields by category (for unmapped columns)
const availableFieldCategories = useMemo(() => {
@@ -1214,7 +1188,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
})) as unknown as Fields<T>
const unmatched = findUnmatchedRequiredFields(typedFields, columns);
console.log("Unmatched required fields:", unmatched);
return unmatched;
}, [fields, columns])
@@ -1226,7 +1199,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
// Type assertion to handle the DeepReadonly<T> vs string type mismatch
return !unmatchedRequiredFields.includes(key as any);
});
console.log("Matched required fields:", matched);
return matched;
}, [requiredFields, unmatchedRequiredFields]);
@@ -1288,15 +1260,18 @@ export const MatchColumnsStep = React.memo(<T extends string>({
// Automatically expand columns with unmapped values - use layoutEffect to avoid flashing
useLayoutEffect(() => {
// Only add new columns that need to be expanded without removing existing ones
const columnsToExpand = columnsWithUnmappedValues
.filter(column => !expandedValueMappings.includes(column.index))
.map(column => column.index);
// Track the current unmapped column indexes
const currentUnmappedIndexes = columnsWithUnmappedValues.map(col => col.index);
if (columnsToExpand.length > 0) {
setExpandedValueMappings(prev => [...prev, ...columnsToExpand]);
// Find columns that are newly unmapped (weren't in expandedValues or userCollapsedColumns)
const newlyUnmappedColumns = currentUnmappedIndexes.filter(index =>
!expandedValues.includes(index) && !userCollapsedColumns.includes(index)
);
if (newlyUnmappedColumns.length > 0) {
setExpandedValues(prev => [...prev, ...newlyUnmappedColumns]);
}
}, [columnsWithUnmappedValues, expandedValueMappings]);
}, [columnsWithUnmappedValues, expandedValues, userCollapsedColumns]);
// Create a stable mapping of column index to change handlers
const columnChangeHandlers = useMemo(() => {
@@ -1371,7 +1346,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<Table>
<TableHeader className="sticky top-0 bg-muted z-10">
<TableRow>
<TableHead className="w-1/4">Imported Spreadsheet Column</TableHead>
<TableHead className="w-1/4">Imported Column</TableHead>
<TableHead className="w-15 text-center">Sample Data</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead>Map To Field</TableHead>
@@ -1381,7 +1356,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<TableBody>
{/* Always show columns with unmapped values */}
{columnsWithUnmappedValues.map((column) => {
const isExpanded = expandedValueMappings.includes(column.index);
const isExpanded = expandedValues.includes(column.index);
return (
<React.Fragment key={`unmapped-values-${column.index}`}>
@@ -1401,7 +1376,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
column={column}
onIgnore={onIgnore}
toggleValueMapping={toggleValueMappingOptimized}
isExpanded={expandedValueMappings.includes(column.index)}
isExpanded={isExpanded}
canExpandValues={isExpandable(column)}
/>
</TableCell>
@@ -1421,7 +1396,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
{/* Always show unmapped columns */}
{unmatchedColumns.map((column) => (
<TableRow key={`unmatched-${column.index}`}>
<TableRow key={`unmatched-${column.index}`} className="bg-amber-50 hover:bg-amber-100">
<TableCell className="font-medium">{column.header}</TableCell>
<TableCell className="text-center">
{renderSamplePreview(column.index)}
@@ -1437,7 +1412,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
column={column}
onIgnore={onIgnore}
toggleValueMapping={toggleValueMappingOptimized}
isExpanded={expandedValueMappings.includes(column.index)}
isExpanded={expandedValues.includes(column.index)}
canExpandValues={isExpandable(column)}
/>
</TableCell>
@@ -1446,12 +1421,12 @@ export const MatchColumnsStep = React.memo(<T extends string>({
{/* Show matched columns if showAllColumns is true */}
{showAllColumns && matchedColumns.map((column) => {
const isExpanded = expandedValueMappings.includes(column.index);
const isExpanded = expandedValues.includes(column.index);
const canExpandValues = isExpandable(column);
return (
<React.Fragment key={`matched-${column.index}`}>
<TableRow className="group">
<TableRow className="group bg-green-50 hover:bg-green-100">
<TableCell className="font-medium">{column.header}</TableCell>
<TableCell className="text-center">
{renderSamplePreview(column.index)}
@@ -1467,7 +1442,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
column={column}
onIgnore={onIgnore}
toggleValueMapping={toggleValueMappingOptimized}
isExpanded={expandedValueMappings.includes(column.index)}
isExpanded={isExpanded}
canExpandValues={canExpandValues}
/>
</TableCell>
@@ -1487,7 +1462,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
{/* Show ignored columns if showAllColumns is true */}
{showAllColumns && ignoredColumns.map((column) => (
<TableRow key={`ignored-${column.index}`} className="text-muted-foreground">
<TableRow key={`ignored-${column.index}`} className="text-muted-foreground bg-red-50 hover:bg-red-100">
<TableCell className="font-medium">{column.header}</TableCell>
<TableCell className="text-center">
{renderSamplePreview(column.index)}
@@ -1496,7 +1471,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<ArrowRightIcon className="h-4 w-4 mx-auto" />
</TableCell>
<TableCell>
<Badge variant="outline">Ignored</Badge>
<Badge variant="outline" className="bg-card">Ignored</Badge>
</TableCell>
<TableCell className="text-right">
<Button
@@ -1537,7 +1512,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
matchedColumns,
ignoredColumns,
showAllColumns,
expandedValueMappings,
expandedValues,
renderSamplePreview,
renderFieldSelector,
renderValueMappings,
@@ -1555,7 +1530,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-full">
{/* Left panel - Global selections & Required fields */}
<div className="lg:col-span-1 space-y-4">
<div>
<div className="bg-muted border rounded-md p-4 shadow-md">
<h3 className="text-lg font-medium mb-0">Global Settings</h3>
<p className="text-sm text-muted-foreground mb-2">
These values will be applied to all imported items
@@ -1614,8 +1589,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
</div>
</div>
<Separator className="my-8" />
{/* Required Fields Section - Updated to show source column */}
<div>
<div className="flex items-center gap-2 mb-4">
@@ -1665,9 +1638,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium">Map Spreadsheet Columns</h3>
<Badge variant={unmatchedRequiredFields.length > 0 ? "destructive" : "outline"}>
{unmatchedRequiredFields.length} required missing
</Badge>
{columnsWithUnmappedValues.length > 0 && (
<Badge variant="outline" className="bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-300">
{columnsWithUnmappedValues.length} with unmapped values

View File

@@ -0,0 +1,31 @@
import { motion } from "framer-motion"
import { CgCheck } from "react-icons/cg"
const animationConfig = {
transition: {
duration: 0.1,
},
exit: { scale: 0.5, opacity: 0 },
initial: { scale: 0.5, opacity: 0 },
animate: { scale: 1, opacity: 1 },
}
type MatchIconProps = {
isChecked: boolean
}
export const MatchIcon = ({ isChecked }: MatchIconProps) => {
return (
<div
className="flex items-center justify-center rounded-full border-2 border-yellow-500 bg-background text-background transition-colors duration-100 min-w-6 min-h-6 w-6 h-6 ml-3.5 mr-3 data-[highlighted=true]:bg-green-500 data-[highlighted=true]:border-green-500"
data-highlighted={isChecked}
data-testid="column-checkmark"
>
{isChecked && (
<motion.div {...animationConfig}>
<CgCheck size="24px" />
</motion.div>
)}
</div>
)
}

View File

@@ -15,30 +15,30 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
(key) => key.toLowerCase() === curr?.toLowerCase(),
)!
const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey]
acc[column.value] = booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)
acc[column.value] = (booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)) as Data<T>[T]
} else {
acc[column.value] = normalizeCheckboxValue(curr)
acc[column.value] = normalizeCheckboxValue(curr) as Data<T>[T]
}
return acc
}
case ColumnType.matched: {
acc[column.value] = curr === "" ? undefined : curr
acc[column.value] = (curr === "" ? undefined : curr) as Data<T>[T]
return acc
}
case ColumnType.matchedMultiInput: {
const field = fields.find((field) => field.key === column.value)!
if (curr) {
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean)
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean) as Data<T>[T]
} else {
acc[column.value] = undefined
acc[column.value] = undefined as Data<T>[T]
}
return acc
}
case ColumnType.matchedSelect:
case ColumnType.matchedSelectOptions: {
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
acc[column.value] = matchedOption?.value || undefined
const matchedOption = column.matchedOptions.find(({ entry }) => entry === curr)
acc[column.value] = (matchedOption?.value || undefined) as Data<T>[T]
return acc
}
case ColumnType.matchedMultiSelect: {
@@ -50,9 +50,9 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
return matchedOption?.value
}).filter(Boolean) as string[]
acc[column.value] = values.length ? values : undefined
acc[column.value] = (values.length ? values : undefined) as Data<T>[T]
} else {
acc[column.value] = undefined
acc[column.value] = undefined as Data<T>[T]
}
return acc
}

View File

@@ -159,7 +159,7 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
</p>
</div>
<Button
variant="default"
variant="secondary"
size="sm"
onClick={discardEmptyAndDuplicateRows}
>

View File

@@ -4,8 +4,6 @@ import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
@@ -43,23 +41,7 @@ export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props
<div className="h-[calc(100vh-23rem)] overflow-auto">
<Table className="relative w-full" style={{ tableLayout: 'fixed' }}>
<TableHeader>
<TableRow className="grid" style={{ gridTemplateColumns }}>
<TableHead className="sticky top-0 z-20 bg-background overflow-hidden">
</TableHead>
{columns.map((column) => (
<TableHead
key={column.key}
className="sticky top-0 z-20 bg-background overflow-hidden"
>
<div className="truncate">
{column.name}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<RadioGroup
value={selectedRowIndex?.toString()}
@@ -70,7 +52,7 @@ export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props
key={rowIndex}
className={cn(
"grid",
selectedRowIndex === rowIndex && "bg-muted",
selectedRowIndex === rowIndex && "bg-muted font-bold",
"group hover:bg-muted/50"
)}
style={{ gridTemplateColumns }}

View File

@@ -1,8 +1,7 @@
import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import type { RawData } from "../../../types"
import { cn } from "@/lib/utils"
const SELECT_COLUMN_KEY = "select-row"
@@ -58,7 +57,7 @@ export const generateSelectionColumns = (data: RawData[]) => {
key: index.toString(),
name: `Column ${index + 1}`,
width: 150,
formatter: ({ row }) => (
formatter: ({ row }: { row: RawData }) => (
<div className="p-2 overflow-hidden text-ellipsis whitespace-nowrap">
{row[index]}
</div>

View File

@@ -1,17 +1,32 @@
import { StepState, StepType, UploadFlow } from "./UploadFlow"
import { useRsi } from "../hooks/useRsi"
import { useRef, useState } from "react"
import { useRef, useState, useEffect } from "react"
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
import { CgCheck } from "react-icons/cg"
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
export const Steps = () => {
const { initialStepState, translations, isNavigationEnabled } = useRsi()
const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi()
const initialStep = stepTypeToStepIndex(initialStepState?.type)
const [activeStep, setActiveStep] = useState(initialStep)
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
const history = useRef<StepState[]>([])
const prevIsOpen = useRef(isOpen)
// Reset state when dialog is reopened
useEffect(() => {
// Check if dialog was closed and is now open again
if (isOpen && !prevIsOpen.current) {
// Reset to initial state
setActiveStep(initialStep)
setState(initialStepState || { type: StepType.upload })
history.current = []
}
// Update previous isOpen value
prevIsOpen.current = isOpen
}, [isOpen, initialStep, initialStepState])
const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex)

View File

@@ -6,13 +6,13 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStepNew } from "./ValidationStepNew"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData } from "../types"
import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStepNew/utils/dataMutations"
export enum StepType {
upload = "upload",
@@ -185,12 +185,10 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map(row => {
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
const newRow = { ...row };
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections.company) newRow.company = globalSelections.company;
if (globalSelections.line) newRow.line = globalSelections.line;
if (globalSelections.subline) newRow.subline = globalSelections.subline;
return newRow;
})
: dataWithMeta;
@@ -213,20 +211,24 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
return (
<ValidationStepNew
initialData={state.data}
file={uploadedFile!}
file={uploadedFile || new File([], "empty.xlsx")}
onBack={() => {
if (onBack) {
// When going back, preserve the global selections
setPersistedGlobalSelections(state.globalSelections)
onBack()
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
onNext({
type: StepType.upload
});
} else if (onBack) {
// Use the provided onBack function
onBack();
}
}}
onNext={(validatedData) => {
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile!,
file: uploadedFile || new File([], "empty.xlsx"),
globalSelections: state.globalSelections
});
}}
@@ -238,21 +240,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
<ImageUploadStep
data={state.data}
file={state.file}
onBack={() => {
if (onBack) {
onNext({
type: StepType.validateData,
data: state.data,
globalSelections: state.globalSelections
})
}
}}
onBack={onBack}
onSubmit={(data, file) => {
// Create a Result object from the array data
const result = {
validData: data,
invalidData: [],
all: data
validData: data as Data<string>[],
invalidData: [] as Data<string>[],
all: data as Data<string>[]
};
onSubmit(result, file);
}}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Loader2, CheckIcon } from 'lucide-react';
import { Code } from '@/components/ui/code';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AiValidationDetails, AiValidationProgress, CurrentPrompt, ProductChangeDetail } from '../hooks/useAiValidation';
import { AiValidationDetails, AiValidationProgress, CurrentPrompt } from '../hooks/useAiValidation';
interface AiValidationDialogsProps {
aiValidationProgress: AiValidationProgress;
@@ -79,7 +79,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
<div
className="h-full bg-primary transition-all duration-500"
style={{
width: `${aiValidationProgress.progressPercent ?? Math.floor((aiValidationProgress.step / 5) * 100)}%`,
width: `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`,
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
}}
/>

View File

@@ -0,0 +1,42 @@
// Define MultiSelectCell component to fix the import issue
type MultiSelectCellProps = {
field: string;
value: any;
onChange: (value: any) => void;
options: any[];
hasErrors: boolean;
className?: string;
};
// Using _ to indicate intentionally unused parameters
const MultiSelectCell = (_: MultiSelectCellProps) => {
// This is a placeholder implementation
return null;
};
const BaseCellContent = ({ fieldType, field, value, onChange, options, hasErrors, className }: {
fieldType: string;
field: string;
value: any;
onChange: (value: any) => void;
options: any[];
hasErrors: boolean;
className?: string;
}) => {
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return (
<MultiSelectCell
field={field}
value={value}
onChange={onChange}
options={options}
hasErrors={hasErrors}
className={className}
/>
);
}
return null;
};
export default BaseCellContent;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Template } from '../hooks/useValidationState'
import { Template } from '../hooks/validationTypes'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -50,7 +50,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const [searchTerm, setSearchTerm] = useState("");
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
@@ -59,6 +58,14 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
}
}, [defaultBrand]);
// Force a re-render when templates change from empty to non-empty
useEffect(() => {
if (templates && templates.length > 0) {
// Force a re-render by updating state
setSearchTerm("");
}
}, [templates]);
// Handle wheel events for scrolling
const handleWheel = (e: React.WheelEvent) => {
const scrollArea = e.currentTarget;
@@ -130,7 +137,11 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
brandFiltered = templates.filter(t => t?.company === selectedBrand);
// Check if the selected brand has any templates
const brandTemplates = templates.filter(t => t?.company === selectedBrand);
// If the selected brand has templates, use them; otherwise, show all templates
brandFiltered = brandTemplates.length > 0 ? brandTemplates : templates;
}
// Then filter by search term if provided
@@ -158,11 +169,26 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
return getTemplateDisplayText(value);
const template = templates.find(t => t.id.toString() === value);
if (!template) return placeholder;
// Get the original display text
const originalText = getTemplateDisplayText(value);
// Check if it has the expected format "Brand - Product Type"
if (originalText.includes(' - ')) {
const [brand, productType] = originalText.split(' - ', 2);
// Reverse the order to "Product Type - Brand"
return `${productType} - ${brand}`;
}
// If it doesn't match the expected format, return the original text
return originalText;
} catch (err) {
console.error('Error getting display text:', err);
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value]);
}, [getTemplateDisplayText, placeholder, value, templates]);
// Safe render function for CommandItem
const renderCommandItem = useCallback((template: Template) => {
@@ -181,7 +207,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
setOpen(false);
setSearchTerm("");
} catch (err) {
setError("Error selecting template");
console.error('Error selecting template:', err);
}
}}
className="flex items-center justify-between"
@@ -191,6 +217,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
</CommandItem>
);
} catch (err) {
console.error('Error rendering template item:', err);
return null;
}
}, [onValueChange, value, getTemplateDisplayText]);
@@ -202,10 +229,10 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", triggerClassName)}
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
>
{getDisplayText()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-[300px] p-0", className)}>
@@ -256,9 +283,23 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
selectedBrand ? (
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
</CommandGroup>
groupedTemplates[selectedBrand]?.length > 0 ? (
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
</CommandGroup>
) : (
// If selected brand has no templates, show all brands
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find(b => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find(b => b.id === companyId);

View File

@@ -0,0 +1,158 @@
import React, { useMemo } from 'react'
import ValidationTable from './ValidationTable'
import { RowSelectionState } from '@tanstack/react-table'
import { Fields } from '../../../types'
import { Template } from '../hooks/validationTypes'
interface UpcValidationTableAdapterProps<T extends string> {
data: any[]
fields: Fields<string>
validationErrors: Map<number, Record<string, any[]>>
rowSelection: RowSelectionState
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
filters: any
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
validatingCells: Set<string>
isLoadingTemplates: boolean
rowProductLines: Record<string, any[]>
rowSublines: Record<string, any[]>
isLoadingLines: Record<string, boolean>
isLoadingSublines: Record<string, boolean>
upcValidation: {
validatingRows: Set<number>
getItemNumber: (rowIndex: number) => string | undefined
}
itemNumbers?: Map<number, string>
}
/**
* UpcValidationTableAdapter component - connects UPC validation data to ValidationTable
*
* This component adapts UPC validation data and functionality to work with the core ValidationTable,
* transforming item numbers and validation states into a format the table component can render.
*/
function UpcValidationTableAdapter<T extends string>({
data,
fields,
validationErrors,
rowSelection,
setRowSelection,
updateRow,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
isValidatingUpc,
validatingUpcRows,
copyDown,
validatingCells: externalValidatingCells,
isLoadingTemplates,
rowProductLines,
rowSublines,
isLoadingLines,
isLoadingSublines,
upcValidation,
itemNumbers
}: UpcValidationTableAdapterProps<T>) {
// Prepare the validation table with UPC data
// Create combined validatingCells set from validating rows and external cells
const combinedValidatingCells = useMemo(() => {
const combined = new Set<string>();
// Add UPC validation cells
upcValidation.validatingRows.forEach(rowIndex => {
// Only mark the item_number cells as validating, NOT the UPC or supplier
combined.add(`${rowIndex}-item_number`);
});
// Add any other validating cells from state
externalValidatingCells.forEach(cellKey => {
combined.add(cellKey);
});
return combined;
}, [upcValidation.validatingRows, externalValidatingCells]);
// Create a consolidated item numbers map from all sources
const consolidatedItemNumbers = useMemo(() => {
const result = new Map<number, string>();
// First add from itemNumbers directly - this is the source of truth for template applications
if (itemNumbers) {
// Log all numbers for debugging
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
itemNumbers.forEach((itemNumber, rowIndex) => {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
result.set(rowIndex, itemNumber);
});
}
// For each row, ensure we have the most up-to-date item number
data.forEach((_, index) => {
// Check if upcValidation has an item number for this row
const itemNumber = upcValidation.getItemNumber(index);
if (itemNumber) {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from upcValidation: ${itemNumber}`);
result.set(index, itemNumber);
}
// Also check if it's directly in the data
const dataItemNumber = data[index].item_number;
if (dataItemNumber && !result.has(index)) {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from data: ${dataItemNumber}`);
result.set(index, dataItemNumber);
}
});
return result;
}, [data, itemNumbers, upcValidation]);
// Create upcValidationResults map using the consolidated item numbers
const upcValidationResults = useMemo(() => {
const results = new Map<number, { itemNumber: string }>();
// Populate with our consolidated item numbers
consolidatedItemNumbers.forEach((itemNumber, rowIndex) => {
results.set(rowIndex, { itemNumber });
});
return results;
}, [consolidatedItemNumbers]);
// Render the validation table with the provided props and UPC data
return (
<ValidationTable
data={data}
fields={fields}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
updateRow={updateRow as unknown as (rowIndex: number, key: string, value: any) => void}
validationErrors={validationErrors}
isValidatingUpc={isValidatingUpc}
validatingUpcRows={validatingUpcRows}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={combinedValidatingCells}
itemNumbers={consolidatedItemNumbers}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
upcValidationResults={upcValidationResults}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
/>
)
}
export default UpcValidationTableAdapter

View File

@@ -0,0 +1,517 @@
import React from 'react'
import { Field, ErrorType } from '../../../types'
import { AlertCircle, ArrowDown, X } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell'
import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
// Context for copy down selection mode
export const CopyDownContext = React.createContext<{
isInCopyDownMode: boolean;
sourceRowIndex: number | null;
sourceFieldKey: string | null;
targetRowIndex: number | null;
setIsInCopyDownMode: (value: boolean) => void;
setSourceRowIndex: (value: number | null) => void;
setSourceFieldKey: (value: string | null) => void;
setTargetRowIndex: (value: number | null) => void;
handleCopyDownComplete: (sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => void;
}>({
isInCopyDownMode: false,
sourceRowIndex: null,
sourceFieldKey: null,
targetRowIndex: null,
setIsInCopyDownMode: () => {},
setSourceRowIndex: () => {},
setSourceFieldKey: () => {},
setTargetRowIndex: () => {},
handleCopyDownComplete: () => {},
});
// Define error object type
type ErrorObject = {
message: string;
level: string;
source?: string;
type?: ErrorType;
}
// Helper function to check if a value is empty - utility function shared by all components
const isEmpty = (val: any): boolean =>
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
// Memoized validation icon component
const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px] text-wrap break-words">
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
));
ValidationIcon.displayName = 'ValidationIcon';
// Memoized base cell content component
const BaseCellContent = React.memo(({
field,
value,
onChange,
hasErrors,
options = [],
className = '',
fieldKey = ''
}: {
field: Field<string>;
value: any;
onChange: (value: any) => void;
hasErrors: boolean;
options?: readonly any[];
className?: string;
fieldKey?: string;
}) => {
// Get field type information
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
? 'select'
: typeof field.fieldType === 'string'
? field.fieldType
: field.fieldType?.type || 'input';
// Check for multiline input
const isMultiline = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.multiline === true;
// Check for price field
const isPrice = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.price === true;
// Special case for line and subline - check this first, before any other field type checks
if (fieldKey === 'line' || fieldKey === 'subline') {
// Force these fields to always use SelectCell regardless of fieldType
return (
<SelectCell
field={{...field, fieldType: { type: 'select', options }}}
value={value}
onChange={onChange}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
if (fieldType === 'select') {
return (
<SelectCell
field={field}
value={value}
onChange={onChange}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return (
<MultiSelectCell
field={field}
value={value}
onChange={onChange}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
return (
<InputCell
field={field}
value={value}
onChange={onChange}
hasErrors={hasErrors}
isMultiline={isMultiline}
isPrice={isPrice}
disabled={field.disabled}
/>
);
}, (prev, next) => {
// Shallow array comparison for options if arrays
const optionsEqual = prev.options === next.options ||
(Array.isArray(prev.options) && Array.isArray(next.options) &&
prev.options.length === next.options.length &&
prev.options.every((opt, idx) => opt === (next.options as any[])[idx]));
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.field === next.field &&
prev.className === next.className &&
optionsEqual
);
});
BaseCellContent.displayName = 'BaseCellContent';
export interface ValidationCellProps {
field: Field<string>
value: any
onChange: (value: any) => void
errors: ErrorObject[]
isValidating?: boolean
fieldKey: string
options?: readonly any[]
itemNumber?: string
width: number
rowIndex: number
copyDown?: (endRowIndex?: number) => void
totalRows?: number
}
// Add efficient error message extraction function
// Highly optimized error processing function with fast paths for common cases
function processErrors(value: any, errors: ErrorObject[]): {
hasError: boolean;
isRequiredButEmpty: boolean;
shouldShowErrorIcon: boolean;
errorMessages: string;
} {
// Fast path - if no errors or empty error array, return immediately
if (!errors || errors.length === 0) {
return {
hasError: false,
isRequiredButEmpty: false,
shouldShowErrorIcon: false,
errorMessages: ''
};
}
// Use the shared isEmpty function for value checking
const valueIsEmpty = isEmpty(value);
// Fast path for the most common case - required field with empty value
if (valueIsEmpty && errors.length === 1 && errors[0].type === ErrorType.Required) {
return {
hasError: true,
isRequiredButEmpty: true,
shouldShowErrorIcon: false,
errorMessages: ''
};
}
// For non-empty values with errors, we need to show error icons
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
// For empty values with required errors, show only a border
const isRequiredButEmpty = valueIsEmpty && errors.some(error => error.type === ErrorType.Required);
// Show error icons for non-empty fields with errors, or for empty fields with non-required errors
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !errors.every(error => error.type === ErrorType.Required));
// Only compute error messages if we're going to show an icon
const errorMessages = shouldShowErrorIcon
? errors
.filter(e => e.level === 'error' || e.level === 'warning')
.map(e => e.message)
.join('\n')
: '';
return {
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
};
}
// Helper function to compare error arrays efficiently with a hash-based approach
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
// Fast path for referential equality
if (prevErrors === nextErrors) return true;
// Fast path for length check
if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
if (prevErrors.length !== nextErrors.length) return false;
// Generate simple hash from error properties
const getErrorHash = (error: ErrorObject): string => {
return `${error.message}|${error.level}|${error.type || ''}`;
};
// Compare using hashes
const prevHashes = prevErrors.map(getErrorHash);
const nextHashes = nextErrors.map(getErrorHash);
// Sort hashes to ensure consistent order
prevHashes.sort();
nextHashes.sort();
// Compare sorted hash arrays
return prevHashes.join(',') === nextHashes.join(',');
}
const ValidationCell = React.memo(({
field,
value,
onChange,
errors,
isValidating,
fieldKey,
options = [],
itemNumber,
width,
copyDown,
rowIndex,
totalRows = 0
}: ValidationCellProps) => {
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
// This ensures that when the itemNumber changes, the display value changes
let displayValue;
if (fieldKey === 'item_number' && itemNumber) {
// Always log when an item_number field is rendered to help debug
console.log(`[VC-DEBUG] ValidationCell rendering item_number for row=${rowIndex} with itemNumber=${itemNumber}, value=${value}`);
// Prioritize itemNumber prop for item_number fields
displayValue = itemNumber;
} else {
displayValue = value;
}
// Use the optimized processErrors function to avoid redundant filtering
const {
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
} = React.useMemo(() => processErrors(displayValue, errors), [displayValue, errors]);
// Track whether this cell is the source of a copy-down operation
const isSourceCell = copyDownContext.isInCopyDownMode &&
rowIndex === copyDownContext.sourceRowIndex &&
fieldKey === copyDownContext.sourceFieldKey;
// Add state for hover on copy down button
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
// Add state for hover on target row
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
// Force isValidating to be a boolean
const isLoading = isValidating === true;
// Handle copy down button click
const handleCopyDownClick = React.useCallback(() => {
if (copyDown && totalRows > rowIndex + 1) {
// Enter copy down mode
copyDownContext.setIsInCopyDownMode(true);
copyDownContext.setSourceRowIndex(rowIndex);
copyDownContext.setSourceFieldKey(fieldKey);
}
}, [copyDown, copyDownContext, fieldKey, rowIndex, totalRows]);
// Check if this cell is in a row that can be a target for copy down
const isInTargetRow = copyDownContext.isInCopyDownMode &&
copyDownContext.sourceFieldKey === fieldKey &&
rowIndex > (copyDownContext.sourceRowIndex || 0);
// Check if this row is the currently selected target row
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
// Handle click on a potential target cell
const handleTargetCellClick = React.useCallback(() => {
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
copyDownContext.handleCopyDownComplete(
copyDownContext.sourceRowIndex,
copyDownContext.sourceFieldKey,
rowIndex
);
}
}, [copyDownContext, isInTargetRow, rowIndex]);
// Memoize the cell style objects to avoid recreating them on every render
const cellStyle = React.useMemo(() => ({
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box' as const,
cursor: isInTargetRow ? 'pointer' : undefined
}), [width, isInTargetRow]);
// Memoize the cell class name to prevent re-calculating on every render
const cellClassName = React.useMemo(() => {
if (isSourceCell || isSelectedTarget || isInTargetRow) {
return isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : '';
}
return '';
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
return (
<TableCell
className="p-1 group relative"
style={cellStyle}
onClick={isInTargetRow ? handleTargetCellClick : undefined}
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
{shouldShowErrorIcon && !isInTargetRow && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
<ValidationIcon error={{
message: errorMessages,
level: 'error',
type: ErrorType.Custom
}} />
</div>
)}
{!shouldShowErrorIcon && copyDown && !isEmpty(displayValue) && !copyDownContext.isInCopyDownMode && (
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopyDownClick}
onMouseEnter={() => setIsCopyDownHovered(true)}
onMouseLeave={() => setIsCopyDownHovered(false)}
className="p-1 rounded-full hover:bg-blue-100 text-blue-500/70 hover:text-blue-600 transition-colors"
aria-label="Copy value to rows below"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-col">
<p className="font-medium">Copy value to rows below</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isSourceCell && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => copyDownContext.setIsInCopyDownMode(false)}
className="p-1 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
aria-label="Cancel copy down"
>
<X className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Cancel copy down</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? (
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
<Skeleton className="w-full h-4" />
</div>
) : (
<div
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
style={{
backgroundColor: isSourceCell ? '#dbeafe' :
isSelectedTarget ? '#bfdbfe' :
isInTargetRow && isTargetRowHovered ? '#dbeafe' :
undefined,
borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined,
boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined
}}
>
<BaseCellContent
field={field}
value={displayValue}
onChange={onChange}
hasErrors={hasError || isRequiredButEmpty}
options={options}
className={cellClassName}
fieldKey={fieldKey}
/>
</div>
)}
</div>
</TableCell>
);
}, (prevProps, nextProps) => {
// Fast path: if all props are the same object
if (prevProps === nextProps) return true;
// Optimize the memo comparison function, checking most impactful props first
// Check isValidating first as it's most likely to change frequently
if (prevProps.isValidating !== nextProps.isValidating) return false;
// Then check value changes
if (prevProps.value !== nextProps.value) return false;
// Item number is related to validation state
if (prevProps.itemNumber !== nextProps.itemNumber) return false;
// Check errors with our optimized comparison function
if (!compareErrorArrays(prevProps.errors, nextProps.errors)) return false;
// Check field identity
if (prevProps.field !== nextProps.field) return false;
// Shallow options comparison - only if field type is select or multi-select
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
const optionsEqual = prevProps.options === nextProps.options ||
(Array.isArray(prevProps.options) &&
Array.isArray(nextProps.options) &&
prevProps.options.length === nextProps.options.length &&
prevProps.options.every((opt, idx) => {
const nextOptions = nextProps.options || [];
return opt === nextOptions[idx];
}));
if (!optionsEqual) return false;
}
// Check copy down context changes
const copyDownContextChanged =
prevProps.rowIndex !== nextProps.rowIndex ||
prevProps.fieldKey !== nextProps.fieldKey;
if (copyDownContextChanged) return false;
// All essential props are the same - we can skip re-rendering
return true;
});
ValidationCell.displayName = 'ValidationCell';
export default ValidationCell;

View File

@@ -0,0 +1,696 @@
import React, { useMemo, useCallback, useState } from 'react'
import {
useReactTable,
getCoreRowModel,
flexRender,
RowSelectionState,
ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/validationTypes'
import ValidationCell, { CopyDownContext } from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
message: string;
level: string;
source?: string;
}
interface ValidationTableProps<T extends string> {
data: RowData<T>[]
fields: Fields<T>
rowSelection: RowSelectionState
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
filters?: { showErrorsOnly?: boolean }
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
rowProductLines?: Record<string, any[]>
rowSublines?: Record<string, any[]>
isLoadingLines?: Record<string, boolean>
isLoadingSublines?: Record<string, boolean>
upcValidationResults: Map<number, { itemNumber: string }>
validatingCells: Set<string>
itemNumbers: Map<number, string>
isLoadingTemplates?: boolean
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
[key: string]: any
}
// Create a memoized wrapper for template selects to prevent unnecessary re-renders
const MemoizedTemplateSelect = React.memo(({
templates,
value,
onValueChange,
getTemplateDisplayText,
defaultBrand,
isLoading
}: {
templates: Template[],
value: string,
onValueChange: (value: string) => void,
getTemplateDisplayText: (value: string | null) => string,
defaultBrand?: string,
isLoading?: boolean
}) => {
if (isLoading) {
return (
<div className="flex items-center justify-center gap-2 border border-input rounded-md px-2 py-2">
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<SearchableTemplateSelect
templates={templates}
value={value}
onValueChange={onValueChange}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
);
}, (prev, next) => {
return (
prev.value === next.value &&
prev.templates === next.templates &&
prev.defaultBrand === next.defaultBrand &&
prev.isLoading === next.isLoading
);
});
MemoizedTemplateSelect.displayName = 'MemoizedTemplateSelect';
// Create a memoized cell component
const MemoizedCell = React.memo(({
field,
value,
onChange,
errors,
isValidating,
fieldKey,
options,
itemNumber,
width,
rowIndex,
copyDown,
totalRows
}: {
field: Field<string>,
value: any,
onChange: (value: any) => void,
errors: ErrorType[],
isValidating?: boolean,
fieldKey: string,
options?: readonly any[],
itemNumber?: string,
width: number,
rowIndex: number,
copyDown?: (endRowIndex?: number) => void,
totalRows: number
}) => {
return (
<ValidationCell
field={field}
value={value}
onChange={onChange}
errors={errors}
isValidating={isValidating}
fieldKey={fieldKey}
options={options}
itemNumber={itemNumber}
width={width}
rowIndex={rowIndex}
copyDown={copyDown}
totalRows={totalRows}
/>
);
}, (prev, next) => {
// CRITICAL FIX: Never memoize item_number cells - always re-render them
if (prev.fieldKey === 'item_number') {
return false; // Never skip re-renders for item_number cells
}
// Optimize the memo comparison function for better performance
// Only re-render if these essential props change
const valueEqual = prev.value === next.value;
const isValidatingEqual = prev.isValidating === next.isValidating;
// Shallow equality check for errors array
const errorsEqual = prev.errors === next.errors || (
Array.isArray(prev.errors) &&
Array.isArray(next.errors) &&
prev.errors.length === next.errors.length &&
prev.errors.every((err, idx) => err === next.errors[idx])
);
// Shallow equality check for options array
const optionsEqual = prev.options === next.options || (
Array.isArray(prev.options) &&
Array.isArray(next.options) &&
prev.options.length === next.options.length &&
prev.options.every((opt, idx) => opt === next.options?.[idx])
);
// Skip checking for props that rarely change
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual;
});
MemoizedCell.displayName = 'MemoizedCell';
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
validatingCells,
itemNumbers,
isLoadingTemplates = false,
copyDown,
rowProductLines = {},
rowSublines = {},
isLoadingLines = {},
isLoadingSublines = {},
isValidatingUpc,
validatingUpcRows = [],
upcValidationResults
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
// Add state for copy down selection mode
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null);
const [sourceFieldKey, setSourceFieldKey] = useState<string | null>(null);
const [targetRowIndex, setTargetRowIndex] = useState<number | null>(null);
// Handle copy down completion
const handleCopyDownComplete = useCallback((sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => {
// Call the copyDown function with the source row index, field key, and target row index
copyDown(sourceRowIndex, fieldKey, targetRowIndex);
// Reset the copy down selection mode
setIsInCopyDownMode(false);
setSourceRowIndex(null);
setSourceFieldKey(null);
setTargetRowIndex(null);
}, [copyDown]);
// Create copy down context value
const copyDownContextValue = useMemo(() => ({
isInCopyDownMode,
sourceRowIndex,
sourceFieldKey,
targetRowIndex,
setIsInCopyDownMode,
setSourceRowIndex,
setSourceFieldKey,
setTargetRowIndex,
handleCopyDownComplete
}), [
isInCopyDownMode,
sourceRowIndex,
sourceFieldKey,
targetRowIndex,
handleCopyDownComplete
]);
// Update targetRowIndex when hovering over rows in copy down mode
const handleRowMouseEnter = useCallback((rowIndex: number) => {
if (isInCopyDownMode && sourceRowIndex !== null && rowIndex > sourceRowIndex) {
setTargetRowIndex(rowIndex);
}
}, [isInCopyDownMode, sourceRowIndex]);
// Memoize the selection column with stable callback
const handleSelectAll = useCallback((value: boolean, table: any) => {
table.toggleAllPageRowsSelected(!!value);
}, []);
const handleRowSelect = useCallback((value: boolean, row: any) => {
row.toggleSelected(!!value);
}, []);
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
id: 'select',
header: ({ table }) => (
<div className="flex h-full items-center justify-center py-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => handleSelectAll(!!value, table)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center py-9">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => handleRowSelect(!!value, row)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
size: 50,
}), [handleSelectAll, handleRowSelect]);
// Memoize template selection handler
const handleTemplateChange = useCallback((value: string, rowIndex: number) => {
applyTemplate(value, [rowIndex]);
}, [applyTemplate]);
// Memoize the template column with stable callback
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
accessorKey: '__template',
header: 'Template',
size: 200,
cell: ({ row }) => {
const templateValue = row.original.__template || null;
const defaultBrand = row.original.company || undefined;
const rowIndex = data.findIndex(r => r === row.original);
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
<div className="w-full overflow-hidden">
<MemoizedTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
isLoading={isLoadingTemplates}
/>
</div>
</TableCell>
);
}
}), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
// Cache options by field key to avoid recreating arrays
const optionsCache = useMemo(() => {
const cache = new Map<string, readonly any[]>();
fields.forEach((field) => {
// Get the field key
const fieldKey = String(field.key);
// Handle all select and multi-select fields the same way
if (field.fieldType &&
(typeof field.fieldType === 'object') &&
(field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')) {
cache.set(fieldKey, (field.fieldType as any).options || []);
}
});
return cache;
}, [fields]);
// Memoize the field update handler
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
updateRow(rowIndex, fieldKey, value);
}, [updateRow]);
// Memoize the copyDown handler
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
copyDown(rowIndex, fieldKey, endRowIndex);
}, [copyDown]);
// Use validatingUpcRows for calculation
const isRowValidatingUpc = useCallback((rowIndex: number) => {
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
}, [isValidatingUpc, validatingUpcRows]);
// Use upcValidationResults for display, prioritizing the most recent values
const getRowUpcResult = useCallback((rowIndex: number) => {
// ALWAYS get from the data array directly - most authoritative source
const rowData = data[rowIndex];
if (rowData && rowData.item_number) {
return rowData.item_number;
}
// Maps are only backup sources when data doesn't have a value
const itemNumberFromMap = itemNumbers.get(rowIndex);
if (itemNumberFromMap) {
return itemNumberFromMap;
}
// Last resort - upcValidationResults
const upcResult = upcValidationResults?.get(rowIndex)?.itemNumber;
if (upcResult) {
return upcResult;
}
return undefined;
}, [data, itemNumbers, upcValidationResults]);
// Memoize field columns with stable handlers
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
// Don't filter out disabled fields, just pass the disabled state to the cell component
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
field.fieldType.type === "multi-select" ? 200 :
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
(field.fieldType as any).multiline ? 300 :
150
);
const fieldKey = String(field.key);
// Get cached options for this field
const fieldOptions = optionsCache.get(fieldKey) || [];
return {
accessorKey: fieldKey,
header: field.label || fieldKey,
size: fieldWidth,
cell: ({ row }) => {
// Get row-specific options for line and subline fields
let options = fieldOptions;
const rowId = row.original.__index;
if (fieldKey === 'line' && rowId && rowProductLines[rowId]) {
options = rowProductLines[rowId];
} else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) {
options = rowSublines[rowId];
}
// Determine if this cell is in loading state - use a clear consistent approach
let isLoading = false;
// Check the validatingCells Set first (for item_number and other fields)
const cellLoadingKey = `${row.index}-${fieldKey}`;
if (validatingCells.has(cellLoadingKey)) {
isLoading = true;
}
// Check if UPC is validating for this row and field is item_number
else if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) {
isLoading = true;
}
// Add loading state for line/subline fields
else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
isLoading = true;
}
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
isLoading = true;
}
// Get validation errors for this cell
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || [];
// Create a copy of the field with guaranteed field type for line and subline fields
let fieldWithType = field;
// Ensure line and subline fields always have the correct fieldType
if (fieldKey === 'line' || fieldKey === 'subline') {
// Create a deep clone of the field to prevent any reference issues
fieldWithType = {
...JSON.parse(JSON.stringify(field)), // Ensure deep clone
fieldType: {
type: 'select',
options: options
},
// Explicitly mark as not disabled to ensure dropdown works
disabled: false
};
}
// CRITICAL CHANGE: Get item number directly from row data first for item_number fields
let itemNumber;
if (fieldKey === 'item_number') {
// Check directly in row data first - this is the most accurate source
const directValue = row.original[fieldKey];
if (directValue) {
itemNumber = directValue;
} else {
// Fall back to centralized getter that checks all sources
itemNumber = getRowUpcResult(row.index);
}
}
// CRITICAL: For item_number fields, create a unique key that includes the itemNumber value
// This forces a complete re-render when the itemNumber changes
const cellKey = fieldKey === 'item_number'
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}-${Date.now()}` // Force re-render on every render cycle for item_number
: `cell-${row.index}-${fieldKey}`;
return (
<MemoizedCell
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
field={fieldWithType as Field<string>}
value={fieldKey === 'item_number' && row.original[field.key]
? row.original[field.key] // Use direct value from row data
: row.original[field.key as keyof typeof row.original]}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={cellErrors}
isValidating={isLoading}
fieldKey={fieldKey}
options={options}
itemNumber={itemNumber}
width={fieldWidth}
rowIndex={row.index}
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
totalRows={data.length}
/>
);
}
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache,
data.length, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines,
isRowValidatingUpc, getRowUpcResult]);
// Combine columns
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
const table = useReactTable({
data,
columns,
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []),
});
// Calculate total table width for stable horizontal scrolling
const totalWidth = useMemo(() => {
return columns.reduce((total, col) => total + (col.size || 0), 0);
}, [columns]);
// Don't render if no data
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
{filters?.showErrorsOnly
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors"
: translations.validationStep.noRowsMessage || "No data to display"}
</p>
</div>
);
}
return (
<CopyDownContext.Provider value={copyDownContextValue}>
<div className="min-w-max relative">
{/* Add global styles for copy down mode */}
{isInCopyDownMode && (
<style>
{`
.copy-down-target-row,
.copy-down-target-row *,
.copy-down-target-row input,
.copy-down-target-row textarea,
.copy-down-target-row div,
.copy-down-target-row button,
.target-row-cell,
.target-row-cell * {
cursor: pointer !important;
}
`}
</style>
)}
{isInCopyDownMode && sourceRowIndex !== null && sourceFieldKey !== null && (
<div className="sticky top-0 z-30 h-0 overflow-visible">
<div
className="absolute w-[240px] top-16 bg-blue-50 border rounded-2xl shadow-lg border-blue-200 p-3 text-sm text-blue-700 flex items-center justify-between"
style={{
left: (() => {
// Find the column index
const colIndex = columns.findIndex(col =>
'accessorKey' in col && col.accessorKey === sourceFieldKey
);
// If column not found, position at a default location
if (colIndex === -1) return '50px';
// Calculate position based on column widths
let position = 0;
for (let i = 0; i < colIndex; i++) {
position += columns[i].size || 0;
}
// Add half of the current column width to center it
position += (columns[colIndex].size || 0) / 2;
// Adjust to center the notification
position -= 120; // Half of the notification width
return `${Math.max(50, position)}px`;
})()
}}
>
<div>
<span className="font-medium">Click on the last row you want to copy to</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsInCopyDownMode(false)}
className="text-xs h-7 border-blue-200 text-blue-700 hover:bg-blue-100"
>
Cancel
</Button>
</div>
</div>
)}
<div className="relative">
{/* Custom Table Header - Always Visible with GPU acceleration */}
<div
className="sticky top-0 z-20 bg-muted border-b shadow-sm will-change-transform"
style={{
width: `${totalWidth}px`,
transform: 'translateZ(0)', // Force GPU acceleration
}}
>
<div className="flex">
{table.getFlatHeaders().map((header) => {
const width = header.getSize();
return (
<div
key={header.id}
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
style={{
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box',
height: '40px'
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
})}
</div>
</div>
{/* Table Body - With optimized rendering */}
<Table style={{
width: `${totalWidth}px`,
tableLayout: 'fixed',
borderCollapse: 'separate',
borderSpacing: 0,
marginTop: '-1px',
willChange: 'transform', // Help browser optimize
contain: 'content', // Contain paint operations
transform: 'translateZ(0)' // Force GPU acceleration
}}>
<TableBody>
{table.getRowModel().rows.map((row) => {
// Precompute validation error status for this row
const hasErrors = validationErrors.has(parseInt(row.id)) &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
// Precompute copy down target status
const isCopyDownTarget = isInCopyDownMode &&
sourceRowIndex !== null &&
parseInt(row.id) > sourceRowIndex;
// Using CSS variables for better performance on hover/state changes
const rowStyle = {
cursor: isCopyDownTarget ? 'pointer' : undefined,
position: 'relative' as const,
willChange: isInCopyDownMode ? 'background-color' : 'auto',
contain: 'layout',
transition: 'background-color 100ms ease-in-out'
};
return (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "!bg-blue-50/50" : "",
hasErrors ? "bg-red-50/40" : "",
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
)}
style={rowStyle}
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
>
{row.getVisibleCells().map((cell: any) => (
<React.Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</React.Fragment>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</CopyDownContext.Provider>
);
};
// Optimize memo comparison with more efficient checks
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
// Check reference equality for simple props first
if (prev.fields !== next.fields) return false;
if (prev.templates !== next.templates) return false;
if (prev.isLoadingTemplates !== next.isLoadingTemplates) return false;
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
// Fast path: data length change always means re-render
if (prev.data.length !== next.data.length) return false;
// Efficiently check row selection changes
const prevSelectionKeys = Object.keys(prev.rowSelection);
const nextSelectionKeys = Object.keys(next.rowSelection);
if (prevSelectionKeys.length !== nextSelectionKeys.length) return false;
if (!prevSelectionKeys.every(key => prev.rowSelection[key] === next.rowSelection[key])) return false;
// Use size for Map comparisons instead of deeper checks
if (prev.validationErrors.size !== next.validationErrors.size) return false;
if (prev.validatingCells.size !== next.validatingCells.size) return false;
if (prev.itemNumbers.size !== next.itemNumbers.size) return false;
// If values haven't changed, component doesn't need to re-render
return true;
};
export default React.memo(ValidationTable, areEqual);

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import React from 'react'
interface CheckboxCellProps<T extends string> {
field: Field<T>
@@ -9,6 +10,7 @@ interface CheckboxCellProps<T extends string> {
onChange: (value: any) => void
hasErrors?: boolean
booleanMatches?: Record<string, boolean>
className?: string
}
const CheckboxCell = <T extends string>({
@@ -16,9 +18,12 @@ const CheckboxCell = <T extends string>({
value,
onChange,
hasErrors,
booleanMatches = {}
booleanMatches = {},
className = ''
}: CheckboxCellProps<T>) => {
const [checked, setChecked] = useState(false)
// Add state for hover
const [isHovered, setIsHovered] = useState(false)
// Initialize checkbox state
useEffect(() => {
@@ -70,6 +75,12 @@ const CheckboxCell = <T extends string>({
setChecked(!!value)
}, [value, field.fieldType, booleanMatches])
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = (className || '').split(' ');
return classNames.includes(cls);
};
// Handle checkbox change
const handleChange = useCallback((checked: boolean) => {
setChecked(checked)
@@ -80,11 +91,27 @@ const CheckboxCell = <T extends string>({
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
return (
<div className={cn(
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
outlineClass,
hasErrors ? "bg-red-50 border-destructive" : "border-input"
)}>
<div
className={cn(
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
outlineClass,
hasErrors ? "bg-red-50 border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Checkbox
checked={checked}
onCheckedChange={handleChange}
@@ -96,4 +123,23 @@ const CheckboxCell = <T extends string>({
)
}
export default CheckboxCell
export default React.memo(CheckboxCell, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.field !== next.field) return false;
if (prev.value !== next.value) return false;
if (prev.className !== next.className) return false;
// Compare booleanMatches objects
const prevMatches = prev.booleanMatches || {};
const nextMatches = next.booleanMatches || {};
const prevKeys = Object.keys(prevMatches);
const nextKeys = Object.keys(nextMatches);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of prevKeys) {
if (prevMatches[key] !== nextMatches[key]) return false;
}
return true;
});

View File

@@ -0,0 +1,299 @@
import React, { useState, useCallback, useTransition, useRef, useEffect, useMemo } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import MultilineInput from './MultilineInput'
interface InputCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
isMultiline?: boolean
isPrice?: boolean
disabled?: boolean
className?: string
}
// Add efficient price formatting utility
const formatPrice = (value: string): string => {
// Remove any non-numeric characters except decimal point
const numericValue = value.replace(/[^\d.]/g, '');
// Parse as float and format to 2 decimal places
const numValue = parseFloat(numericValue);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
}
return numericValue;
};
const InputCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
isMultiline = false,
isPrice = false,
disabled = false,
className = ''
}: InputCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [isPending, startTransition] = useTransition();
// Use a ref to track if we need to process the value
const needsProcessingRef = useRef(false);
// Track local display value to avoid waiting for validation
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
// Add state for hover
const [isHovered, setIsHovered] = useState(false);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = className.split(' ');
return classNames.includes(cls);
};
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
if (localDisplayValue === null ||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
value.trim() !== localDisplayValue.trim())) {
setLocalDisplayValue(value);
}
}, [value, localDisplayValue]);
// Efficiently handle price formatting without multiple rerenders
useEffect(() => {
if (isPrice && needsProcessingRef.current && !isEditing) {
needsProcessingRef.current = false;
// Do price processing only when needed
const formattedValue = formatPrice(value);
if (formattedValue !== value) {
onChange(formattedValue);
}
}
}, [value, isPrice, isEditing, onChange]);
// Handle focus event - optimized to be synchronous
const handleFocus = useCallback(() => {
setIsEditing(true);
// For price fields, strip formatting when focusing
if (value !== undefined && value !== null) {
if (isPrice) {
// Remove any non-numeric characters except decimal point
const numericValue = String(value).replace(/[^\d.]/g, '');
setEditValue(numericValue);
} else {
setEditValue(String(value));
}
} else {
setEditValue('');
}
onStartEdit?.();
}, [value, onStartEdit, isPrice]);
// Handle blur event - use transition for non-critical updates
const handleBlur = useCallback(() => {
// First - lock in the current edit value to prevent it from being lost
const finalValue = editValue.trim();
// Then transition to non-editing state
startTransition(() => {
setIsEditing(false);
// Format the value for storage (remove formatting like $ for price)
let processedValue = finalValue;
if (isPrice && processedValue) {
needsProcessingRef.current = true;
}
// Update local display value immediately to prevent UI flicker
setLocalDisplayValue(processedValue);
// Commit the change to parent component
onChange(processedValue);
onEndEdit?.();
});
}, [editValue, onChange, onEndEdit, isPrice]);
// Handle direct input change - optimized to be synchronous for typing
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value;
setEditValue(newValue);
}, [isPrice]);
// Get the display value - prioritize local display value
const displayValue = useMemo(() => {
// First priority: local display value (for immediate updates)
if (localDisplayValue !== null) {
if (isPrice) {
// Format price value
const numValue = parseFloat(localDisplayValue);
return !isNaN(numValue) ? numValue.toFixed(2) : localDisplayValue;
}
return localDisplayValue;
}
// Second priority: handle price formatting for the actual value
if (isPrice && value) {
if (typeof value === 'number') {
return value.toFixed(2);
} else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value).toFixed(2);
}
}
// Default: use the actual value or empty string
return value ?? '';
}, [isPrice, value, localDisplayValue]);
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
// If disabled, just render the value without any interactivity
if (disabled) {
return (
<div
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
);
}
// Render multiline fields using the dedicated MultilineInput component
if (isMultiline) {
return (
<MultilineInput
field={field}
value={value}
onChange={onChange}
hasErrors={hasErrors}
disabled={disabled}
className={className}
/>
);
}
// Original component for non-multiline fields
return (
<div className="w-full">
{isEditing ? (
<Input
type="text"
value={editValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : "",
isPending ? "opacity-50" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'text'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
)}
</div>
)
}
// Optimize memo comparison to focus on essential props
export default React.memo(InputCell, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.isMultiline !== next.isMultiline) return false;
if (prev.isPrice !== next.isPrice) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.field !== next.field) return false;
// Only check value if not editing (to avoid expensive rerender during editing)
if (prev.value !== next.value) {
// For price values, do a more intelligent comparison
if (prev.isPrice) {
// Convert both to numeric values for comparison
const prevNum = typeof prev.value === 'number' ? prev.value :
typeof prev.value === 'string' ? parseFloat(prev.value) : 0;
const nextNum = typeof next.value === 'number' ? next.value :
typeof next.value === 'string' ? parseFloat(next.value) : 0;
// Only update if the actual numeric values differ
if (!isNaN(prevNum) && !isNaN(nextNum) &&
Math.abs(prevNum - nextNum) > 0.001) {
return false;
}
} else {
return false;
}
}
return true;
});

View File

@@ -0,0 +1,545 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { Field } from '../../../../types'
import { cn } from '@/lib/utils'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
// Define a type for field options
interface FieldOption {
label: string;
value: string;
}
interface MultiSelectCellProps<T extends string> {
field: Field<T>
value: string[]
onChange: (value: string[]) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
options?: readonly FieldOption[]
disabled?: boolean
className?: string
}
// Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({
option,
isSelected,
onSelect
}: {
option: FieldOption,
isSelected: boolean,
onSelect: (value: string) => void
}) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => onSelect(option.value)}
className="flex w-full"
>
<div className="flex items-center w-full overflow-hidden">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate w-full">{option.label}</span>
</div>
</CommandItem>
), (prev, next) => {
return prev.option.value === next.option.value &&
prev.isSelected === next.isSelected;
});
OptionItem.displayName = 'OptionItem';
// Create a virtualized list component for large option lists
const VirtualizedOptions = React.memo(({
options,
selectedValues,
onSelect,
maxHeight = 200
}: {
options: FieldOption[],
selectedValues: Set<string>,
onSelect: (value: string) => void,
maxHeight?: number
}) => {
const listRef = useRef<HTMLDivElement>(null);
// Only render visible options for better performance with large lists
const [visibleOptions, setVisibleOptions] = useState<FieldOption[]>([]);
const [scrollPosition, setScrollPosition] = useState(0);
// Constants for virtualization
const itemHeight = 32; // Height of each option item in pixels
const visibleCount = Math.ceil(maxHeight / itemHeight) + 2; // Number of visible items + buffer
// Handle scroll events
const handleScroll = useCallback(() => {
if (listRef.current) {
setScrollPosition(listRef.current.scrollTop);
}
}, []);
// Update visible options based on scroll position
useEffect(() => {
if (options.length <= visibleCount) {
// If fewer options than visible count, just show all
setVisibleOptions(options);
return;
}
// Calculate start and end indices
const startIndex = Math.floor(scrollPosition / itemHeight);
const endIndex = Math.min(startIndex + visibleCount, options.length);
// Update visible options
setVisibleOptions(options.slice(Math.max(0, startIndex), endIndex));
}, [options, scrollPosition, visibleCount, itemHeight]);
// If fewer than the threshold, render all directly
if (options.length <= 100) {
return (
<div ref={listRef} className="max-h-[200px] overflow-y-auto" onScroll={handleScroll}>
{options.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<div
ref={listRef}
className="max-h-[200px] overflow-y-auto"
onScroll={handleScroll}
style={{ height: `${Math.min(maxHeight, options.length * itemHeight)}px` }}
>
<div style={{ height: `${options.length * itemHeight}px`, position: 'relative' }}>
<div style={{
position: 'absolute',
top: `${Math.floor(scrollPosition / itemHeight) * itemHeight}px`,
width: '100%'
}}>
{visibleOptions.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
</div>
</div>
);
});
VirtualizedOptions.displayName = 'VirtualizedOptions';
const MultiSelectCell = <T extends string>({
field,
value = [],
onChange,
onStartEdit,
onEndEdit,
hasErrors,
options: providedOptions,
disabled = false,
className = ''
}: MultiSelectCellProps<T>) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// Add internal state for tracking selections - ensure value is always an array
const [internalValue, setInternalValue] = useState<string[]>(Array.isArray(value) ? value : [])
// Ref for the command list to enable scrolling
const commandListRef = useRef<HTMLDivElement>(null)
// Add state for hover
const [isHovered, setIsHovered] = useState(false)
// Add ref to track if we need to sync internal state with external value
const shouldSyncWithExternalValue = useRef(true)
// Create a memoized Set for fast lookups of selected values
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
// Sync internalValue with external value when component mounts or value changes externally
// Modified to prevent infinite loop by checking if values are different before updating
useEffect(() => {
// Only sync if we should (not during internal edits) and if not open
if (shouldSyncWithExternalValue.current && !open) {
const externalValue = Array.isArray(value) ? value : [];
// Only update if values are actually different to prevent infinite loops
if (internalValue.length !== externalValue.length ||
!internalValue.every(v => externalValue.includes(v)) ||
!externalValue.every(v => internalValue.includes(v))) {
setInternalValue(externalValue);
}
}
}, [value, open, internalValue]);
// Handle open state changes with improved responsiveness
const handleOpenChange = useCallback((newOpen: boolean) => {
if (open && !newOpen) {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
// Only update parent state when dropdown closes
// Make a defensive copy to avoid mutations
const valuesToCommit = [...internalValue];
// Immediate UI update
setOpen(false);
// Update parent with the value immediately
onChange(valuesToCommit);
if (onEndEdit) onEndEdit();
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
} else if (newOpen && !open) {
// When opening the dropdown, sync with external value
const externalValue = Array.isArray(value) ? value : [];
setInternalValue(externalValue);
setSearchQuery(""); // Reset search query on open
setOpen(true);
if (onStartEdit) onStartEdit();
} else if (!newOpen) {
// Handle case when dropdown is already closed but handleOpenChange is called
setOpen(false);
}
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
// Memoize field options to prevent unnecessary recalculations
const selectOptions = useMemo(() => {
const fieldType = field.fieldType;
const fieldOptions = fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
fieldType.options ?
fieldType.options :
[];
// Use provided options or field options, ensuring they have the correct shape
// Skip this work if we have a large number of options and they didn't change
if (providedOptions && providedOptions.length > 0) {
// Check if options are already in the right format
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
return providedOptions as FieldOption[];
}
return providedOptions.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
}
// Check field options format
if (fieldOptions.length > 0) {
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
return fieldOptions as FieldOption[];
}
return fieldOptions.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
}
// Add default option if no options available
return [{ label: 'No options available', value: '' }];
}, [field.fieldType, providedOptions]);
// Use deferredValue for search to prevent UI blocking with large lists
const deferredSearchQuery = React.useDeferredValue(searchQuery);
// Memoize filtered options based on search query - efficient filtering algorithm
const filteredOptions = useMemo(() => {
// If no search query, return all options
if (!deferredSearchQuery.trim()) return selectOptions;
const query = deferredSearchQuery.toLowerCase();
// Use faster algorithm for large option lists
if (selectOptions.length > 100) {
return selectOptions.filter(option => {
// First check starting with the query (most relevant)
if (option.label.toLowerCase().startsWith(query)) return true;
// Then check includes for more general matches
return option.label.toLowerCase().includes(query);
});
}
// For smaller lists, do full text search
return selectOptions.filter(option =>
option.label.toLowerCase().includes(query)
);
}, [selectOptions, deferredSearchQuery]);
// Sort options with selected items at the top for the dropdown - only for smaller lists
const sortedOptions = useMemo(() => {
// Skip expensive sorting for large lists
if (selectOptions.length > 100) return filteredOptions;
return [...filteredOptions].sort((a, b) => {
const aSelected = selectedValueSet.has(a.value);
const bSelected = selectedValueSet.has(b.value);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return a.label.localeCompare(b.label);
});
}, [filteredOptions, selectedValueSet, selectOptions.length]);
// Memoize selected values display
const selectedValues = useMemo(() => {
// Use a map for looking up options by value for better performance
const optionsMap = new Map(selectOptions.map(opt => [opt.value, opt]));
return internalValue.map(v => {
const option = optionsMap.get(v);
return {
value: v,
label: option ? option.label : String(v)
};
});
}, [internalValue, selectOptions]);
// Update the handleSelect to operate on internalValue instead of directly calling onChange
const handleSelect = useCallback((selectedValue: string) => {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
setInternalValue(prev => {
let newValue;
if (prev.includes(selectedValue)) {
// Remove the value
newValue = prev.filter(v => v !== selectedValue);
} else {
// Add the value - make a new array to avoid mutations
newValue = [...prev, selectedValue];
}
return newValue;
});
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
}, []);
// Handle wheel scroll in dropdown
const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) {
e.stopPropagation();
commandListRef.current.scrollTop += e.deltaY;
}
}, []);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = className.split(' ');
return classNames.includes(cls);
};
// If disabled, just render the value without any interactivity
if (disabled) {
const displayValue = internalValue.length > 0
? internalValue.map(val => {
const option = selectOptions.find(opt => opt.value === val);
return option ? option.label : val;
}).join(', ')
: '';
return (
<div
className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue || ""}
</div>
);
}
return (
<Popover open={open} onOpenChange={(o) => {
// Only open the popover if we're not in copy down mode
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
setOpen(o);
handleOpenChange(o);
}
}}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue.length && "text-muted-foreground",
hasErrors ? "border-destructive" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onClick={(e) => {
// Don't open the dropdown if we're in copy down mode
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
// Let the parent cell handle the click by NOT preventing default or stopping propagation
return;
}
// Only prevent default and stop propagation if not in copy down mode
e.preventDefault();
e.stopPropagation();
// Toggle the open state and call handleOpenChange to ensure values are saved
const newOpenState = !open;
setOpen(newOpenState);
handleOpenChange(newOpenState);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center w-full justify-between">
<div className="flex items-center gap-2 overflow-hidden">
{internalValue.length === 0 ? (
<span className="text-muted-foreground truncate w-full">Select...</span>
) : internalValue.length === 1 ? (
<span className="truncate w-full">{selectedValues[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate">
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<ChevronsUpDown className="mx-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{selectedValueSet.has(option.value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
MultiSelectCell.displayName = 'MultiSelectCell';
export default React.memo(MultiSelectCell, (prev, next) => {
// Check primitive props first (cheap comparisons)
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.className !== next.className) return false;
// Check field reference
if (prev.field !== next.field) return false;
// Check value arrays (potentially expensive for large arrays)
// Handle undefined or null values safely
const prevValue = prev.value || [];
const nextValue = next.value || [];
if (prevValue.length !== nextValue.length) return false;
for (let i = 0; i < prevValue.length; i++) {
if (prevValue[i] !== nextValue[i]) return false;
}
// Check options (potentially expensive for large option lists)
const prevOptions = prev.options || [];
const nextOptions = next.options || [];
if (prevOptions.length !== nextOptions.length) return false;
// For large option lists, just compare references
if (prevOptions.length > 100) {
return prevOptions === nextOptions;
}
// For smaller lists, do a shallow comparison
for (let i = 0; i < prevOptions.length; i++) {
if (prevOptions[i] !== nextOptions[i]) return false;
}
return true;
});

View File

@@ -0,0 +1,238 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Field } from '../../../../types'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface MultilineInputProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
hasErrors?: boolean
disabled?: boolean
className?: string
}
const MultilineInput = <T extends string>({
field,
value,
onChange,
hasErrors = false,
disabled = false,
className = ''
}: MultilineInputProps<T>) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
const pendingChangeRef = useRef<string | null>(null);
// Add state for hover
const [isHovered, setIsHovered] = useState(false);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = (className || '').split(' ');
return classNames.includes(cls);
};
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
if (localDisplayValue === null ||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
value.trim() !== localDisplayValue.trim())) {
setLocalDisplayValue(value);
}
}, [value, localDisplayValue]);
// Process any pending changes in the background
useEffect(() => {
if (pendingChangeRef.current !== null && !popoverOpen) {
const newValue = pendingChangeRef.current;
pendingChangeRef.current = null;
// Apply changes after the popover is closed
if (newValue !== value) {
onChange(newValue);
}
}
}, [popoverOpen, onChange, value]);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
if (preventReopenRef.current) {
e.preventDefault();
e.stopPropagation();
preventReopenRef.current = false;
return;
}
// Only process if not already open
if (!popoverOpen) {
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || value || '');
}
}, [popoverOpen, value, localDisplayValue]);
// Handle immediate close of popover
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Store pending changes for async processing
pendingChangeRef.current = editValue;
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change to be processed in the background
setTimeout(() => {
onChange(editValue);
}, 0);
}
// Immediately close popover
setPopoverOpen(false);
// Prevent reopening
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange]);
// Handle clicking outside the popover
const handleInteractOutside = useCallback(() => {
handleClosePopover();
}, [handleClosePopover]);
// Handle popover open/close
const handlePopoverOpenChange = useCallback((open: boolean) => {
if (!open && popoverOpen) {
// Just call the close handler
handleClosePopover();
} else if (open && !popoverOpen) {
// When opening, set edit value from current display
setEditValue(localDisplayValue || value || '');
setPopoverOpen(true);
}
}, [value, popoverOpen, handleClosePopover, localDisplayValue]);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : (value ?? '');
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
// If disabled, just render the value without any interactivity
if (disabled) {
return (
<div
className={cn(
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
);
}
return (
<div className="w-full" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full cursor-pointer",
"overflow-hidden whitespace-pre-wrap",
outlineClass,
hasErrors ? "border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'pointer'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: cellRef.current?.offsetWidth || 'auto' }}
align="start"
side="bottom"
alignOffset={0}
sideOffset={-80}
avoidCollisions={false}
onInteractOutside={handleInteractOutside}
forceMount
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
onClick={handleClosePopover}
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5"
>
<X className="h-3 w-3" />
</Button>
<Textarea
value={editValue}
onChange={handleChange}
className="min-h-[200px] border-none focus-visible:ring-0 rounded-none p-2"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
);
};
export default React.memo(MultilineInput, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.field !== next.field) return false;
if (prev.value !== next.value) return false;
if (prev.className !== next.className) return false;
return true;
});

Some files were not shown because too many files have changed in this diff Show More