7 Commits

132 changed files with 5296 additions and 32369 deletions

View File

@@ -1,43 +0,0 @@
# Details
Date : 2025-03-17 14:20:03
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6565 codes, 1027 comments, 1053 blanks, all 8645 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/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/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/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/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 | 1,101 | 234 | 213 | 1,548 |
| [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/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/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 | 93 | 13 | 18 | 124 |
| [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

@@ -1,15 +0,0 @@
# Diff Details
Date : 2025-03-17 14:20:03
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@@ -1,19 +0,0 @@
# Diff Summary
Date : 2025-03-17 14:20:03
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@@ -1,22 +0,0 @@
Date : 2025-03-17 14:20:03
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
Languages
+----------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------+------------+------------+------------+------------+------------+
+----------+------------+------------+------------+------------+------------+
Directories
+------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+------+------------+------------+------------+------------+------------+
+------+------------+------------+------------+------------+------------+
Files
+----------+----------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+----------+----------+------------+------------+------------+------------+
| Total | | 0 | 0 | 0 | 0 |
+----------+----------+------------+------------+------------+------------+

File diff suppressed because one or more lines are too long

View File

@@ -1,31 +0,0 @@
# Summary
Date : 2025-03-17 14:20:03
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6565 codes, 1027 comments, 1053 blanks, all 8645 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 20 | 6,189 | 914 | 970 | 8,073 |
| 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 |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 28 | 6,565 | 1,027 | 1,053 | 8,645 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 13 | 4,004 | 515 | 536 | 5,055 |
| components (Files) | 8 | 2,771 | 357 | 378 | 3,506 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,165 | 393 | 432 | 2,990 |
| 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

@@ -1,62 +0,0 @@
Date : 2025-03-17 14:20:03
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6565 codes, 1027 comments, 1053 blanks, all 8645 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 20 | 6,189 | 914 | 970 | 8,073 |
| 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 |
+------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 28 | 6,565 | 1,027 | 1,053 | 8,645 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 13 | 4,004 | 515 | 536 | 5,055 |
| components (Files) | 8 | 2,771 | 357 | 378 | 3,506 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,165 | 393 | 432 | 2,990 |
| 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/SaveTemplateDialog.tsx | TypeScript JSX | 83 | 0 | 4 | 87 |
| /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/TemplateManager.tsx | TypeScript JSX | 193 | 4 | 15 | 212 |
| /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 | 1,101 | 234 | 213 | 1,548 |
| /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/useFilters.tsx | TypeScript JSX | 89 | 12 | 16 | 117 |
| /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 | 93 | 13 | 18 | 124 |
| /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,565 | 1,027 | 1,053 | 8,645 |
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

View File

@@ -1,43 +0,0 @@
# Details
Date : 2025-03-17 16:02:14
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6551 codes, 1023 comments, 1050 blanks, all 8624 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/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/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/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/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 | 971 | 194 | 178 | 1,343 |
| [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/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/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

@@ -1,17 +0,0 @@
# Diff Details
Date : 2025-03-17 16:02:14
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, -14 codes, -4 comments, -3 blanks, all -21 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/ValidationContainer.tsx](/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx) | TypeScript JSX | -130 | -40 | -35 | -205 |
| [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 | 116 | 36 | 32 | 184 |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@@ -1,23 +0,0 @@
# Diff Summary
Date : 2025-03-17 16:02:14
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, -14 codes, -4 comments, -3 blanks, all -21 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 2 | -14 | -4 | -3 | -21 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 2 | -14 | -4 | -3 | -21 |
| components | 1 | -130 | -40 | -35 | -205 |
| hooks | 1 | 116 | 36 | 32 | 184 |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@@ -1,28 +0,0 @@
Date : 2025-03-17 16:02:14
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 2 files, -14 codes, -4 comments, -3 blanks, all -21 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 2 | -14 | -4 | -3 | -21 |
+----------------+------------+------------+------------+------------+------------+
Directories
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 2 | -14 | -4 | -3 | -21 |
| components | 1 | -130 | -40 | -35 | -205 |
| hooks | 1 | 116 | 36 | 32 | 184 |
+-------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx | TypeScript JSX | -130 | -40 | -35 | -205 |
| /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx | TypeScript JSX | 116 | 36 | 32 | 184 |
| Total | | -14 | -4 | -3 | -21 |
+-------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

File diff suppressed because one or more lines are too long

View File

@@ -1,31 +0,0 @@
# Summary
Date : 2025-03-17 16:02:14
Directory /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6551 codes, 1023 comments, 1050 blanks, all 8624 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| TypeScript JSX | 20 | 6,175 | 910 | 967 | 8,052 |
| 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 |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 28 | 6,551 | 1,023 | 1,050 | 8,624 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 13 | 3,874 | 475 | 501 | 4,850 |
| components (Files) | 8 | 2,641 | 317 | 343 | 3,301 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,281 | 429 | 464 | 3,174 |
| 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

@@ -1,62 +0,0 @@
Date : 2025-03-17 16:02:14
Directory : /Users/matt/Dev/inventory/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew
Total : 28 files, 6551 codes, 1023 comments, 1050 blanks, all 8624 lines
Languages
+----------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------------+------------+------------+------------+------------+------------+
| TypeScript JSX | 20 | 6,175 | 910 | 967 | 8,052 |
| 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 |
+------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 28 | 6,551 | 1,023 | 1,050 | 8,624 |
| . (Files) | 3 | 63 | 6 | 22 | 91 |
| components | 13 | 3,874 | 475 | 501 | 4,850 |
| components (Files) | 8 | 2,641 | 317 | 343 | 3,301 |
| components/cells | 5 | 1,233 | 158 | 158 | 1,549 |
| hooks | 6 | 2,281 | 429 | 464 | 3,174 |
| 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/SaveTemplateDialog.tsx | TypeScript JSX | 83 | 0 | 4 | 87 |
| /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/TemplateManager.tsx | TypeScript JSX | 193 | 4 | 15 | 212 |
| /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 | 971 | 194 | 178 | 1,343 |
| /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/useFilters.tsx | TypeScript JSX | 89 | 12 | 16 | 117 |
| /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,551 | 1,023 | 1,050 | 8,624 |
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+

4
.gitignore vendored
View File

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

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.

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

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

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

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

View File

@@ -223,7 +223,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
onBack();
}
}}
onNext={(validatedData) => {
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,

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 [] = useState<string | null>(null);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
@@ -232,7 +231,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
aria-expanded={open}
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
>
<span className="truncate overflow-hidden mr-2">{getDisplayText()}</span>
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button>
</PopoverTrigger>

View File

@@ -2,18 +2,19 @@ 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: (value: RowSelectionState) => void
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
filters: any
templates: any[]
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string) => string
getTemplateDisplayText: (templateId: string | null) => string
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
@@ -27,6 +28,7 @@ interface UpcValidationTableAdapterProps<T extends string> {
validatingRows: Set<number>
getItemNumber: (rowIndex: number) => string | undefined
}
itemNumbers?: Map<number, string>
}
/**
@@ -55,63 +57,79 @@ function UpcValidationTableAdapter<T extends string>({
rowSublines,
isLoadingLines,
isLoadingSublines,
upcValidation
upcValidation,
itemNumbers
}: UpcValidationTableAdapterProps<T>) {
// Prepare the validation table with UPC data
const AdaptedTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
// Create validatingCells set from validating rows, but only for item_number fields
// This ensures only the item_number column shows loading state during UPC validation
const combinedValidatingCells = new Set<string>();
// 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
combinedValidatingCells.add(`${rowIndex}-item_number`);
combined.add(`${rowIndex}-item_number`);
});
// Add any other validating cells from state
externalValidatingCells.forEach(cellKey => {
combinedValidatingCells.add(cellKey);
combined.add(cellKey);
});
// Convert the Map to the expected format for the ValidationTable
// Create a new Map from the item numbers to ensure proper typing
const itemNumbersMap = new Map<number, string>();
// Merge the item numbers with the data for display purposes only
const enhancedData = props.data.map((row: any, index: number) => {
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) {
// Add to our map for proper prop passing
itemNumbersMap.set(index, itemNumber);
return {
...row,
item_number: 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 row;
});
return (
<ValidationTable
{...props}
data={enhancedData}
validatingCells={combinedValidatingCells}
itemNumbers={itemNumbersMap}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
/>
);
}), [upcValidation.validatingRows, upcValidation.getItemNumber, isLoadingTemplates, copyDown, externalValidatingCells, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines]);
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 (
<AdaptedTable
<ValidationTable
data={data}
fields={fields}
rowSelection={rowSelection}
@@ -124,11 +142,11 @@ function UpcValidationTableAdapter<T extends string>({
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={new Set()}
itemNumbers={new Map()}
validatingCells={combinedValidatingCells}
itemNumbers={consolidatedItemNumbers}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
upcValidationResults={new Map()}
upcValidationResults={upcValidationResults}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Field, ErrorType } from '../../../types'
import { Loader2, AlertCircle, ArrowDown, X } from 'lucide-react'
import { AlertCircle, ArrowDown, X } from 'lucide-react'
import {
Tooltip,
TooltipContent,
@@ -11,6 +11,7 @@ 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<{
@@ -292,8 +293,18 @@ const ValidationCell = React.memo(({
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
// Display value prioritizes itemNumber if available (for item_number fields)
const displayValue = fieldKey === 'item_number' && itemNumber ? itemNumber : value;
// 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 {
@@ -351,12 +362,8 @@ const ValidationCell = React.memo(({
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box' as const,
cursor: isInTargetRow ? 'pointer' : undefined,
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
}), [width, isInTargetRow, isSourceCell, isSelectedTarget, isTargetRowHovered]);
cursor: isInTargetRow ? 'pointer' : undefined
}), [width, isInTargetRow]);
// Memoize the cell class name to prevent re-calculating on every render
const cellClassName = React.useMemo(() => {
@@ -431,12 +438,21 @@ const ValidationCell = React.memo(({
</div>
)}
{isLoading ? (
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}>
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span>Loading...</span>
<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' : ''}`}>
<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}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import { useValidationState } from '../hooks/useValidationState'
import { Props } from '../hooks/validationTypes'
import { Button } from '@/components/ui/button'
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
import { toast } from 'sonner'
@@ -9,7 +10,6 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs'
import { Fields } from '../../../types'
import { ErrorType, ValidationError, ErrorSources } from '../../../types'
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
import { TemplateForm } from '@/components/templates/TemplateForm'
import axios from 'axios'
@@ -17,8 +17,7 @@ import { RowSelectionState } from '@tanstack/react-table'
import { useUpcValidation } from '../hooks/useUpcValidation'
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
import { clearAllUniquenessCaches } from '../hooks/useValidation'
import { Skeleton } from '@/components/ui/skeleton'
/**
* ValidationContainer component - the main wrapper for the validation step
*
@@ -49,7 +48,6 @@ const ValidationContainer = <T extends string>({
validationErrors,
rowSelection,
setRowSelection,
updateRow,
templates,
selectedTemplateId,
applyTemplate,
@@ -60,7 +58,10 @@ const ValidationContainer = <T extends string>({
loadTemplates,
setData,
fields,
isLoadingTemplates } = validationState
isLoadingTemplates,
validatingCells,
setValidatingCells
} = validationState
// Use product lines fetching hook
const {
@@ -72,9 +73,6 @@ const ValidationContainer = <T extends string>({
fetchSublines
} = useProductLinesFetching(data);
// Add state for tracking cells in loading state
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
// Use UPC validation hook
const upcValidation = useUpcValidation(data, setData);
@@ -144,7 +142,6 @@ const ValidationContainer = <T extends string>({
}, []);
// Add a ref to track the last validation time
const lastValidationTime = useRef(0);
// Trigger revalidation only for specifically marked fields
useEffect(() => {
@@ -301,82 +298,8 @@ const ValidationContainer = <T extends string>({
}, [prepareRowDataForTemplateForm, fetchFieldOptions]);
// Create a function to validate uniqueness if validateUniqueItemNumbers is not available
const validateUniqueValues = useCallback(() => {
// Check if validateUniqueItemNumbers exists on validationState using safer method
if ('validateUniqueItemNumbers' in validationState &&
typeof (validationState as any).validateUniqueItemNumbers === 'function') {
(validationState as any).validateUniqueItemNumbers();
} else {
// Otherwise fall back to revalidating all rows
validationState.revalidateRows(Array.from(Array(data.length).keys()));
}
}, [validationState, data.length]);
// Apply item numbers to data and trigger revalidation for uniqueness
const applyItemNumbersAndValidate = useCallback(() => {
// Clear uniqueness validation caches to ensure fresh validation
clearAllUniquenessCaches();
upcValidation.applyItemNumbersToData((updatedRowIds) => {
console.log(`Revalidating item numbers for ${updatedRowIds.length} rows`);
// Force clearing all uniqueness errors for item_number and upc fields first
const newValidationErrors = new Map(validationErrors);
// Clear uniqueness errors for all rows that had their item numbers updated
updatedRowIds.forEach(rowIndex => {
const rowErrors = newValidationErrors.get(rowIndex);
if (rowErrors) {
// Create a copy of row errors without uniqueness errors for item_number/upc
const filteredErrors: Record<string, ValidationError[]> = { ...rowErrors };
let hasChanges = false;
// Clear item_number errors if they exist and are uniqueness errors
if (filteredErrors.item_number &&
filteredErrors.item_number.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.item_number;
hasChanges = true;
}
// Also clear upc/barcode errors if they exist and are uniqueness errors
if (filteredErrors.upc &&
filteredErrors.upc.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.upc;
hasChanges = true;
}
if (filteredErrors.barcode &&
filteredErrors.barcode.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.barcode;
hasChanges = true;
}
// Update the map or remove the row entry if no errors remain
if (hasChanges) {
if (Object.keys(filteredErrors).length > 0) {
newValidationErrors.set(rowIndex, filteredErrors);
} else {
newValidationErrors.delete(rowIndex);
}
}
}
});
// Call the revalidateRows function directly with affected rows
validationState.revalidateRows(updatedRowIds);
// Immediately run full uniqueness validation across all rows if available
// This is crucial to properly identify new uniqueness issues
setTimeout(() => {
validateUniqueValues();
}, 0);
// Mark all updated rows for revalidation
updatedRowIds.forEach(rowIndex => {
markRowForRevalidation(rowIndex, 'item_number');
});
});
}, [upcValidation.applyItemNumbersToData, markRowForRevalidation, clearAllUniquenessCaches, validationErrors, validationState.revalidateRows, validateUniqueValues]);
// Handle next button click - memoized
const handleNext = useCallback(() => {
@@ -472,28 +395,22 @@ const ValidationContainer = <T extends string>({
// This function is defined for potential future use but not currently used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleRowSelectionChange = useCallback((newSelection: RowSelectionState) => {
setRowSelection(newSelection);
}, [setRowSelection]);
const handleRowSelectionChange = useCallback(
(value: React.SetStateAction<RowSelectionState>) => {
setRowSelection(value);
},
[setRowSelection]
);
// Add scroll container ref at the container level
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
const isScrolling = useRef(false);
// Track if we're currently validating a UPC
const isValidatingUpcRef = useRef(false);
// Track last UPC update to prevent conflicting changes
const lastUpcUpdate = useRef({
rowIndex: -1,
supplier: "",
upc: ""
});
// Add these ref declarations here, at component level
const lastCompanyFetchTime = useRef<Record<string, number>>({});
const lastLineFetchTime = useRef<Record<string, number>>({});
// Memoize scroll handlers - simplified to avoid performance issues
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
@@ -1042,6 +959,7 @@ const ValidationContainer = <T extends string>({
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidation={upcValidation}
itemNumbers={upcValidation.itemNumbers}
/>
);
}, [
@@ -1150,9 +1068,9 @@ const ValidationContainer = <T extends string>({
{/* Selection Action Bar - only shown when items are selected */}
{Object.keys(rowSelection).length > 0 && (
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-5 duration-300">
<div className="bg-card shadow-xl rounded-lg border border-muted px-4 py-3 flex items-center gap-3">
<div className="bg-card shadow-xl rounded-2xl border border-gray-200 px-4 py-3 flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="mr-2 bg-muted items-center flex text-primary pl-2 pr-7 h-[32px] flex-shrink-0 rounded-md text-xs font-medium border border-primary">
<div className="mr-3 bg-muted shadow-xs items-center flex text-primary pl-2 pr-7 h-8 flex-shrink-0 rounded-md text-xs font-medium border border-muted">
{Object.keys(rowSelection).length} selected
</div>
@@ -1167,11 +1085,10 @@ const ValidationContainer = <T extends string>({
</Button>
</div>
<div className="flex items-center">
<div className="flex items-center ml-2 mr-1 shadow-xs">
{isLoadingTemplates ? (
<Button variant="outline" className="w-[220px] justify-between" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading templates...
<Button variant="outline" className="w-[250px] justify-between h-8" disabled>
<Skeleton className="h-4 w-full" />
</Button>
) : templates && templates.length > 0 ? (
<SearchableTemplateSelect
@@ -1183,11 +1100,11 @@ const ValidationContainer = <T extends string>({
}
}}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Apply template to selected"
triggerClassName="w-[220px]"
placeholder="Apply template to selected rows"
triggerClassName="w-[250px] text-xs h-8"
/>
) : (
<Button variant="outline" className="w-full justify-between" disabled>
<Button variant="outline" className="w-full justify-between text-xs" disabled>
No templates available
</Button>
)}
@@ -1198,14 +1115,16 @@ const ValidationContainer = <T extends string>({
variant="outline"
size="sm"
onClick={openTemplateForm}
className="h-8 mr-1 shadow-xs"
>
Save as Template
Save as template
</Button>
)}
<Button
variant={isFromScratch ? "destructive" : "outline"}
variant={"destructive"}
size="sm"
className="h-8 shadow-xs"
onClick={() => {
console.log('Delete/Discard button clicked');
console.log('Row selection state:', rowSelection);

View File

@@ -7,7 +7,7 @@ import {
ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/useValidationState'
import { RowData, Template } from '../hooks/validationTypes'
import ValidationCell, { CopyDownContext } from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
@@ -15,7 +15,7 @@ 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 { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
@@ -67,10 +67,9 @@ const MemoizedTemplateSelect = React.memo(({
}) => {
if (isLoading) {
return (
<Button variant="outline" className="w-full justify-between overflow-hidden" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin flex-none" />
<span className="truncate overflow-hidden">Loading...</span>
</Button>
<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>
);
}
@@ -139,11 +138,15 @@ const MemoizedCell = React.memo(({
/>
);
}, (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;
const itemNumberEqual = prev.itemNumber === next.itemNumber;
// Shallow equality check for errors array
const errorsEqual = prev.errors === next.errors || (
@@ -162,7 +165,7 @@ const MemoizedCell = React.memo(({
);
// Skip checking for props that rarely change
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual && itemNumberEqual;
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual;
});
MemoizedCell.displayName = 'MemoizedCell';
@@ -185,7 +188,10 @@ const ValidationTable = <T extends string>({
rowProductLines = {},
rowSublines = {},
isLoadingLines = {},
isLoadingSublines = {}
isLoadingSublines = {},
isValidatingUpc,
validatingUpcRows = [],
upcValidationResults
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
@@ -254,7 +260,7 @@ const ValidationTable = <T extends string>({
</div>
),
cell: ({ row }) => (
<div className="flex h-[40px] items-center justify-center">
<div className="flex items-center justify-center py-9">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => handleRowSelect(!!value, row)}
@@ -328,6 +334,34 @@ const ValidationTable = <T extends string>({
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
@@ -368,6 +402,10 @@ const ValidationTable = <T extends string>({
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;
@@ -395,26 +433,40 @@ const ValidationTable = <T extends string>({
disabled: false
};
// Debug logging
console.log(`Field ${fieldKey} in ValidationTable (after deep clone):`, {
originalField: field,
modifiedField: fieldWithType,
options,
hasOptions: options && options.length > 0,
disabled: fieldWithType.disabled
});
}
// 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={row.original[field.key as keyof typeof row.original]}
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={itemNumbers.get(row.index)}
itemNumber={itemNumber}
width={fieldWidth}
rowIndex={row.index}
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
@@ -424,7 +476,9 @@ const ValidationTable = <T extends string>({
}
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache, data.length, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines]);
[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]);
@@ -590,7 +644,7 @@ const ValidationTable = <T extends string>({
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
row.getIsSelected() ? "!bg-blue-50/50" : "",
hasErrors ? "bg-red-50/40" : "",
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useDeferredValue, useTransition, useRef, useEffect, useMemo } from 'react'
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'
@@ -46,7 +46,6 @@ const InputCell = <T extends string>({
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [isPending, startTransition] = useTransition();
const deferredEditValue = useDeferredValue(editValue);
// Use a ref to track if we need to process the value
const needsProcessingRef = useRef(false);

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { getApiUrl, RowData } from './useValidationState';
import { Fields, InfoWithSource, ErrorSources, ErrorType } from '../../../types';
import { getApiUrl, RowData } from './validationTypes';
import { Fields } from '../../../types';
import { Meta } from '../types';
import { addErrorsAndRunHooks } from '../utils/dataMutations';
import * as Diff from 'diff';

View File

@@ -0,0 +1,161 @@
import { useCallback } from 'react';
import type { Field, Fields, RowHook } from '../../../types';
import type { Meta } from '../types';
import { ErrorType, ValidationError } from '../../../types';
import { RowData, isEmpty } from './validationTypes';
// Create a cache for validation results to avoid repeated validation of the same data
const validationResultCache = new Map();
// Add a function to clear cache for a specific field value
export const clearValidationCacheForField = (fieldKey: string) => {
// Look for entries that match this field key
validationResultCache.forEach((_, key) => {
if (key.startsWith(`${fieldKey}-`)) {
validationResultCache.delete(key);
}
});
};
// Add a special function to clear all uniqueness validation caches
export const clearAllUniquenessCaches = () => {
// Clear cache for common unique fields
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
clearValidationCacheForField(fieldKey);
});
// Also clear any cache entries that might involve uniqueness validation
validationResultCache.forEach((_, key) => {
if (key.includes('unique')) {
validationResultCache.delete(key);
}
});
};
export const useFieldValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>
) => {
// Validate a single field
const validateField = useCallback((
value: any,
field: Field<T>
): ValidationError[] => {
const errors: ValidationError[] = [];
if (!field.validations) return errors;
// Create a cache key using field key, value, and validation rules
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
// Check cache first to avoid redundant validation
if (validationResultCache.has(cacheKey)) {
return validationResultCache.get(cacheKey) || [];
}
field.validations.forEach(validation => {
switch (validation.rule) {
case 'required':
// Use the shared isEmpty function
if (isEmpty(value)) {
errors.push({
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
type: ErrorType.Required
});
}
break;
case 'unique':
// Unique validation happens at table level, not here
break;
case 'regex':
if (value !== undefined && value !== null && value !== '') {
try {
const regex = new RegExp(validation.value, validation.flags);
if (!regex.test(String(value))) {
errors.push({
message: validation.errorMessage,
level: validation.level || 'error',
type: ErrorType.Regex
});
}
} catch (error) {
console.error('Invalid regex in validation:', error);
}
}
break;
}
});
// Store results in cache to speed up future validations
validationResultCache.set(cacheKey, errors);
return errors;
}, []);
// Validate a single row
const validateRow = useCallback(async (
row: RowData<T>,
rowIndex: number,
allRows: RowData<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {};
fields.forEach(field => {
const value = row[String(field.key) as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[String(field.key)] = errors;
}
});
// Special validation for supplier and company fields - only apply if the field exists in fields
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
type: ErrorType.Required
}];
}
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
type: ErrorType.Required
}];
}
// Run row hook if provided
let rowHookResult: Meta = {
__index: row.__index || String(rowIndex)
};
if (rowHook) {
try {
// Call the row hook and extract only the __index property
const result = await rowHook(row, rowIndex, allRows);
rowHookResult.__index = result.__index || rowHookResult.__index;
} catch (error) {
console.error('Error in row hook:', error);
}
}
// We no longer need to merge errors since we're not storing them in the row data
// The calling code should handle storing errors in the validationErrors Map
return {
__index: row.__index || String(rowIndex)
};
}, [fields, validateField, rowHook]);
return {
validateField,
validateRow,
clearValidationCacheForField,
clearAllUniquenessCaches
};
};

View File

@@ -0,0 +1,110 @@
import { useState, useCallback, useMemo } from 'react';
import { FilterState, RowData } from './validationTypes';
import type { Fields } from '../../../types';
import { ValidationError } from '../../../types';
export const useFilterManagement = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
validationErrors: Map<number, Record<string, ValidationError[]>>
) => {
// Filter state
const [filters, setFilters] = useState<FilterState>({
searchText: "",
showErrorsOnly: false,
filterField: null,
filterValue: null,
});
// Filter data based on current filter state
const filteredData = useMemo(() => {
return data.filter((row, index) => {
// Filter by search text
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matchesSearch = fields.some((field) => {
const value = row[field.key as keyof typeof row];
if (value === undefined || value === null) return false;
return String(value).toLowerCase().includes(searchLower);
});
if (!matchesSearch) return false;
}
// Filter by errors
if (filters.showErrorsOnly) {
const hasErrors =
validationErrors.has(index) &&
Object.keys(validationErrors.get(index) || {}).length > 0;
if (!hasErrors) return false;
}
// Filter by field value
if (filters.filterField && filters.filterValue) {
const fieldValue = row[filters.filterField as keyof typeof row];
if (fieldValue === undefined) return false;
const valueStr = String(fieldValue).toLowerCase();
const filterStr = filters.filterValue.toLowerCase();
if (!valueStr.includes(filterStr)) return false;
}
return true;
});
}, [data, fields, filters, validationErrors]);
// Get filter fields
const filterFields = useMemo(() => {
return fields.map((field) => ({
key: String(field.key),
label: field.label,
}));
}, [fields]);
// Get filter values for the selected field
const filterValues = useMemo(() => {
if (!filters.filterField) return [];
// Get unique values for the selected field
const uniqueValues = new Set<string>();
data.forEach((row) => {
const value = row[filters.filterField as keyof typeof row];
if (value !== undefined && value !== null) {
uniqueValues.add(String(value));
}
});
return Array.from(uniqueValues).map((value) => ({
value,
label: value,
}));
}, [data, filters.filterField]);
// Update filters
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
setFilters((prev) => ({
...prev,
...newFilters,
}));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
setFilters({
searchText: "",
showErrorsOnly: false,
filterField: null,
filterValue: null,
});
}, []);
return {
filters,
filteredData,
filterFields,
filterValues,
updateFilters,
resetFilters
};
};

View File

@@ -0,0 +1,366 @@
import { useCallback } from 'react';
import { RowData } from './validationTypes';
import type { Field, Fields } from '../../../types';
import { ErrorType, ValidationError } from '../../../types';
export const useRowOperations = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
) => {
// Helper function to validate a field value
const fieldValidationHelper = useCallback(
(rowIndex: number, specificField?: string) => {
// Skip validation if row doesn't exist
if (rowIndex < 0 || rowIndex >= data.length) return;
// Get the row data
const row = data[rowIndex];
// If validating a specific field, only check that field
if (specificField) {
const field = fields.find((f) => String(f.key) === specificField);
if (field) {
const value = row[specificField as keyof typeof row];
// Use state setter instead of direct mutation
setValidationErrors((prev) => {
const newErrors = new Map(prev);
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
// Quick check for required fields - this prevents flashing errors
const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0);
// For non-empty values, remove required errors immediately
if (isRequired && !isEmpty && existingErrors[specificField]) {
const nonRequiredErrors = existingErrors[specificField].filter(
(e) => e.type !== ErrorType.Required
);
if (nonRequiredErrors.length === 0) {
// If no other errors, remove the field entirely from errors
delete existingErrors[specificField];
} else {
existingErrors[specificField] = nonRequiredErrors;
}
}
// Run full validation for the field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update validation errors for this field
if (errors.length > 0) {
existingErrors[specificField] = errors;
} else {
delete existingErrors[specificField];
}
// Update validation errors map
if (Object.keys(existingErrors).length > 0) {
newErrors.set(rowIndex, existingErrors);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
}
} else {
// Validate all fields in the row
setValidationErrors((prev) => {
const newErrors = new Map(prev);
const rowErrors: Record<string, ValidationError[]> = {};
fields.forEach((field) => {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
});
// Update validation errors map
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
}
},
[data, fields, validateFieldFromHook, setValidationErrors]
);
// Use validateRow as an alias for fieldValidationHelper for compatibility
const validateRow = fieldValidationHelper;
// Modified updateRow function that properly handles field-specific validation
const updateRow = useCallback(
(rowIndex: number, key: T, value: any) => {
// Process value before updating data
let processedValue = value;
// Strip dollar signs from price fields
if (
(key === "msrp" || key === "cost_each") &&
typeof value === "string"
) {
processedValue = value.replace(/[$,]/g, "");
// Also ensure it's a valid number
const numValue = parseFloat(processedValue);
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2);
}
}
// Find the row data first
const rowData = data[rowIndex];
if (!rowData) {
console.error(`No row data found for index ${rowIndex}`);
return;
}
// Create a copy of the row to avoid mutation
const updatedRow = { ...rowData, [key]: processedValue };
// Update the data immediately - this sets the value
setData((prevData) => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = updatedRow;
}
return newData;
});
// Find the field definition
const field = fields.find((f) => String(f.key) === key);
if (!field) return;
// CRITICAL FIX: Combine both validation operations into a single state update
// to prevent intermediate rendering that causes error icon flashing
setValidationErrors((prev) => {
const newMap = new Map(prev);
const existingErrors = newMap.get(rowIndex) || {};
const newRowErrors = { ...existingErrors };
// Check for required field first
const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
processedValue === undefined ||
processedValue === null ||
processedValue === "" ||
(Array.isArray(processedValue) && processedValue.length === 0) ||
(typeof processedValue === "object" &&
processedValue !== null &&
Object.keys(processedValue).length === 0);
// For required fields with values, remove required errors
if (isRequired && !isEmpty && newRowErrors[key as string]) {
const hasRequiredError = newRowErrors[key as string].some(
(e) => e.type === ErrorType.Required
);
if (hasRequiredError) {
// Remove required errors but keep other types of errors
const nonRequiredErrors = newRowErrors[key as string].filter(
(e) => e.type !== ErrorType.Required
);
if (nonRequiredErrors.length === 0) {
// If no other errors, delete the field's errors entirely
delete newRowErrors[key as string];
} else {
// Otherwise keep non-required errors
newRowErrors[key as string] = nonRequiredErrors;
}
}
}
// Now run full validation for the field (except for required which we already handled)
const errors = validateFieldFromHook(
processedValue,
field as unknown as Field<T>
).filter((e) => e.type !== ErrorType.Required || isEmpty);
// Update with new validation results
if (errors.length > 0) {
newRowErrors[key as string] = errors;
} else if (!newRowErrors[key as string]) {
// If no errors found and no existing errors, ensure field is removed from errors
delete newRowErrors[key as string];
}
// Update the map
if (Object.keys(newRowErrors).length > 0) {
newMap.set(rowIndex, newRowErrors);
} else {
newMap.delete(rowIndex);
}
return newMap;
});
// Handle simple secondary effects here
setTimeout(() => {
// Use __index to find the actual row in the full data array
const rowId = rowData.__index;
// Handle company change - clear line/subline
if (key === "company" && processedValue) {
// Clear any existing line/subline values
setData((prevData) => {
const newData = [...prevData];
const idx = newData.findIndex((item) => item.__index === rowId);
if (idx >= 0) {
newData[idx] = {
...newData[idx],
line: undefined,
subline: undefined,
};
}
return newData;
});
}
// Handle line change - clear subline
if (key === "line" && processedValue) {
// Clear any existing subline value
setData((prevData) => {
const newData = [...prevData];
const idx = newData.findIndex((item) => item.__index === rowId);
if (idx >= 0) {
newData[idx] = {
...newData[idx],
subline: undefined,
};
}
return newData;
});
}
}, 50);
},
[data, fields, validateFieldFromHook, setData, setValidationErrors]
);
// Improved revalidateRows function
const revalidateRows = useCallback(
async (
rowIndexes: number[],
updatedFields?: { [rowIndex: number]: string[] }
) => {
// Process all specified rows using a single state update to avoid race conditions
setValidationErrors((prev) => {
const newErrors = new Map(prev);
// Process each row
for (const rowIndex of rowIndexes) {
if (rowIndex < 0 || rowIndex >= data.length) continue;
const row = data[rowIndex];
if (!row) continue;
// If we have specific fields to update for this row
const fieldsToValidate = updatedFields?.[rowIndex] || [];
if (fieldsToValidate.length > 0) {
// Get existing errors for this row
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
// Validate each specified field
for (const fieldKey of fieldsToValidate) {
const field = fields.find((f) => String(f.key) === fieldKey);
if (!field) continue;
const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) {
existingRowErrors[fieldKey] = errors;
} else {
delete existingRowErrors[fieldKey];
}
}
// Update the row's errors
if (Object.keys(existingRowErrors).length > 0) {
newErrors.set(rowIndex, existingRowErrors);
} else {
newErrors.delete(rowIndex);
}
} else {
// No specific fields provided - validate the entire row
const rowErrors: Record<string, ValidationError[]> = {};
// Validate all fields in the row
for (const field of fields) {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
}
// Update the row's errors
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
}
}
return newErrors;
});
},
[data, fields, validateFieldFromHook]
);
// Copy a cell value to all cells below it in the same column
const copyDown = useCallback(
(rowIndex: number, key: T) => {
// Get the source value to copy
const sourceValue = data[rowIndex][key];
// Update all rows below with the same value using the existing updateRow function
// This ensures all validation logic runs consistently
for (let i = rowIndex + 1; i < data.length; i++) {
// Just use updateRow which will handle validation with proper timing
updateRow(i, key, sourceValue);
}
},
[data, updateRow]
);
return {
validateRow,
updateRow,
revalidateRows,
copyDown
};
};

View File

@@ -0,0 +1,516 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { Template, RowData, TemplateState, getApiUrl } from './validationTypes';
import { RowSelectionState } from '@tanstack/react-table';
import { ValidationError } from '../../../types';
export const useTemplateManagement = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
rowSelection: RowSelectionState,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
validateRow: (rowIndex: number, specificField?: string) => void,
isApplyingTemplateRef: React.MutableRefObject<boolean>,
upcValidation: {
validateUpc: (rowIndex: number, supplierId: string, upcValue: string) => Promise<{success: boolean, itemNumber?: string}>,
applyItemNumbersToData: (onApplied?: (updatedRowIds: number[]) => void) => void
},
setValidatingCells?: React.Dispatch<React.SetStateAction<Set<string>>>
) => {
// Template state
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [templateState, setTemplateState] = useState<TemplateState>({
selectedTemplateId: null,
showSaveTemplateDialog: false,
newTemplateName: "",
newTemplateType: "",
});
// Load templates
const loadTemplates = useCallback(async () => {
try {
setIsLoadingTemplates(true);
console.log("Fetching templates from:", `${getApiUrl()}/templates`);
const response = await fetch(`${getApiUrl()}/templates`);
if (!response.ok) throw new Error("Failed to fetch templates");
const templateData = await response.json();
const validTemplates = templateData.filter(
(t: any) =>
t && typeof t === "object" && t.id && t.company && t.product_type
);
setTemplates(validTemplates);
} catch (error) {
console.error("Error fetching templates:", error);
toast.error("Failed to load templates");
} finally {
setIsLoadingTemplates(false);
}
}, []);
// Refresh templates
const refreshTemplates = useCallback(() => {
loadTemplates();
}, [loadTemplates]);
// Save a new template
const saveTemplate = useCallback(
async (name: string, type: string) => {
try {
// Get selected rows
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
const selectedRow = data[selectedRowIndex];
if (!selectedRow) {
toast.error("Please select a row to create a template");
return;
}
// Extract data for template, removing metadata fields
const {
__index,
__template,
__original,
__corrected,
__changes,
...templateData
} = selectedRow as any;
// Clean numeric values (remove $ from price fields)
const cleanedData: Record<string, any> = {};
// Process each key-value pair
Object.entries(templateData).forEach(([key, value]) => {
// Handle numeric values with dollar signs
if (typeof value === "string" && value.includes("$")) {
cleanedData[key] = value.replace(/[$,\s]/g, "").trim();
}
// Handle array values (like categories or ship_restrictions)
else if (Array.isArray(value)) {
cleanedData[key] = value;
}
// Handle other values
else {
cleanedData[key] = value;
}
});
// Send the template to the API
const response = await fetch(`${getApiUrl()}/templates`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...cleanedData,
company: name,
product_type: type,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.error || errorData.details || "Failed to save template"
);
}
// Get the new template from the response
const newTemplate = await response.json();
// Update the templates list with the new template
setTemplates((prev) => [...prev, newTemplate]);
// Update the row to show it's using this template
setData((prev) => {
const newData = [...prev];
if (newData[selectedRowIndex]) {
newData[selectedRowIndex] = {
...newData[selectedRowIndex],
__template: newTemplate.id.toString(),
};
}
return newData;
});
toast.success(`Template "${name}" saved successfully`);
// Reset dialog state
setTemplateState((prev) => ({
...prev,
showSaveTemplateDialog: false,
newTemplateName: "",
newTemplateType: "",
}));
} catch (error) {
console.error("Error saving template:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save template"
);
}
},
[data, rowSelection, setData]
);
// Apply template to rows - optimized version
const applyTemplate = useCallback(
(templateId: string, rowIndexes: number[]) => {
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) {
toast.error("Template not found");
return;
}
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
// Validate row indexes
const validRowIndexes = rowIndexes.filter(
(index) => index >= 0 && index < data.length && Number.isInteger(index)
);
if (validRowIndexes.length === 0) {
toast.error("No valid rows to update");
console.error("Invalid row indexes:", rowIndexes);
return;
}
// Set the template application flag
isApplyingTemplateRef.current = true;
// Save scroll position
const scrollPosition = {
left: window.scrollX,
top: window.scrollY,
};
// Create a copy of data and process all rows at once to minimize state updates
const newData = [...data];
const batchErrors = new Map<number, Record<string, ValidationError[]>>();
const batchStatuses = new Map<
number,
"pending" | "validating" | "validated" | "error"
>();
// Extract template fields once outside the loop
const templateFields = Object.entries(template).filter(
([key]) =>
![
"id",
"__meta",
"__template",
"__original",
"__corrected",
"__changes",
].includes(key)
);
// Apply template to each valid row
validRowIndexes.forEach((index) => {
// Create a new row with template values
const originalRow = newData[index];
const updatedRow = { ...originalRow } as Record<string, any>;
// Apply template fields (excluding metadata fields)
for (const [key, value] of templateFields) {
updatedRow[key] = value;
}
// Mark the row as using this template
updatedRow.__template = templateId;
// Update the row in the data array
newData[index] = updatedRow as RowData<T>;
// Clear validation errors and mark as validated
batchErrors.set(index, {});
batchStatuses.set(index, "validated");
});
// Check which rows need UPC validation
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
const row = newData[rowIndex];
return row && row.upc && row.supplier;
});
// Perform a single update for all rows
setData(newData);
// Update all validation errors and statuses at once
setValidationErrors((prev) => {
const newErrors = new Map(prev);
for (const [rowIndex, errors] of batchErrors.entries()) {
newErrors.set(rowIndex, errors);
}
return newErrors;
});
setRowValidationStatus((prev) => {
const newStatus = new Map(prev);
for (const [rowIndex, status] of batchStatuses.entries()) {
newStatus.set(rowIndex, status);
}
return newStatus;
});
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(scrollPosition.left, scrollPosition.top);
});
// Show success toast
if (validRowIndexes.length === 1) {
toast.success("Template applied");
} else if (validRowIndexes.length > 1) {
toast.success(`Template applied to ${validRowIndexes.length} rows`);
}
// Reset template application flag to allow validation
isApplyingTemplateRef.current = false;
// If there are rows with both UPC and supplier, validate them
if (upcValidationRows.length > 0) {
console.log(`Validating UPCs for ${upcValidationRows.length} rows after template application`);
// Process each row sequentially - this mimics the exact manual edit behavior
const processNextValidation = (index = 0) => {
if (index >= upcValidationRows.length) {
return; // All rows processed
}
const rowIndex = upcValidationRows[index];
const row = newData[rowIndex];
if (row && row.supplier && row.upc) {
// The EXACT implementation from handleUpdateRow when supplier is edited manually:
// 1. Mark the item_number cell as being validated - THIS IS CRITICAL FOR LOADING STATE
const cellKey = `${rowIndex}-item_number`;
// Clear validation errors for this field
setValidationErrors(prev => {
const newErrors = new Map(prev);
if (newErrors.has(rowIndex)) {
const rowErrors = { ...newErrors.get(rowIndex) };
if (rowErrors.item_number) {
delete rowErrors.item_number;
}
newErrors.set(rowIndex, rowErrors);
}
return newErrors;
});
// Set loading state - using setValidatingCells from props
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
}
// Validate UPC for this row
upcValidation.validateUpc(rowIndex, row.supplier.toString(), row.upc.toString())
.then(result => {
if (result.success && result.itemNumber) {
// CRITICAL FIX: Directly update data with the item number to ensure immediate UI update
setData(prevData => {
const newData = [...prevData];
// Update this specific row with the item number
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: result.itemNumber
};
}
return newData;
});
// Also trigger other relevant updates
upcValidation.applyItemNumbersToData();
// Mark for revalidation after item numbers are updated
setTimeout(() => {
// Validate the row EXACTLY like in manual edit
validateRow(rowIndex, 'item_number');
// CRITICAL FIX: Make one final check to ensure data is correct
setTimeout(() => {
// Get the current item number from the data
const currentItemNumber = (() => {
try {
const dataAtThisPointInTime = data[rowIndex];
return dataAtThisPointInTime?.item_number;
} catch (e) {
return undefined;
}
})();
// If the data is wrong at this point, fix it directly
if (currentItemNumber !== result.itemNumber) {
// Directly update the data to fix the issue
setData(dataRightNow => {
const fixedData = [...dataRightNow];
if (rowIndex >= 0 && rowIndex < fixedData.length) {
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
}
return fixedData;
});
// Then do a force update after a brief delay
setTimeout(() => {
setData(currentData => {
// Critical fix: ensure the item number is correct
if (currentData[rowIndex] && currentData[rowIndex].item_number !== result.itemNumber) {
// Create a completely new array with the correct item number
const fixedData = [...currentData];
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
return fixedData;
}
// Create a completely new array
return [...currentData];
});
}, 20);
} else {
// Item number is already correct, just do the force update
setData(currentData => {
// Create a completely new array
return [...currentData];
});
}
}, 50);
// Clear loading state
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row after validation is complete
setTimeout(() => processNextValidation(index + 1), 100);
}, 100);
} else {
// Clear loading state on failure
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row if validation fails
setTimeout(() => processNextValidation(index + 1), 100);
}
})
.catch(err => {
console.error(`Error validating UPC for row ${rowIndex}:`, err);
// Clear loading state on error
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row despite error
setTimeout(() => processNextValidation(index + 1), 100);
});
} else {
// Skip this row and continue to the next
processNextValidation(index + 1);
}
};
// Start processing validations
processNextValidation();
}
},
[
data,
templates,
setData,
setValidationErrors,
setRowValidationStatus,
validateRow,
upcValidation,
setValidatingCells
]
);
// Apply template to selected rows
const applyTemplateToSelected = useCallback(
(templateId: string) => {
if (!templateId) return;
// Update the selected template ID
setTemplateState((prev) => ({
...prev,
selectedTemplateId: templateId,
}));
// Get selected row keys (which may be UUIDs)
const selectedKeys = Object.entries(rowSelection)
.filter(([_, selected]) => selected === true)
.map(([key, _]) => key);
console.log("Selected row keys:", selectedKeys);
if (selectedKeys.length === 0) {
toast.error("No rows selected");
return;
}
// Map UUID keys to array indices
const selectedIndexes = selectedKeys
.map((key) => {
// Find the matching row index in the data array
const index = data.findIndex(
(row) =>
(row.__index && row.__index === key) || // Match by __index
String(data.indexOf(row)) === key // Or by numeric index
);
return index;
})
.filter((index) => index !== -1); // Filter out any not found
console.log("Mapped row indices:", selectedIndexes);
if (selectedIndexes.length === 0) {
toast.error("Could not find selected rows");
return;
}
// Apply template to selected rows
applyTemplate(templateId, selectedIndexes);
},
[rowSelection, applyTemplate, setTemplateState, data]
);
return {
templates,
isLoadingTemplates,
templateState,
setTemplateState,
loadTemplates,
refreshTemplates,
saveTemplate,
applyTemplate,
applyTemplateToSelected
};
};

View File

@@ -0,0 +1,151 @@
import { useCallback } from 'react';
import { RowData } from './validationTypes';
import type { Fields } from '../../../types';
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
export const useUniqueItemNumbersValidation = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
) => {
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
const validateUniqueItemNumbers = useCallback(async () => {
console.log("Validating unique fields");
// Skip if no data
if (!data.length) return;
// Track unique identifiers in maps
const uniqueFieldsMap = new Map<string, Map<string, number[]>>();
// Find fields that need uniqueness validation
const uniqueFields = fields
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
.map((field) => String(field.key));
console.log(
`Found ${uniqueFields.length} fields requiring uniqueness validation:`,
uniqueFields
);
// Always check item_number uniqueness even if not explicitly defined
if (!uniqueFields.includes("item_number")) {
uniqueFields.push("item_number");
}
// Initialize maps for each unique field
uniqueFields.forEach((fieldKey) => {
uniqueFieldsMap.set(fieldKey, new Map<string, number[]>());
});
// Initialize batch updates
const errors = new Map<number, Record<string, ValidationError[]>>();
// Single pass through data to identify all unique values
data.forEach((row, index) => {
uniqueFields.forEach((fieldKey) => {
const value = row[fieldKey as keyof typeof row];
// Skip empty values
if (value === undefined || value === null || value === "") {
return;
}
const valueStr = String(value);
const fieldMap = uniqueFieldsMap.get(fieldKey);
if (fieldMap) {
// Get or initialize the array of indices for this value
const indices = fieldMap.get(valueStr) || [];
indices.push(index);
fieldMap.set(valueStr, indices);
}
});
});
// Process duplicates
uniqueFields.forEach((fieldKey) => {
const fieldMap = uniqueFieldsMap.get(fieldKey);
if (!fieldMap) return;
fieldMap.forEach((indices, value) => {
// Only process if there are duplicates
if (indices.length > 1) {
// Get the validation rule for this field
const field = fields.find((f) => String(f.key) === fieldKey);
const validationRule = field?.validations?.find(
(v) => v.rule === "unique"
);
const errorObj = {
message:
validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`,
level: validationRule?.level || ("error" as "error"),
source: ErrorSources.Table,
type: ErrorType.Unique,
};
// Add error to each row with this value
indices.forEach((rowIndex) => {
const rowErrors = errors.get(rowIndex) || {};
rowErrors[fieldKey] = [errorObj];
errors.set(rowIndex, rowErrors);
});
}
});
});
// Apply batch updates only if we have errors to report
if (errors.size > 0) {
// OPTIMIZATION: Check if we actually have new errors before updating state
let hasChanges = false;
// We'll update errors with a single batch operation
setValidationErrors((prev) => {
const newMap = new Map(prev);
// Check each row for changes
errors.forEach((rowErrors, rowIndex) => {
const existingErrors = newMap.get(rowIndex) || {};
const updatedErrors = { ...existingErrors };
let rowHasChanges = false;
// Check each field for changes
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
// Compare with existing errors
const existingFieldErrors = existingErrors[fieldKey];
if (
!existingFieldErrors ||
existingFieldErrors.length !== fieldErrors.length ||
!existingFieldErrors.every(
(err, idx) =>
err.message === fieldErrors[idx].message &&
err.type === fieldErrors[idx].type
)
) {
// We have a change
updatedErrors[fieldKey] = fieldErrors;
rowHasChanges = true;
hasChanges = true;
}
});
// Only update if we have changes
if (rowHasChanges) {
newMap.set(rowIndex, updatedErrors);
}
});
// Only return a new map if we have changes
return hasChanges ? newMap : prev;
});
}
console.log("Uniqueness validation complete");
}, [data, fields, setValidationErrors]);
return {
validateUniqueItemNumbers
};
};

View File

@@ -0,0 +1,131 @@
import { useCallback } from 'react';
import type { Fields } from '../../../types';
import { ErrorSources, ErrorType } from '../../../types';
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
export const useUniqueValidation = <T extends string>(
fields: Fields<T>
) => {
// Additional function to explicitly validate uniqueness for specified fields
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
// Field keys that need special handling for uniqueness
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// If the field doesn't need uniqueness validation, return empty errors
if (!uniquenessFields.includes(fieldKey)) {
const field = fields.find(f => String(f.key) === fieldKey);
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
return new Map<number, Record<string, InfoWithSource>>();
}
}
// Create map to track errors
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (!field) return uniqueErrors;
// Get validation properties
const validation = field.validations?.find(v => v.rule === 'unique');
const allowEmpty = validation?.allowEmpty ?? false;
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[fieldKey as keyof typeof row] || '');
// Skip empty values if allowed
if (allowEmpty && isEmpty(value)) {
return;
}
if (!valueMap.has(value)) {
valueMap.set(value, [rowIndex]);
} else {
valueMap.get(value)?.push(rowIndex);
}
});
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Skip empty values
if (!value || value.trim() === '') return;
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Create errors object if needed
if (!uniqueErrors.has(rowIndex)) {
uniqueErrors.set(rowIndex, {});
}
// Add error for this field
uniqueErrors.get(rowIndex)![fieldKey] = {
message: errorMessage,
level: level as 'info' | 'warning' | 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
});
}
});
return uniqueErrors;
}, [fields]);
// Validate uniqueness for multiple fields
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
// Process each field and merge results
const allErrors = new Map<number, Record<string, InfoWithSource>>();
fieldKeys.forEach(fieldKey => {
const fieldErrors = validateUniqueField(data, fieldKey);
// Merge errors
fieldErrors.forEach((errors, rowIdx) => {
if (!allErrors.has(rowIdx)) {
allErrors.set(rowIdx, {});
}
Object.assign(allErrors.get(rowIdx)!, errors);
});
});
return allErrors;
}, [validateUniqueField]);
// Run complete validation for uniqueness
const validateAllUniqueFields = useCallback((data: RowData<T>[]) => {
// Get fields requiring uniqueness validation
const uniqueFields = fields
.filter(field => field.validations?.some(v => v.rule === 'unique'))
.map(field => String(field.key));
// Also add standard unique fields that might not be explicitly marked as unique
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// Combine all fields that need uniqueness validation
const allUniqueFieldKeys = [...new Set([
...uniqueFields,
...standardUniqueFields
])];
// Filter to only fields that exist in the data
const existingFields = allUniqueFieldKeys.filter(fieldKey =>
data.some(row => fieldKey in row)
);
// Validate all fields at once
return validateUniqueFields(data, existingFields);
}, [fields, validateUniqueFields]);
return {
validateUniqueField,
validateUniqueFields,
validateAllUniqueFields
};
};

View File

@@ -30,13 +30,6 @@ export const useUpcValidation = (
const processedUpcMapRef = useRef(new Map<string, string>());
const initialUpcValidationDoneRef = useRef(false);
// For batch validation
const validationQueueRef = useRef<Array<{rowIndex: number, supplierId: string, upcValue: string}>>([]);
const isProcessingBatchRef = useRef(false);
// For validation results
const [upcValidationResults] = useState<Map<number, { itemNumber: string }>>(new Map());
// Helper to create cell key
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
@@ -56,17 +49,40 @@ export const useUpcValidation = (
// Update item number
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
console.log(`Setting item number for row ${rowIndex} to ${itemNumber}`);
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, []);
// Mark a row as being validated
const startValidatingRow = useCallback((rowIndex: number) => {
validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
}, []);
// CRITICAL: Update BOTH the data state and the ref
// First, update the data directly to ensure UI consistency
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData];
// Only update if the row exists
if (rowIndex >= 0 && rowIndex < newData.length) {
// First, we need a new object reference for the row to force a re-render
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
}
return newData;
});
// Also update the itemNumbers map AFTER the data is updated
// This ensures the map reflects the current state of the data
setTimeout(() => {
// Update the ref with the same value
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force an immediate React render cycle by triggering state updates
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
setValidatingRows(new Set(validationStateRef.current.validatingRows));
}, 0);
}, [setData]);
// Mark a row as no longer being validated
const stopValidatingRow = useCallback((rowIndex: number) => {
@@ -139,11 +155,22 @@ export const useUpcValidation = (
);
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
// Start validation - track this with the ref to avoid race conditions
startValidatingRow(rowIndex);
startValidatingCell(rowIndex, 'item_number');
// Log validation start to help debug template issues
console.log(`[UPC-DEBUG] Starting UPC validation for row ${rowIndex} with supplier ${supplierId}, upc ${upcValue}`);
console.log(`Validating UPC: rowIndex=${rowIndex}, supplierId=${supplierId}, upc=${upcValue}`);
// IMPORTANT: Set validation state using setState to FORCE UI updates
validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
// Start cell validation and explicitly update UI via setState
const cellKey = getCellKey(rowIndex, 'item_number');
validationStateRef.current.validatingCells.add(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Set loading state for row ${rowIndex}, cell key ${cellKey}`);
console.log(`[UPC-DEBUG] Current validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Current validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
try {
// Create a unique key for this validation to track it
@@ -164,18 +191,43 @@ export const useUpcValidation = (
});
// Fetch the product by UPC
console.log(`[UPC-DEBUG] Fetching product data for UPC ${upcValue} with supplier ${supplierId}`);
const product = await fetchProductByUpc(supplierId, upcValue);
console.log(`[UPC-DEBUG] Fetch complete for row ${rowIndex}, success: ${!product.error}`);
// Check if this validation is still relevant (hasn't been superseded by another)
if (!validationStateRef.current.activeValidations.has(validationKey)) {
console.log(`Validation ${validationKey} was cancelled`);
console.log(`[UPC-DEBUG] Validation ${validationKey} was cancelled`);
return { success: false };
}
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
if (product && !product.error && product.data?.itemNumber) {
// Store this validation result
updateItemNumber(rowIndex, product.data.itemNumber);
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
// CRITICAL FIX: Directly update the data with the new item number first
setData(prevData => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
// This should happen before updating the map
newData[rowIndex] = {
...newData[rowIndex],
item_number: product.data.itemNumber
};
}
return newData;
});
// Then, update the map to match what's now in the data
validationStateRef.current.itemNumbers.set(rowIndex, product.data.itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force a shallow copy of the itemNumbers map to trigger useEffect dependencies
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
return {
success: true,
@@ -183,7 +235,7 @@ export const useUpcValidation = (
};
} else {
// No item number found but validation was still attempted
console.log(`No item number found for UPC ${upcValue}`);
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
// Clear any existing item number to show validation was attempted and failed
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
@@ -194,157 +246,74 @@ export const useUpcValidation = (
return { success: false };
}
} catch (error) {
console.error('Error validating UPC:', error);
console.error('[UPC-DEBUG] Error validating UPC:', error);
return { success: false };
} finally {
// End validation
stopValidatingRow(rowIndex);
stopValidatingCell(rowIndex, 'item_number');
// End validation - FORCE UI update by using setState directly
console.log(`[UPC-DEBUG] Ending validation for row ${rowIndex}`);
validationStateRef.current.validatingRows.delete(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
if (validationStateRef.current.validatingRows.size === 0) {
setIsValidatingUpc(false);
}
validationStateRef.current.validatingCells.delete(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Cleared loading state for row ${rowIndex}`);
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
}
}, [fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, startValidatingRow, stopValidatingRow, setData]);
}, [fetchProductByUpc, updateItemNumber, setData]);
// Apply item numbers to data
const applyItemNumbersToData = useCallback((onApplied?: (updatedRowIds: number[]) => void) => {
// Create a copy of the current item numbers map to avoid race conditions
const currentItemNumbers = new Map(validationStateRef.current.itemNumbers);
// Apply all pending item numbers to the data state
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
// Skip if we have nothing to apply
if (validationStateRef.current.itemNumbers.size === 0) {
if (callback) callback([]);
return;
}
// Only apply if we have any item numbers
if (currentItemNumbers.size === 0) return;
// Track updated row indices to pass to callback
const updatedRowIndices: number[] = [];
// Log for debugging
console.log(`Applying ${currentItemNumbers.size} item numbers to data`);
// Gather all row IDs that will be updated
const rowIds: number[] = [];
// Update the data state with all item numbers
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData];
// Update each row with its item number without affecting other fields
currentItemNumbers.forEach((itemNumber, rowIndex) => {
if (rowIndex < newData.length) {
console.log(`Setting item_number for row ${rowIndex} to ${itemNumber}`);
// Apply each item number to the data
validationStateRef.current.itemNumbers.forEach((itemNumber, rowIndex) => {
// Ensure row exists and value has actually changed
if (rowIndex >= 0 && rowIndex < newData.length &&
newData[rowIndex]?.item_number !== itemNumber) {
// Only update the item_number field, leaving other fields unchanged
// Create a new row object to force re-rendering
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
// Track which rows were updated
updatedRowIndices.push(rowIndex);
// Track which row was updated for the callback
rowIds.push(rowIndex);
}
});
return newData;
});
// Call the callback if provided, after state updates are processed
if (onApplied && updatedRowIndices.length > 0) {
// Use setTimeout to ensure this happens after the state update
setTimeout(() => {
onApplied(updatedRowIndices);
}, 100); // Use 100ms to ensure the data update is fully processed
// Force a re-render by updating React state
setTimeout(() => {
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, 0);
// Call the callback with the updated row IDs
if (callback) {
callback(rowIds);
}
}, [setData]);
// Process validation queue in batches - faster processing with smaller batches
const processBatchValidation = useCallback(async () => {
if (isProcessingBatchRef.current) return;
if (validationQueueRef.current.length === 0) return;
console.log(`Processing validation batch with ${validationQueueRef.current.length} items`);
isProcessingBatchRef.current = true;
// Process in smaller batches for better UI responsiveness
const BATCH_SIZE = 5;
const queue = [...validationQueueRef.current];
validationQueueRef.current = [];
// Track if any updates were made
let updatesApplied = false;
// Track updated row indices
const updatedRows: number[] = [];
try {
// Process in small batches
for (let i = 0; i < queue.length; i += BATCH_SIZE) {
const batch = queue.slice(i, i + BATCH_SIZE);
// Process batch in parallel
const results = await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
try {
// Skip if already validated
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
console.log(`Using cached item number for row ${rowIndex}: ${cachedItemNumber}`);
updateItemNumber(rowIndex, cachedItemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
return true;
}
return false;
}
// Fetch from API
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
const itemNumber = result.data.itemNumber;
// Store in cache
processedUpcMapRef.current.set(cacheKey, itemNumber);
// Update item number
updateItemNumber(rowIndex, itemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
console.log(`Set item number for row ${rowIndex} to ${itemNumber}`);
return true;
}
return false;
} catch (error) {
console.error(`Error processing row ${rowIndex}:`, error);
return false;
} finally {
// Clear validation state
stopValidatingRow(rowIndex);
}
}));
// If any updates were applied in this batch, update the data
if (results.some(Boolean) && updatesApplied) {
applyItemNumbersToData(updatedRowIds => {
console.log(`Processed batch UPC validation for rows: ${updatedRowIds.join(', ')}`);
});
updatesApplied = false;
updatedRows.length = 0; // Clear the array
}
// Small delay between batches to allow UI to update
if (i + BATCH_SIZE < queue.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
} catch (error) {
console.error('Error in batch processing:', error);
} finally {
isProcessingBatchRef.current = false;
// Process any new items
if (validationQueueRef.current.length > 0) {
setTimeout(processBatchValidation, 0);
}
}
}, [fetchProductByUpc, updateItemNumber, stopValidatingRow, applyItemNumbersToData]);
// For immediate processing
// Batch validate all UPCs in the data
const validateAllUPCs = useCallback(async () => {
// Skip if we've already done the initial validation
@@ -508,8 +477,8 @@ export const useUpcValidation = (
getItemNumber,
applyItemNumbersToData,
// Results
upcValidationResults,
// CRITICAL: Expose the itemNumbers map directly
itemNumbers: validationStateRef.current.itemNumbers,
// Initialization state
initialValidationDone: initialUpcValidationDoneRef.current

View File

@@ -0,0 +1,174 @@
import { useCallback } from 'react'
import type { Field, Fields, RowHook } from '../../../types'
import { ErrorSources } from '../../../types'
import { RowData, InfoWithSource } from './validationTypes'
import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation'
import { useUniqueValidation } from './useUniqueValidation'
// Main validation hook that brings together field and uniqueness validation
export const useValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>
) => {
// Use the field validation hook
const { validateField, validateRow } = useFieldValidation(fields, rowHook);
// Use the uniqueness validation hook
const {
validateUniqueField,
validateAllUniqueFields
} = useUniqueValidation(fields);
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
const validationErrors = new Map<number, Record<string, InfoWithSource>>();
// If we're updating a specific field, only validate that field for that row
if (fieldToUpdate) {
const { rowIndex, fieldKey } = fieldToUpdate;
// Special handling for fields that often update item_number
const triggersItemNumberValidation = fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier';
// If updating a uniqueness field or field that affects item_number, clear ALL related validation caches
const isUniqueField = fieldKey === 'upc' || fieldKey === 'item_number' ||
fieldKey === 'supplier_no' || fieldKey === 'notions_no' ||
fieldKey === 'name' || triggersItemNumberValidation;
// Force cache clearing for uniqueness-validated fields to ensure fresh validation
if (isUniqueField) {
console.log(`Clearing validation cache for uniqueness field: ${fieldKey}`);
clearValidationCacheForField(fieldKey);
// If a field that might affect item_number, also clear item_number cache
if (triggersItemNumberValidation) {
console.log('Also clearing item_number validation cache');
clearValidationCacheForField('item_number');
}
}
if (rowIndex >= 0 && rowIndex < data.length) {
const row = data[rowIndex];
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (field) {
// Validate just this field for this row
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
// Store the validation error
validationErrors.set(rowIndex, {
[fieldKey]: {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
}
});
}
// Check if the field requires uniqueness validation or if it's item_number after UPC/Supplier change
const needsUniquenessCheck = isUniqueField ||
field.validations?.some(v => v.rule === 'unique');
if (needsUniquenessCheck) {
console.log(`Running immediate uniqueness validation for field ${fieldKey}`);
// For item_number updated via UPC validation, or direct UPC update, check both fields
if (fieldKey === 'item_number' || fieldKey === 'upc' || fieldKey === 'barcode') {
// Validate both item_number and UPC/barcode fields for uniqueness
const itemNumberUniqueErrors = validateUniqueField(data, 'item_number');
const upcUniqueErrors = validateUniqueField(data, fieldKey === 'item_number' ? 'upc' : fieldKey);
// Combine the errors
itemNumberUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
upcUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
} else {
// Normal uniqueness validation for other fields
const uniqueErrors = validateUniqueField(data, fieldKey);
// Add unique errors to validation errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
}
}
}
}
} else {
// Full validation - all fields for all rows
console.log('Running full validation for all fields and rows');
// Process each row for field-level validations
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
let rowErrors: Record<string, InfoWithSource> = {};
// Validate all fields for this row
fields.forEach(field => {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
};
}
});
// Add row to validationErrors if it has any errors
if (Object.keys(rowErrors).length > 0) {
validationErrors.set(rowIndex, rowErrors);
}
}
// Validate all unique fields
const uniqueErrors = validateAllUniqueFields(data);
// Merge in unique errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
console.log('Uniqueness validation complete');
}
return {
data,
validationErrors
};
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
return {
validateData,
validateField,
validateRow,
validateUniqueField,
clearValidationCacheForField,
clearAllUniquenessCaches
};
}

View File

@@ -0,0 +1,725 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useRsi } from "../../../hooks/useRsi";
import { ErrorType } from "../../../types";
import { RowSelectionState } from "@tanstack/react-table";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import config from "@/config";
import { useValidation } from "./useValidation";
import { useRowOperations } from "./useRowOperations";
import { useTemplateManagement } from "./useTemplateManagement";
import { useFilterManagement } from "./useFilterManagement";
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
import { useUpcValidation } from "./useUpcValidation";
import { Props, RowData } from "./validationTypes";
export const useValidationState = <T extends string>({
initialData,
onBack,
onNext,
}: Props<T>) => {
const { fields, rowHook, tableHook } = useRsi<T>();
// Import validateField from useValidation
const { validateField: validateFieldFromHook } = useValidation<T>(
fields,
rowHook
);
// Add ref to track template application state
const isApplyingTemplateRef = useRef(false);
// Core data state
const [data, setData] = useState<RowData<T>[]>(() => {
// Clean price fields in initial data before setting state
return initialData.map((row) => {
const updatedRow = { ...row } as Record<string, any>;
// Clean MSRP
if (typeof updatedRow.msrp === "string") {
updatedRow.msrp = updatedRow.msrp.replace(/[$,]/g, "");
const numValue = parseFloat(updatedRow.msrp);
if (!isNaN(numValue)) {
updatedRow.msrp = numValue.toFixed(2);
}
}
// Clean cost_each
if (typeof updatedRow.cost_each === "string") {
updatedRow.cost_each = updatedRow.cost_each.replace(/[$,]/g, "");
const numValue = parseFloat(updatedRow.cost_each);
if (!isNaN(numValue)) {
updatedRow.cost_each = numValue.toFixed(2);
}
}
// Set default tax category if not already set
if (
updatedRow.tax_cat === undefined ||
updatedRow.tax_cat === null ||
updatedRow.tax_cat === ""
) {
updatedRow.tax_cat = "0";
}
// Set default shipping restrictions if not already set
if (
updatedRow.ship_restrictions === undefined ||
updatedRow.ship_restrictions === null ||
updatedRow.ship_restrictions === ""
) {
updatedRow.ship_restrictions = "0";
}
return updatedRow as RowData<T>;
});
});
// Row selection state
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Validation state
const [isValidating] = useState(false);
const [validationErrors, setValidationErrors] = useState<
Map<number, Record<string, any[]>>
>(new Map());
const [rowValidationStatus, setRowValidationStatus] = useState<
Map<number, "pending" | "validating" | "validated" | "error">
>(new Map());
// Add state for tracking cells in loading state
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
const initialValidationDoneRef = useRef(false);
const isValidatingRef = useRef(false);
// Use row operations hook
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
data,
fields,
setData,
setValidationErrors,
validateFieldFromHook
);
// Use UPC validation hook - MUST be initialized before template management
const upcValidation = useUpcValidation(data, setData);
// Use unique item numbers validation hook
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(
data,
fields,
setValidationErrors
);
// Use template management hook
const templateManagement = useTemplateManagement<T>(
data,
setData,
rowSelection,
setValidationErrors,
setRowValidationStatus,
validateRow,
isApplyingTemplateRef,
upcValidation,
setValidatingCells
);
// Use filter management hook
const filterManagement = useFilterManagement<T>(data, fields, validationErrors);
// Run validation when data changes - FIXED to prevent recursive validation
useEffect(() => {
// Skip initial load - we have a separate initialization process
if (!initialValidationDoneRef.current) return;
// Don't run validation during template application
if (isApplyingTemplateRef.current) return;
// CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops
if (isValidatingRef.current) return;
console.log("Running validation on data change");
isValidatingRef.current = true;
// For faster validation, run synchronously instead of in an async function
const validateFields = () => {
try {
// Run regex validations on all rows
const regexFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "regex")
);
if (regexFields.length > 0) {
// Create a map to collect validation errors
const regexErrors = new Map<
number,
Record<string, any[]>
>();
// Check each row for regex errors
data.forEach((row, rowIndex) => {
const rowErrors: Record<string, any[]> = {};
let hasErrors = false;
// Check each regex field
regexFields.forEach((field) => {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip empty values
if (value === undefined || value === null || value === "") {
return;
}
// Find regex validation
const regexValidation = field.validations?.find(
(v) => v.rule === "regex"
);
if (regexValidation) {
try {
// Check if value matches regex
const regex = new RegExp(
regexValidation.value,
regexValidation.flags
);
if (!regex.test(String(value))) {
// Add regex validation error
rowErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
hasErrors = true;
}
} catch (error) {
console.error("Invalid regex in validation:", error);
}
}
});
// Add errors if any found
if (hasErrors) {
regexErrors.set(rowIndex, rowErrors);
}
});
// Update validation errors
if (regexErrors.size > 0) {
setValidationErrors((prev) => {
const newErrors = new Map(prev);
// Merge in regex errors
for (const [rowIndex, errors] of regexErrors.entries()) {
const existingErrors = newErrors.get(rowIndex) || {};
newErrors.set(rowIndex, { ...existingErrors, ...errors });
}
return newErrors;
});
}
}
// Run uniqueness validations immediately
validateUniqueItemNumbers();
} finally {
// Always ensure the ref is reset, even if an error occurs
setTimeout(() => {
isValidatingRef.current = false;
}, 100);
}
};
// Run validation immediately
validateFields();
}, [data, fields, validateUniqueItemNumbers]);
// Add field options query
const { data: fieldOptionsData } = useQuery({
queryKey: ["import-field-options"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error("Failed to fetch field options");
}
return response.json();
},
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
// Get display text for a template
const getTemplateDisplayText = useCallback(
(templateId: string | null) => {
if (!templateId) return "Select a template";
const template = templateManagement.templates.find((t) => t.id.toString() === templateId);
if (!template) return "Unknown template";
try {
const companyId = template.company || "";
const productType = template.product_type || "Unknown Type";
// Find company name from field options
const companyName =
fieldOptionsData?.companies?.find(
(c: { value: string; label: string }) => c.value === companyId
)?.label || companyId;
return `${companyName} - ${productType}`;
} catch (error) {
console.error(
"Error formatting template display text:",
error,
template
);
return "Error displaying template";
}
},
[templateManagement.templates, fieldOptionsData]
);
// Check if there are any errors
const hasErrors = useMemo(() => {
for (const [_, status] of rowValidationStatus.entries()) {
if (status === "error") return true;
}
return false;
}, [rowValidationStatus]);
// Create a function to handle button clicks (continue or back)
const handleButtonClick = useCallback(
async (direction: "next" | "back") => {
if (direction === "back" && onBack) {
// If a specific action is defined for back, use it
onBack();
return;
}
if (direction === "next") {
// When proceeding to the next screen, check for unvalidated rows first
const hasErrors = [...validationErrors.entries()].some(
([_, errors]) => {
return Object.values(errors).some((errorSet) =>
errorSet.some((error) => error.type !== ErrorType.Required)
);
}
);
if (hasErrors) {
// We have validation errors - ask the user to fix them first or continue anyway
const shouldContinue = window.confirm(
"There are validation errors in your data. Do you want to continue anyway?"
);
if (!shouldContinue) {
// User chose to fix errors
return;
}
}
// Prepare the data for the next step
try {
// No toast here - unnecessary and distracting
// Call onNext with the cleaned data
if (onNext) {
// Remove metadata fields before passing to onNext
const cleanedData = data.map((row) => {
const {
__index,
__template,
__original,
__corrected,
__changes,
...cleanRow
} = row;
return cleanRow as any;
});
onNext(cleanedData);
}
} catch (error) {
console.error("Error proceeding to next step:", error);
toast.error("Error saving data");
}
}
},
[data, onBack, onNext, validationErrors]
);
// Initialize validation on mount
useEffect(() => {
if (initialValidationDoneRef.current) return;
console.log("Running initial validation");
const runCompleteValidation = async () => {
if (!data || data.length === 0) return;
console.log("Running complete validation...");
// Get required fields
const requiredFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "required")
);
console.log(`Found ${requiredFields.length} required fields`);
// Get fields that have regex validation
const regexFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "regex")
);
console.log(`Found ${regexFields.length} fields with regex validation`);
// Get fields that need uniqueness validation
const uniqueFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "unique")
);
console.log(
`Found ${uniqueFields.length} fields requiring uniqueness validation`
);
// Limit batch size to avoid UI freezing
const BATCH_SIZE = 100;
const totalRows = data.length;
// Initialize new data for any modifications
const newData = [...data];
// Create a temporary Map to collect all validation errors
const validationErrorsTemp = new Map<
number,
Record<string, any[]>
>();
// Variables for batching
let currentBatch = 0;
const totalBatches = Math.ceil(totalRows / BATCH_SIZE);
const processBatch = async () => {
// Calculate batch range
const startIdx = currentBatch * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, totalRows);
console.log(
`Processing batch ${
currentBatch + 1
}/${totalBatches} (rows ${startIdx} to ${endIdx - 1})`
);
// Process rows in this batch
const batchPromises: Promise<void>[] = [];
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
batchPromises.push(
new Promise<void>((resolve) => {
const row = data[rowIndex];
// Skip if row is empty or undefined
if (!row) {
resolve();
return;
}
// Store field errors for this row
const fieldErrors: Record<string, any[]> = {};
let hasErrors = false;
// Check if price fields need formatting
const rowAsRecord = row as Record<string, any>;
let mSrpNeedsProcessing = false;
let costEachNeedsProcessing = false;
if (
rowAsRecord.msrp &&
typeof rowAsRecord.msrp === "string" &&
(rowAsRecord.msrp.includes("$") ||
rowAsRecord.msrp.includes(","))
) {
mSrpNeedsProcessing = true;
}
if (
rowAsRecord.cost_each &&
typeof rowAsRecord.cost_each === "string" &&
(rowAsRecord.cost_each.includes("$") ||
rowAsRecord.cost_each.includes(","))
) {
costEachNeedsProcessing = true;
}
// Process price fields if needed
if (mSrpNeedsProcessing || costEachNeedsProcessing) {
// Create a clean copy only if needed
const cleanedRow = { ...row } as Record<string, any>;
if (mSrpNeedsProcessing) {
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
const numValue = parseFloat(msrpValue);
cleanedRow.msrp = !isNaN(numValue)
? numValue.toFixed(2)
: msrpValue;
}
if (costEachNeedsProcessing) {
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
const numValue = parseFloat(costValue);
cleanedRow.cost_each = !isNaN(numValue)
? numValue.toFixed(2)
: costValue;
}
newData[rowIndex] = cleanedRow as RowData<T>;
}
// Validate required fields
for (const field of requiredFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip non-required empty fields
if (
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0)
) {
// Add error for empty required fields
fieldErrors[key] = [
{
message:
field.validations?.find((v) => v.rule === "required")
?.errorMessage || "This field is required",
level: "error",
source: "row",
type: "required",
},
];
hasErrors = true;
}
}
// Validate regex fields - even if they have data
for (const field of regexFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip empty values as they're handled by required validation
if (value === undefined || value === null || value === "") {
continue;
}
// Find regex validation
const regexValidation = field.validations?.find(
(v) => v.rule === "regex"
);
if (regexValidation) {
try {
// Check if value matches regex
const regex = new RegExp(
regexValidation.value,
regexValidation.flags
);
if (!regex.test(String(value))) {
// Add regex validation error
fieldErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
hasErrors = true;
}
} catch (error) {
console.error("Invalid regex in validation:", error);
}
}
}
// Update validation errors for this row
if (hasErrors) {
validationErrorsTemp.set(rowIndex, fieldErrors);
}
resolve();
})
);
}
// Wait for all row validations to complete
await Promise.all(batchPromises);
};
const processAllBatches = async () => {
for (let batch = 0; batch < totalBatches; batch++) {
currentBatch = batch;
await processBatch();
// Yield to UI thread periodically
if (batch % 2 === 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
// All batches complete
console.log("All initial validation batches complete");
// Apply collected validation errors all at once
setValidationErrors(validationErrorsTemp);
// Apply any data changes (like price formatting)
if (JSON.stringify(data) !== JSON.stringify(newData)) {
setData(newData);
}
// Run uniqueness validation after the basic validation
validateUniqueItemNumbers();
// Mark that initial validation is done
initialValidationDoneRef.current = true;
console.log("Initial validation complete");
};
// Start the validation process
processAllBatches();
};
// Run the complete validation
runCompleteValidation();
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers]);
// Update fields with latest options
const fieldsWithOptions = useMemo(() => {
if (!fieldOptionsData) return fields;
return fields.map((field) => {
// Skip fields that aren't select or multi-select
if (
typeof field.fieldType !== "object" ||
(field.fieldType.type !== "select" &&
field.fieldType.type !== "multi-select")
) {
return field;
}
// Get the correct options based on field key
let options = [];
switch (field.key) {
case "company":
options = [...(fieldOptionsData.companies || [])];
break;
case "supplier":
options = [...(fieldOptionsData.suppliers || [])];
break;
case "categories":
options = [...(fieldOptionsData.categories || [])];
break;
case "themes":
options = [...(fieldOptionsData.themes || [])];
break;
case "colors":
options = [...(fieldOptionsData.colors || [])];
break;
case "tax_cat":
options = [...(fieldOptionsData.taxCategories || [])];
// Ensure tax_cat is always a select, not multi-select
return {
...field,
fieldType: {
type: "select",
options,
},
};
case "ship_restrictions":
options = [...(fieldOptionsData.shippingRestrictions || [])];
break;
case "artist":
options = [...(fieldOptionsData.artists || [])];
break;
case "size_cat":
options = [...(fieldOptionsData.sizes || [])];
break;
default:
options = [...(field.fieldType.options || [])];
}
return {
...field,
fieldType: {
...field.fieldType,
options,
},
};
});
}, [fields, fieldOptionsData]);
// Load templates on mount
useEffect(() => {
templateManagement.loadTemplates();
}, [templateManagement.loadTemplates]);
return {
// Data
data,
setData,
filteredData: filterManagement.filteredData,
// Validation
isValidating,
validationErrors,
rowValidationStatus,
validateRow,
hasErrors,
// CRITICAL: Export validatingCells to make it available to ValidationContainer
validatingCells,
setValidatingCells,
// Row selection
rowSelection,
setRowSelection,
// Row manipulation
updateRow,
copyDown,
// Templates
templates: templateManagement.templates,
isLoadingTemplates: templateManagement.isLoadingTemplates,
selectedTemplateId: templateManagement.templateState.selectedTemplateId,
showSaveTemplateDialog: templateManagement.templateState.showSaveTemplateDialog,
newTemplateName: templateManagement.templateState.newTemplateName,
newTemplateType: templateManagement.templateState.newTemplateType,
setTemplateState: templateManagement.setTemplateState,
templateState: templateManagement.templateState,
loadTemplates: templateManagement.loadTemplates,
saveTemplate: templateManagement.saveTemplate,
applyTemplate: templateManagement.applyTemplate,
applyTemplateToSelected: templateManagement.applyTemplateToSelected,
getTemplateDisplayText,
refreshTemplates: templateManagement.refreshTemplates,
// Filters
filters: filterManagement.filters,
filterFields: filterManagement.filterFields,
filterValues: filterManagement.filterValues,
updateFilters: filterManagement.updateFilters,
resetFilters: filterManagement.resetFilters,
// Fields reference
fields: fieldsWithOptions, // Return updated fields with options
// Hooks
rowHook,
tableHook,
// Button handling
handleButtonClick,
revalidateRows,
};
};

View File

@@ -0,0 +1,100 @@
import type { Data } from "../../../types";
import { ErrorSources, ErrorType } from "../../../types";
import config from "@/config";
// Define the Props interface for ValidationStepNew
export interface Props<T extends string> {
initialData: RowData<T>[];
file?: File;
onBack?: () => void;
onNext?: (data: RowData<T>[]) => void;
isFromScratch?: boolean;
}
// Extended Data type with meta information
export type RowData<T extends string> = Data<T> & {
__index?: string;
__template?: string;
__original?: Record<string, any>;
__corrected?: Record<string, any>;
__changes?: Record<string, boolean>;
upc?: string;
barcode?: string;
supplier?: string;
company?: string;
item_number?: string;
[key: string]: any; // Allow any string key for dynamic fields
};
// Template interface
export interface Template {
id: number;
company: string;
product_type: string;
[key: string]: string | number | boolean | undefined;
}
// Props for the useValidationState hook
export interface ValidationStateProps<T extends string> extends Props<T> {}
// Interface for validation results
export interface ValidationResult {
error?: boolean;
message?: string;
data?: Record<string, any>;
type?: ErrorType;
source?: ErrorSources;
}
// Filter state interface
export interface FilterState {
searchText: string;
showErrorsOnly: boolean;
filterField: string | null;
filterValue: string | null;
}
// UI validation state interface for useUpcValidation
export interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key
validatingRows: Set<number>; // Rows currently being validated
activeValidations: Set<string>; // Active validations
}
// InfoWithSource interface for validation errors
export interface InfoWithSource {
message: string;
level: 'info' | 'warning' | 'error';
source: ErrorSources;
type: ErrorType;
}
// Template state interface
export interface TemplateState {
selectedTemplateId: string | null;
showSaveTemplateDialog: boolean;
newTemplateName: string;
newTemplateType: string;
}
// Add config at the top of the file
// Import the config or access it through window
declare global {
interface Window {
config?: {
apiUrl: string;
};
}
}
// Use a helper to get API URL consistently
export const getApiUrl = () => config.apiUrl;
// Shared utility function for checking empty values
export const isEmpty = (value: any): boolean =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);

View File

@@ -1,5 +1,5 @@
import ValidationContainer from './components/ValidationContainer'
import { Props } from './hooks/useValidationState'
import { Props } from './hooks/validationTypes'
/**
* ValidationStepNew component - modern implementation of the validation step

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