Compare commits
26 Commits
af067f7360
...
add-permis
| Author | SHA1 | Date | |
|---|---|---|---|
| d60a8cbc6e | |||
| 1fcbf54989 | |||
| ce75496770 | |||
| 7eae4a0b29 | |||
| f421154c1d | |||
| 03dc119a15 | |||
| 1963bee00c | |||
| 387e7e5e73 | |||
| a51a48ce89 | |||
| aacb3a2fd0 | |||
| 35d2f0df7c | |||
| 7d46ebd6ba | |||
| 1496aa57b1 | |||
| fc9ef2f0d7 | |||
| 675a0fc374 | |||
| ca2653ea1a | |||
| a8d3fd8033 | |||
| 702b956ff1 | |||
| 9b8577f258 | |||
| 9623681a15 | |||
| cc22fd8c35 | |||
| 0ef1b6100e | |||
| a519746ccb | |||
| f29dd8ef8b | |||
| f2a5c06005 | |||
| fb9f959fe5 |
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 |
|
||||
+------------------------------------------------------------------------------------------------------------------------------------------+----------------+------------+------------+------------+------------+
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
4
.gitignore
vendored
@@ -64,3 +64,7 @@ csv/**/*
|
||||
!csv/.gitkeep
|
||||
inventory/tsconfig.tsbuildinfo
|
||||
inventory-server/scripts/.fuse_hidden00000fa20000000a
|
||||
|
||||
.VSCodeCounter/
|
||||
.VSCodeCounter/*
|
||||
.VSCodeCounter/**/*
|
||||
172
docs/PERMISSIONS.md
Normal file
172
docs/PERMISSIONS.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Permission System Documentation
|
||||
|
||||
This document outlines the permission system implemented in the Inventory Manager application.
|
||||
|
||||
## Permission Structure
|
||||
|
||||
Permissions follow this naming convention:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
|
||||
## Permission Components
|
||||
|
||||
### PermissionGuard
|
||||
|
||||
The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<PermissionGuard
|
||||
permission="create:products"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `permission`: Single permission code
|
||||
- `anyPermissions`: Array of permissions (ANY match grants access)
|
||||
- `allPermissions`: Array of permissions (ALL required)
|
||||
- `adminOnly`: For admin-only sections
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `fallback`: Content to show if permission check fails
|
||||
|
||||
### PermissionProtectedRoute
|
||||
|
||||
Protects entire pages based on page access permissions.
|
||||
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### ProtectedSection
|
||||
|
||||
Protects sections within a page based on action permissions.
|
||||
|
||||
```tsx
|
||||
<ProtectedSection page="products" action="create">
|
||||
<button>Add Product</button>
|
||||
</ProtectedSection>
|
||||
```
|
||||
|
||||
### PermissionButton
|
||||
|
||||
Button that automatically handles permissions.
|
||||
|
||||
```tsx
|
||||
<PermissionButton
|
||||
page="products"
|
||||
action="create"
|
||||
onClick={handleCreateProduct}
|
||||
>
|
||||
Add Product
|
||||
</PermissionButton>
|
||||
```
|
||||
|
||||
### SettingsSection
|
||||
|
||||
Specific component for settings with built-in permission checks.
|
||||
|
||||
```tsx
|
||||
<SettingsSection
|
||||
title="System Settings"
|
||||
description="Configure global settings"
|
||||
permission="edit:system_settings"
|
||||
>
|
||||
{/* Settings content */}
|
||||
</SettingsSection>
|
||||
```
|
||||
|
||||
## Permission Hooks
|
||||
|
||||
### usePermissions
|
||||
|
||||
Core hook for checking any permission.
|
||||
|
||||
```tsx
|
||||
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||
if (hasPermission('delete:products')) {
|
||||
// Can delete products
|
||||
}
|
||||
```
|
||||
|
||||
### usePagePermission
|
||||
|
||||
Specialized hook for page-level permissions.
|
||||
|
||||
```tsx
|
||||
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||
if (canEdit()) {
|
||||
// Can edit products
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
Permissions are stored in the database:
|
||||
- `permissions` table: Stores all available permissions
|
||||
- `user_permissions` junction table: Maps permissions to users
|
||||
|
||||
Admin users automatically have all permissions.
|
||||
|
||||
## Common Permission Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### Page Protection
|
||||
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
const { canEdit } = usePagePermission('products');
|
||||
|
||||
function handleEdit() {
|
||||
if (!canEdit()) {
|
||||
toast.error("You don't have permission");
|
||||
return;
|
||||
}
|
||||
// Edit logic
|
||||
}
|
||||
```
|
||||
|
||||
### UI Element Protection
|
||||
|
||||
```tsx
|
||||
<PermissionButton
|
||||
page="products"
|
||||
action="delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</PermissionButton>
|
||||
```
|
||||
131
docs/validation-hook-refactor.md
Normal file
131
docs/validation-hook-refactor.md
Normal 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.
|
||||
128
inventory-server/auth/permissions.js
Normal file
128
inventory-server/auth/permissions.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Get pool from global or create a new one if not available
|
||||
let pool;
|
||||
if (typeof global.pool !== 'undefined') {
|
||||
pool = global.pool;
|
||||
} else {
|
||||
// If global pool is not available, create a new connection
|
||||
const { Pool } = require('pg');
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
console.log('Created new database pool in permissions.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific permission
|
||||
* @param {number} userId - The user ID to check
|
||||
* @param {string} permissionCode - The permission code to check
|
||||
* @returns {Promise<boolean>} - Whether the user has the permission
|
||||
*/
|
||||
async function checkPermission(userId, permissionCode) {
|
||||
try {
|
||||
// First check if the user is an admin
|
||||
const adminResult = await pool.query(
|
||||
'SELECT is_admin FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// If user is admin, automatically grant permission
|
||||
if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise check for specific permission
|
||||
const result = await pool.query(
|
||||
`SELECT COUNT(*) AS has_permission
|
||||
FROM user_permissions up
|
||||
JOIN permissions p ON up.permission_id = p.id
|
||||
WHERE up.user_id = $1 AND p.code = $2`,
|
||||
[userId, permissionCode]
|
||||
);
|
||||
|
||||
return result.rows[0].has_permission > 0;
|
||||
} catch (error) {
|
||||
console.error('Error checking permission:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require a specific permission
|
||||
* @param {string} permissionCode - The permission code required
|
||||
* @returns {Function} - Express middleware function
|
||||
*/
|
||||
function requirePermission(permissionCode) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const hasPermission = await checkPermission(req.user.id, permissionCode);
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
requiredPermission: permissionCode
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Permission middleware error:', error);
|
||||
res.status(500).json({ error: 'Server error checking permissions' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a user
|
||||
* @param {number} userId - The user ID
|
||||
* @returns {Promise<string[]>} - Array of permission codes
|
||||
*/
|
||||
async function getUserPermissions(userId) {
|
||||
try {
|
||||
// Check if user is admin
|
||||
const adminResult = await pool.query(
|
||||
'SELECT is_admin FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (adminResult.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isAdmin = adminResult.rows[0].is_admin;
|
||||
|
||||
if (isAdmin) {
|
||||
// Admin gets all permissions
|
||||
const allPermissions = await pool.query('SELECT code FROM permissions');
|
||||
return allPermissions.rows.map(p => p.code);
|
||||
} else {
|
||||
// Get assigned permissions
|
||||
const permissions = await pool.query(
|
||||
`SELECT p.code
|
||||
FROM permissions p
|
||||
JOIN user_permissions up ON p.id = up.permission_id
|
||||
WHERE up.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return permissions.rows.map(p => p.code);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user permissions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkPermission,
|
||||
requirePermission,
|
||||
getUserPermissions
|
||||
};
|
||||
513
inventory-server/auth/routes.js
Normal file
513
inventory-server/auth/routes.js
Normal file
@@ -0,0 +1,513 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requirePermission, getUserPermissions } = require('./permissions');
|
||||
|
||||
// Get pool from global or create a new one if not available
|
||||
let pool;
|
||||
if (typeof global.pool !== 'undefined') {
|
||||
pool = global.pool;
|
||||
} else {
|
||||
// If global pool is not available, create a new connection
|
||||
const { Pool } = require('pg');
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
console.log('Created new database pool in routes.js');
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get user from database
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, is_admin FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = result.rows[0];
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Login route
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Get user from database
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ error: 'Account is inactive' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
);
|
||||
|
||||
// Get user permissions
|
||||
const permissions = await getUserPermissions(user.id);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
permissions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
// Get user permissions
|
||||
const permissions = await getUserPermissions(req.user.id);
|
||||
|
||||
res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
is_admin: req.user.is_admin,
|
||||
permissions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting current user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
||||
FROM users
|
||||
ORDER BY username
|
||||
`);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error getting users:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user with permissions
|
||||
router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
// Get user details
|
||||
const userResult = await pool.query(`
|
||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, [userId]);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
const permissionsResult = await pool.query(`
|
||||
SELECT p.id, p.name, p.code, p.category, p.description
|
||||
FROM permissions p
|
||||
JOIN user_permissions up ON p.id = up.permission_id
|
||||
WHERE up.user_id = $1
|
||||
ORDER BY p.category, p.name
|
||||
`, [userId]);
|
||||
|
||||
// Combine user and permissions
|
||||
const user = {
|
||||
...userResult.rows[0],
|
||||
permissions: permissionsResult.rows
|
||||
};
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new user
|
||||
router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
||||
|
||||
console.log("Create user request:", {
|
||||
username,
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// Check if username is taken
|
||||
const existingUser = await client.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Username already exists' });
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Insert new user
|
||||
const userResult = await client.query(`
|
||||
INSERT INTO users (username, email, password, is_admin, is_active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false]);
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
// Assign permissions if provided and not admin
|
||||
if (!is_admin && Array.isArray(permissions) && permissions.length > 0) {
|
||||
console.log("Adding permissions for new user:", userId);
|
||||
console.log("Permissions received:", permissions);
|
||||
|
||||
// Check permission format
|
||||
const permissionIds = permissions.map(p => {
|
||||
if (typeof p === 'object' && p.id) {
|
||||
console.log("Permission is an object with ID:", p.id);
|
||||
return parseInt(p.id, 10);
|
||||
} else if (typeof p === 'number') {
|
||||
console.log("Permission is a number:", p);
|
||||
return p;
|
||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||
console.log("Permission is a string that can be parsed as a number:", p);
|
||||
return parseInt(p, 10);
|
||||
} else {
|
||||
console.log("Unknown permission format:", typeof p, p);
|
||||
// If it's a permission code, we need to look up the ID
|
||||
return null;
|
||||
}
|
||||
}).filter(id => id !== null);
|
||||
|
||||
console.log("Filtered permission IDs:", permissionIds);
|
||||
|
||||
if (permissionIds.length > 0) {
|
||||
const permissionValues = permissionIds
|
||||
.map(permId => `(${userId}, ${permId})`)
|
||||
.join(',');
|
||||
|
||||
console.log("Inserting permission values:", permissionValues);
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO user_permissions (user_id, permission_id)
|
||||
VALUES ${permissionValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
console.log("Successfully inserted permissions for new user:", userId);
|
||||
} catch (err) {
|
||||
console.error("Error inserting permissions for new user:", err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
console.log("No valid permission IDs found to insert for new user");
|
||||
}
|
||||
} else {
|
||||
console.log("Not adding permissions: is_admin =", is_admin, "permissions array:", Array.isArray(permissions), "length:", permissions ? permissions.length : 0);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
id: userId,
|
||||
message: 'User created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error creating user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
||||
|
||||
console.log("Update user request:", {
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
// Check if user exists
|
||||
const userExists = await client.query(
|
||||
'SELECT id FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userExists.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Build update fields
|
||||
const updateFields = [];
|
||||
const updateValues = [userId]; // First parameter is the user ID
|
||||
let paramIndex = 2;
|
||||
|
||||
if (username !== undefined) {
|
||||
updateFields.push(`username = $${paramIndex++}`);
|
||||
updateValues.push(username);
|
||||
}
|
||||
|
||||
if (email !== undefined) {
|
||||
updateFields.push(`email = $${paramIndex++}`);
|
||||
updateValues.push(email || null);
|
||||
}
|
||||
|
||||
if (is_admin !== undefined) {
|
||||
updateFields.push(`is_admin = $${paramIndex++}`);
|
||||
updateValues.push(!!is_admin);
|
||||
}
|
||||
|
||||
if (is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
updateValues.push(!!is_active);
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (password) {
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
updateFields.push(`password = $${paramIndex++}`);
|
||||
updateValues.push(hashedPassword);
|
||||
}
|
||||
|
||||
// Update user if there are fields to update
|
||||
if (updateFields.length > 0) {
|
||||
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = $1
|
||||
`, updateValues);
|
||||
}
|
||||
|
||||
// Update permissions if provided
|
||||
if (Array.isArray(permissions)) {
|
||||
console.log("Updating permissions for user:", userId);
|
||||
console.log("Permissions received:", permissions);
|
||||
|
||||
// First remove existing permissions
|
||||
await client.query(
|
||||
'DELETE FROM user_permissions WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
console.log("Deleted existing permissions for user:", userId);
|
||||
|
||||
// Add new permissions if any and not admin
|
||||
const newIsAdmin = is_admin !== undefined ? is_admin : (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin;
|
||||
|
||||
console.log("User is admin:", newIsAdmin);
|
||||
|
||||
if (!newIsAdmin && permissions.length > 0) {
|
||||
console.log("Adding permissions:", permissions);
|
||||
|
||||
// Check permission format
|
||||
const permissionIds = permissions.map(p => {
|
||||
if (typeof p === 'object' && p.id) {
|
||||
console.log("Permission is an object with ID:", p.id);
|
||||
return parseInt(p.id, 10);
|
||||
} else if (typeof p === 'number') {
|
||||
console.log("Permission is a number:", p);
|
||||
return p;
|
||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||
console.log("Permission is a string that can be parsed as a number:", p);
|
||||
return parseInt(p, 10);
|
||||
} else {
|
||||
console.log("Unknown permission format:", typeof p, p);
|
||||
// If it's a permission code, we need to look up the ID
|
||||
return null;
|
||||
}
|
||||
}).filter(id => id !== null);
|
||||
|
||||
console.log("Filtered permission IDs:", permissionIds);
|
||||
|
||||
if (permissionIds.length > 0) {
|
||||
const permissionValues = permissionIds
|
||||
.map(permId => `(${userId}, ${permId})`)
|
||||
.join(',');
|
||||
|
||||
console.log("Inserting permission values:", permissionValues);
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO user_permissions (user_id, permission_id)
|
||||
VALUES ${permissionValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
console.log("Successfully inserted permissions for user:", userId);
|
||||
} catch (err) {
|
||||
console.error("Error inserting permissions:", err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
console.log("No valid permission IDs found to insert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({ message: 'User updated successfully' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error updating user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
// Check that user is not deleting themselves
|
||||
if (req.user.id === parseInt(userId, 10)) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
// Delete user (this will cascade to user_permissions due to FK constraints)
|
||||
const result = await pool.query(
|
||||
'DELETE FROM users WHERE id = $1 RETURNING id',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'User deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all permissions grouped by category
|
||||
router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT category, json_agg(
|
||||
json_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'code', code,
|
||||
'description', description
|
||||
) ORDER BY name
|
||||
) as permissions
|
||||
FROM permissions
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
`);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error getting permissions:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all permissions
|
||||
router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT *
|
||||
FROM permissions
|
||||
ORDER BY category, name
|
||||
`);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error getting permissions:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,5 +2,88 @@ CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
email VARCHAR UNIQUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_login TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
);
|
||||
|
||||
-- Function to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Sequence and defined type for users table if not exists
|
||||
CREATE SEQUENCE IF NOT EXISTS users_id_seq;
|
||||
|
||||
-- Create permissions table
|
||||
CREATE TABLE IF NOT EXISTS "public"."permissions" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"name" varchar NOT NULL UNIQUE,
|
||||
"code" varchar NOT NULL UNIQUE,
|
||||
"description" text,
|
||||
"category" varchar NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create user_permissions junction table
|
||||
CREATE TABLE IF NOT EXISTS "public"."user_permissions" (
|
||||
"user_id" int4 NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE,
|
||||
"permission_id" int4 NOT NULL REFERENCES "public"."permissions"("id") ON DELETE CASCADE,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY ("user_id", "permission_id")
|
||||
);
|
||||
|
||||
-- Add triggers for updated_at on users and permissions
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_permissions_updated_at ON permissions;
|
||||
CREATE TRIGGER update_permissions_updated_at
|
||||
BEFORE UPDATE ON permissions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Insert default permissions by page - only the ones used in application
|
||||
INSERT INTO permissions (name, code, description, category) VALUES
|
||||
('Dashboard Access', 'access:dashboard', 'Can access the Dashboard page', 'Pages'),
|
||||
('Products Access', 'access:products', 'Can access the Products page', 'Pages'),
|
||||
('Categories Access', 'access:categories', 'Can access the Categories page', 'Pages'),
|
||||
('Vendors Access', 'access:vendors', 'Can access the Vendors page', 'Pages'),
|
||||
('Analytics Access', 'access:analytics', 'Can access the Analytics page', 'Pages'),
|
||||
('Forecasting Access', 'access:forecasting', 'Can access the Forecasting page', 'Pages'),
|
||||
('Purchase Orders Access', 'access:purchase_orders', 'Can access the Purchase Orders page', 'Pages'),
|
||||
('Import Access', 'access:import', 'Can access the Import page', 'Pages'),
|
||||
('Settings Access', 'access:settings', 'Can access the Settings page', 'Pages'),
|
||||
('AI Validation Debug Access', 'access:ai_validation_debug', 'Can access the AI Validation Debug page', 'Pages')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Settings section permissions
|
||||
INSERT INTO permissions (name, code, description, category) VALUES
|
||||
('Data Management', 'settings:data_management', 'Access to the Data Management settings section', 'Settings'),
|
||||
('Stock Management', 'settings:stock_management', 'Access to the Stock Management settings section', 'Settings'),
|
||||
('Performance Metrics', 'settings:performance_metrics', 'Access to the Performance Metrics settings section', 'Settings'),
|
||||
('Calculation Settings', 'settings:calculation_settings', 'Access to the Calculation Settings section', 'Settings'),
|
||||
('Template Management', 'settings:templates', 'Access to the Template Management settings section', 'Settings'),
|
||||
('User Management', 'settings:user_management', 'Access to the User Management settings section', 'Settings')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Set any existing users as admin
|
||||
UPDATE users SET is_admin = TRUE WHERE is_admin IS NULL;
|
||||
|
||||
-- Grant all permissions to admin users
|
||||
INSERT INTO user_permissions (user_id, permission_id)
|
||||
SELECT u.id, p.id
|
||||
FROM users u, permissions p
|
||||
WHERE u.is_admin = TRUE
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -5,6 +5,7 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Pool } = require('pg');
|
||||
const morgan = require('morgan');
|
||||
const authRoutes = require('./routes');
|
||||
|
||||
// Log startup configuration
|
||||
console.log('Starting auth server with config:', {
|
||||
@@ -27,11 +28,14 @@ const pool = new Pool({
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
// Make pool available globally
|
||||
global.pool = pool;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(morgan('combined'));
|
||||
app.use(cors({
|
||||
origin: ['http://localhost:5173', 'https://inventory.kent.pw'],
|
||||
origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
@@ -42,7 +46,7 @@ app.post('/login', async (req, res) => {
|
||||
try {
|
||||
// Get user from database
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, password FROM users WHERE username = $1',
|
||||
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
@@ -52,6 +56,11 @@ app.post('/login', async (req, res) => {
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ error: 'Account is inactive' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
@@ -60,31 +69,84 @@ app.post('/login', async (req, res) => {
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
// Get user permissions for the response
|
||||
const permissionsResult = await pool.query(`
|
||||
SELECT code
|
||||
FROM permissions p
|
||||
JOIN user_permissions up ON p.id = up.permission_id
|
||||
WHERE up.user_id = $1
|
||||
`, [user.id]);
|
||||
|
||||
const permissions = permissionsResult.rows.map(row => row.code);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
permissions: user.is_admin ? [] : permissions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected route to verify token
|
||||
app.get('/protected', async (req, res) => {
|
||||
// User info endpoint
|
||||
app.get('/me', async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
res.json({ userId: decoded.userId, username: decoded.username });
|
||||
|
||||
// Get user details from database
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Get user permissions
|
||||
let permissions = [];
|
||||
if (!user.is_admin) {
|
||||
const permissionsResult = await pool.query(`
|
||||
SELECT code
|
||||
FROM permissions p
|
||||
JOIN user_permissions up ON p.id = up.permission_id
|
||||
WHERE up.user_id = $1
|
||||
`, [user.id]);
|
||||
|
||||
permissions = permissionsResult.rows.map(row => row.code);
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
is_admin: user.is_admin,
|
||||
permissions: permissions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mount all routes from routes.js
|
||||
app.use('/', authRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'healthy' });
|
||||
|
||||
14
inventory-server/package-lock.json
generated
14
inventory-server/package-lock.json
generated
@@ -1537,20 +1537,6 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
||||
@@ -184,7 +184,7 @@ async function resetDatabase() {
|
||||
SELECT string_agg(tablename, ', ') as tables
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT IN ('users', 'calculate_history', 'import_history');
|
||||
AND tablename NOT IN ('users', 'permissions', 'user_permissions', 'calculate_history', 'import_history');
|
||||
`);
|
||||
|
||||
if (!tablesResult.rows[0].tables) {
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
const { Pool, Client } = require('pg');
|
||||
const { Client: SSHClient } = require('ssh2');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
let pool;
|
||||
|
||||
function initPool(config) {
|
||||
// Log config without sensitive data
|
||||
const safeConfig = {
|
||||
host: config.host || process.env.DB_HOST,
|
||||
user: config.user || process.env.DB_USER,
|
||||
database: config.database || process.env.DB_NAME,
|
||||
port: config.port || process.env.DB_PORT || 5432,
|
||||
max: config.max || 10,
|
||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||
ssl: config.ssl || false,
|
||||
password: (config.password || process.env.DB_PASSWORD) ? '[password set]' : '[no password]'
|
||||
};
|
||||
console.log('[Database] Initializing pool with config:', safeConfig);
|
||||
|
||||
// Create the pool with the configuration
|
||||
pool = new Pool({
|
||||
host: config.host || process.env.DB_HOST,
|
||||
user: config.user || process.env.DB_USER,
|
||||
password: config.password || process.env.DB_PASSWORD,
|
||||
database: config.database || process.env.DB_NAME,
|
||||
port: config.port || process.env.DB_PORT || 5432,
|
||||
max: config.max || 10,
|
||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||
ssl: config.ssl || false
|
||||
});
|
||||
|
||||
// Test the pool connection
|
||||
return pool.connect()
|
||||
.then(client => {
|
||||
console.log('[Database] Pool connection successful');
|
||||
client.release();
|
||||
return pool;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Database] Connection failed:', err);
|
||||
throw err;
|
||||
});
|
||||
pool = mysql.createPool(config);
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function getConnection() {
|
||||
|
||||
2269
inventory/package-lock.json
generated
2269
inventory/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,26 +10,12 @@
|
||||
"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/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
@@ -60,8 +46,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",
|
||||
@@ -81,6 +65,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
@@ -90,7 +75,8 @@
|
||||
"tanstack": "^1.0.0",
|
||||
"uuid": "^11.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -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();
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Products } from './pages/Products';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Orders } from './pages/Orders';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Analytics } from './pages/Analytics';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
@@ -16,44 +15,61 @@ 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"
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { Protected } from './components/auth/Protected';
|
||||
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = sessionStorage.getItem('token');
|
||||
if (token) {
|
||||
const token = localStorage.getItem('token');
|
||||
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||
|
||||
// If we have a token but aren't logged in yet, verify the token
|
||||
if (token && !isLoggedIn) {
|
||||
try {
|
||||
const response = await fetch(`${config.authUrl}/protected`, {
|
||||
const response = await fetch(`${config.authUrl}/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
sessionStorage.removeItem('token');
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
navigate('/login');
|
||||
|
||||
// Only navigate to login if we're not already there
|
||||
if (!location.pathname.includes('/login')) {
|
||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||
}
|
||||
} else {
|
||||
// If token is valid, set the login flag
|
||||
sessionStorage.setItem('isLoggedIn', 'true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
sessionStorage.removeItem('token');
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
navigate('/login');
|
||||
|
||||
// Only navigate to login if we're not already there
|
||||
if (!location.pathname.includes('/login')) {
|
||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
}, [navigate, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChakraProvider>
|
||||
<AuthProvider>
|
||||
<Toaster richColors position="top-center" />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -62,21 +78,60 @@ function App() {
|
||||
<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 index element={
|
||||
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
||||
<Dashboard />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<Protected page="dashboard">
|
||||
<Dashboard />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/products" element={
|
||||
<Protected page="products">
|
||||
<Products />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
<Import />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/categories" element={
|
||||
<Protected page="categories">
|
||||
<Categories />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/vendors" element={
|
||||
<Protected page="vendors">
|
||||
<Vendors />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/purchase-orders" element={
|
||||
<Protected page="purchase_orders">
|
||||
<PurchaseOrders />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/analytics" element={
|
||||
<Protected page="analytics">
|
||||
<Analytics />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<Protected page="settings">
|
||||
<Settings />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Forecasting />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ChakraProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
44
inventory/src/components/auth/FirstAccessiblePage.tsx
Normal file
44
inventory/src/components/auth/FirstAccessiblePage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
// Define available pages in order of priority
|
||||
const PAGES = [
|
||||
{ path: "/products", permission: "access:products" },
|
||||
{ path: "/categories", permission: "access:categories" },
|
||||
{ path: "/vendors", permission: "access:vendors" },
|
||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||
{ path: "/analytics", permission: "access:analytics" },
|
||||
{ path: "/forecasting", permission: "access:forecasting" },
|
||||
{ path: "/import", permission: "access:import" },
|
||||
{ path: "/settings", permission: "access:settings" },
|
||||
{ path: "/ai-validation/debug", permission: "access:ai_validation_debug" }
|
||||
];
|
||||
|
||||
export function FirstAccessiblePage() {
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
// If user isn't loaded yet, don't render anything
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Admin users have access to all pages, so this component
|
||||
// shouldn't be rendering for them (handled by App.tsx)
|
||||
if (user.is_admin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first page the user has access to
|
||||
const firstAccessiblePage = PAGES.find(page => {
|
||||
return user.permissions?.includes(page.permission);
|
||||
});
|
||||
|
||||
// If we found a page, redirect to it
|
||||
if (firstAccessiblePage) {
|
||||
return <Navigate to={firstAccessiblePage.path} replace />;
|
||||
}
|
||||
|
||||
// If user has no access to any page, redirect to login
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Permission System Documentation
|
||||
|
||||
This document outlines the simplified permission system implemented in the Inventory Manager application.
|
||||
|
||||
## Permission Structure
|
||||
|
||||
Permissions follow this naming convention:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
|
||||
## Permission Component
|
||||
|
||||
### Protected
|
||||
|
||||
The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<Protected
|
||||
permission="create:products"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
</Protected>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `permission`: Single permission code (e.g., "create:products")
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `resource` + `action`: Resource and action (checks `{action}:{resource}` permission)
|
||||
- `adminOnly`: For admin-only sections
|
||||
- `fallback`: Content to show if permission check fails
|
||||
|
||||
### RequireAuth
|
||||
|
||||
Used for basic authentication checks (is user logged in?).
|
||||
|
||||
```tsx
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
{/* Protected routes */}
|
||||
</Route>
|
||||
```
|
||||
|
||||
## Common Permission Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### Page Protection
|
||||
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<Protected page="products" fallback={<Navigate to="/" />}>
|
||||
<Products />
|
||||
</Protected>
|
||||
} />
|
||||
```
|
||||
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
<Protected permission="edit:products">
|
||||
<form>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Save Changes</button>
|
||||
</form>
|
||||
</Protected>
|
||||
```
|
||||
|
||||
### Button Protection
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!hasPermission('delete:products')}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
// With Protected component
|
||||
<Protected permission="delete:products" fallback={null}>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
</Protected>
|
||||
```
|
||||
82
inventory/src/components/auth/Protected.tsx
Normal file
82
inventory/src/components/auth/Protected.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ReactNode, useContext } from "react";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
interface ProtectedProps {
|
||||
// For specific permission code
|
||||
permission?: string;
|
||||
|
||||
// For page access permission format: access:{page}
|
||||
page?: string;
|
||||
|
||||
// For action permission format: {action}:{resource}
|
||||
resource?: string;
|
||||
action?: "view" | "create" | "edit" | "delete" | string;
|
||||
|
||||
// For admin-only access
|
||||
adminOnly?: boolean;
|
||||
|
||||
// Content to render if permission check passes
|
||||
children: ReactNode;
|
||||
|
||||
// Optional fallback content
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified component that conditionally renders content based on user permissions
|
||||
*/
|
||||
export function Protected({
|
||||
permission,
|
||||
page,
|
||||
resource,
|
||||
action,
|
||||
adminOnly,
|
||||
children,
|
||||
fallback = null
|
||||
}: ProtectedProps) {
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
// If user isn't loaded yet, don't render anything
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Admin check - admins always have access to everything
|
||||
if (user.is_admin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Admin-only check
|
||||
if (adminOnly) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Check permissions array exists
|
||||
if (!user.permissions) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Page access check (access:page)
|
||||
if (page) {
|
||||
const pagePermission = `access:${page.toLowerCase()}`;
|
||||
if (!user.permissions.includes(pagePermission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
}
|
||||
|
||||
// Resource action check (action:resource)
|
||||
if (resource && action) {
|
||||
const resourcePermission = `${action}:${resource.toLowerCase()}`;
|
||||
if (!user.permissions.includes(resourcePermission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
}
|
||||
|
||||
// Single permission check
|
||||
if (permission && !user.permissions.includes(permission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// If all checks pass, render children
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,8 +1,45 @@
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { useContext, useEffect, useState } from "react"
|
||||
import { AuthContext } from "@/contexts/AuthContext"
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true"
|
||||
const { token, user, fetchCurrentUser } = useContext(AuthContext)
|
||||
const location = useLocation()
|
||||
const [isLoading, setIsLoading] = useState(!!token && !user)
|
||||
|
||||
// This will make sure the user data is loaded the first time
|
||||
useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
if (token && !user) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await fetchCurrentUser()
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUserData()
|
||||
}, [token, user, fetchCurrentUser])
|
||||
|
||||
// Check if token exists but we're not logged in
|
||||
useEffect(() => {
|
||||
if (token && !isLoggedIn) {
|
||||
// Verify the token and fetch user data
|
||||
fetchCurrentUser().catch(() => {
|
||||
// Do nothing - the AuthContext will handle errors
|
||||
})
|
||||
}
|
||||
}, [token, isLoggedIn, fetchCurrentUser])
|
||||
|
||||
// If still loading user data, show nothing yet
|
||||
if (isLoading) {
|
||||
return <div className="p-8 flex justify-center items-center h-screen">Loading...</div>
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Redirect to login with the current path in the redirect parameter
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,47 +24,56 @@ import {
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Overview",
|
||||
icon: Home,
|
||||
url: "/",
|
||||
permission: "access:dashboard"
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
icon: Package,
|
||||
url: "/products",
|
||||
permission: "access:products"
|
||||
},
|
||||
{
|
||||
title: "Import",
|
||||
icon: FileSpreadsheet,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
icon: Tags,
|
||||
url: "/categories",
|
||||
permission: "access:categories"
|
||||
},
|
||||
{
|
||||
title: "Vendors",
|
||||
icon: Users,
|
||||
url: "/vendors",
|
||||
permission: "access:vendors"
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
icon: ClipboardList,
|
||||
url: "/purchase-orders",
|
||||
permission: "access:purchase_orders"
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart2,
|
||||
url: "/analytics",
|
||||
permission: "access:analytics"
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,8 +82,8 @@ export function AppSidebar() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
sessionStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
@@ -98,20 +107,26 @@ export function AppSidebar() {
|
||||
location.pathname === item.url ||
|
||||
(item.url !== "/" && location.pathname.startsWith(item.url));
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<Protected
|
||||
key={item.title}
|
||||
permission={item.permission}
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
@@ -122,24 +137,30 @@ export function AppSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Settings"
|
||||
isActive={location.pathname === "/settings"}
|
||||
>
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<Protected
|
||||
permission="access:settings"
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Settings"
|
||||
isActive={location.pathname === "/settings"}
|
||||
>
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { motion } from "motion/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { StepType } from "./steps/UploadFlow"
|
||||
export { ReactSpreadsheetImport } from "./ReactSpreadsheetImport"
|
||||
export * from "./types"
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"> </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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
@@ -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,
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AiValidationDialogs } from '../../../components/AiValidationDialogs';
|
||||
import { Product } from '../../../types/product';
|
||||
import { config } from '../../../config';
|
||||
|
||||
interface CurrentPrompt {
|
||||
isOpen: boolean;
|
||||
prompt: string;
|
||||
isLoading: boolean;
|
||||
debugData?: {
|
||||
taxonomyStats: {
|
||||
categories: number;
|
||||
themes: number;
|
||||
colors: number;
|
||||
taxCodes: number;
|
||||
sizeCategories: number;
|
||||
suppliers: number;
|
||||
companies: number;
|
||||
artists: number;
|
||||
} | null;
|
||||
basePrompt: string;
|
||||
sampleFullPrompt: string;
|
||||
promptLength: number;
|
||||
estimatedProcessingTime?: {
|
||||
seconds: number | null;
|
||||
sampleCount: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const ValidationStepNew: React.FC = () => {
|
||||
const [aiValidationProgress, setAiValidationProgress] = useState(0);
|
||||
const [aiValidationDetails, setAiValidationDetails] = useState('');
|
||||
const [currentPrompt, setCurrentPrompt] = useState<CurrentPrompt>({
|
||||
isOpen: false,
|
||||
prompt: '',
|
||||
isLoading: true,
|
||||
});
|
||||
const [isChangeReverted, setIsChangeReverted] = useState(false);
|
||||
const [fieldData, setFieldData] = useState<Product[]>([]);
|
||||
|
||||
const showCurrentPrompt = async (products: Product[]) => {
|
||||
setCurrentPrompt((prev) => ({ ...prev, isOpen: true, isLoading: true }));
|
||||
|
||||
try {
|
||||
// Get the prompt
|
||||
const promptResponse = await fetch(`${config.apiUrl}/ai-validation/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ products })
|
||||
});
|
||||
|
||||
if (!promptResponse.ok) {
|
||||
throw new Error('Failed to fetch AI prompt');
|
||||
}
|
||||
|
||||
const promptData = await promptResponse.json();
|
||||
|
||||
// Get the debug data in the same request or as a separate request
|
||||
const debugResponse = await fetch(`${config.apiUrl}/ai-validation/debug-info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: promptData.prompt })
|
||||
});
|
||||
|
||||
let debugData;
|
||||
if (debugResponse.ok) {
|
||||
debugData = await debugResponse.json();
|
||||
} else {
|
||||
// If debug-info fails, use a fallback to get taxonomy stats
|
||||
const fallbackResponse = await fetch(`${config.apiUrl}/ai-validation/debug`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ products: [products[0]] }) // Use first product for stats
|
||||
});
|
||||
|
||||
if (fallbackResponse.ok) {
|
||||
debugData = await fallbackResponse.json();
|
||||
// Set promptLength correctly from the actual prompt
|
||||
debugData.promptLength = promptData.prompt.length;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPrompt((prev) => ({
|
||||
...prev,
|
||||
prompt: promptData.prompt,
|
||||
isLoading: false,
|
||||
debugData: debugData
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching prompt:', error);
|
||||
setCurrentPrompt((prev) => ({
|
||||
...prev,
|
||||
prompt: 'Error loading prompt',
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const revertAiChange = () => {
|
||||
setIsChangeReverted(true);
|
||||
};
|
||||
|
||||
const getFieldDisplayValueWithHighlight = (value: string, highlight: string) => {
|
||||
// Implementation of getFieldDisplayValueWithHighlight
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AiValidationDialogs
|
||||
aiValidationProgress={aiValidationProgress}
|
||||
aiValidationDetails={aiValidationDetails}
|
||||
currentPrompt={currentPrompt}
|
||||
setAiValidationProgress={setAiValidationProgress}
|
||||
setAiValidationDetails={setAiValidationDetails}
|
||||
setCurrentPrompt={setCurrentPrompt}
|
||||
revertAiChange={revertAiChange}
|
||||
isChangeReverted={isChangeReverted}
|
||||
getFieldDisplayValueWithHighlight={getFieldDisplayValueWithHighlight}
|
||||
fields={fieldData}
|
||||
debugData={currentPrompt.debugData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidationStepNew;
|
||||
@@ -0,0 +1,499 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckIcon } from "lucide-react";
|
||||
import { Code } from "@/components/ui/code";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AiValidationDetails,
|
||||
AiValidationProgress,
|
||||
CurrentPrompt,
|
||||
} from "../hooks/useAiValidation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface TaxonomyStats {
|
||||
categories: number;
|
||||
themes: number;
|
||||
colors: number;
|
||||
taxCodes: number;
|
||||
sizeCategories: number;
|
||||
suppliers: number;
|
||||
companies: number;
|
||||
artists: number;
|
||||
}
|
||||
|
||||
interface DebugData {
|
||||
taxonomyStats: TaxonomyStats | null;
|
||||
basePrompt: string;
|
||||
sampleFullPrompt: string;
|
||||
promptLength: number;
|
||||
estimatedProcessingTime?: {
|
||||
seconds: number | null;
|
||||
sampleCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AiValidationDialogsProps {
|
||||
aiValidationProgress: AiValidationProgress;
|
||||
aiValidationDetails: AiValidationDetails;
|
||||
currentPrompt: CurrentPrompt;
|
||||
setAiValidationProgress: React.Dispatch<
|
||||
React.SetStateAction<AiValidationProgress>
|
||||
>;
|
||||
setAiValidationDetails: React.Dispatch<
|
||||
React.SetStateAction<AiValidationDetails>
|
||||
>;
|
||||
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
|
||||
revertAiChange: (productIndex: number, fieldKey: string) => void;
|
||||
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
|
||||
getFieldDisplayValueWithHighlight: (
|
||||
fieldKey: string,
|
||||
originalValue: any,
|
||||
correctedValue: any
|
||||
) => { originalHtml: string; correctedHtml: string };
|
||||
fields: readonly any[];
|
||||
debugData?: DebugData;
|
||||
}
|
||||
|
||||
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
aiValidationProgress,
|
||||
aiValidationDetails,
|
||||
currentPrompt,
|
||||
setAiValidationProgress,
|
||||
setAiValidationDetails,
|
||||
setCurrentPrompt,
|
||||
revertAiChange,
|
||||
isChangeReverted,
|
||||
getFieldDisplayValueWithHighlight,
|
||||
fields,
|
||||
debugData,
|
||||
}) => {
|
||||
const [costPerMillionTokens, setCostPerMillionTokens] = useState(2.5); // Default cost
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)} seconds`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate token costs
|
||||
const calculateTokenCost = (promptLength: number): number => {
|
||||
const estimatedTokens = Math.round(promptLength / 4);
|
||||
return (estimatedTokens / 1_000_000) * costPerMillionTokens * 100; // In cents
|
||||
};
|
||||
|
||||
// Use the prompt length from the current prompt
|
||||
const promptLength = currentPrompt.prompt ? currentPrompt.prompt.length : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Current Prompt Dialog with Debug Info */}
|
||||
<Dialog
|
||||
open={currentPrompt.isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setCurrentPrompt((prev) => ({ ...prev, isOpen: open }))
|
||||
}
|
||||
>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Current AI Prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the current prompt that would be sent to the AI for
|
||||
validation
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
|
||||
{/* Debug Information Section */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex justify-center items-center h-[100px]"></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">Prompt Length</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Characters:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">{promptLength}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Tokens:</span>{" "}
|
||||
<span className="font-semibold">
|
||||
~{Math.round(promptLength / 4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">Cost Estimate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<label
|
||||
htmlFor="costPerMillion"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
$
|
||||
</label>
|
||||
<input
|
||||
id="costPerMillion"
|
||||
className="w-[40px] px-1 border rounded-md text-sm"
|
||||
defaultValue={costPerMillionTokens.toFixed(2)}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
setCostPerMillionTokens(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="costPerMillion"
|
||||
className="text-sm text-muted-foreground ml-1"
|
||||
>
|
||||
per million input tokens
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Cost:</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{calculateTokenCost(promptLength).toFixed(1)}¢
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Processing Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{debugData?.estimatedProcessingTime ? (
|
||||
debugData.estimatedProcessingTime.seconds ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Estimated time:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{formatTime(
|
||||
debugData.estimatedProcessingTime.seconds
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Based on{" "}
|
||||
{debugData.estimatedProcessingTime.sampleCount}{" "}
|
||||
similar validation
|
||||
{debugData.estimatedProcessingTime
|
||||
.sampleCount !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No historical data available for this prompt size
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No processing time data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Section */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="h-full w-full">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Code className="whitespace-pre-wrap p-4 break-normal max-w-full">
|
||||
{currentPrompt.prompt}
|
||||
</Code>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Validation Progress Dialog */}
|
||||
<Dialog
|
||||
open={aiValidationProgress.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Only allow closing if validation failed
|
||||
if (!open && aiValidationProgress.step === -1) {
|
||||
setAiValidationProgress((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Progress</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{
|
||||
width: `${
|
||||
aiValidationProgress.progressPercent ??
|
||||
Math.round((aiValidationProgress.step / 5) * 100)
|
||||
}%`,
|
||||
backgroundColor:
|
||||
aiValidationProgress.step === -1
|
||||
? "var(--destructive)"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right">
|
||||
{aiValidationProgress.step === -1
|
||||
? "❌"
|
||||
: `${
|
||||
aiValidationProgress.progressPercent ??
|
||||
Math.round((aiValidationProgress.step / 5) * 100)
|
||||
}%`}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{aiValidationProgress.status}
|
||||
</p>
|
||||
{(() => {
|
||||
// Only show time remaining if we have an estimate and are in progress
|
||||
return (
|
||||
aiValidationProgress.estimatedSeconds &&
|
||||
aiValidationProgress.elapsedSeconds !== undefined &&
|
||||
aiValidationProgress.step > 0 &&
|
||||
aiValidationProgress.step < 5 && (
|
||||
<div className="text-center text-sm">
|
||||
{(() => {
|
||||
// Calculate time remaining using the elapsed seconds
|
||||
const elapsedSeconds =
|
||||
aiValidationProgress.elapsedSeconds;
|
||||
const totalEstimatedSeconds =
|
||||
aiValidationProgress.estimatedSeconds;
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
totalEstimatedSeconds - elapsedSeconds
|
||||
);
|
||||
|
||||
// Format time remaining
|
||||
if (remainingSeconds < 60) {
|
||||
return `Approximately ${Math.round(
|
||||
remainingSeconds
|
||||
)} seconds remaining`;
|
||||
} else {
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = Math.round(remainingSeconds % 60);
|
||||
return `Approximately ${minutes}m ${seconds}s remaining`;
|
||||
}
|
||||
})()}
|
||||
{aiValidationProgress.promptLength && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Prompt length:{" "}
|
||||
{aiValidationProgress.promptLength.toLocaleString()}{" "}
|
||||
characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Validation Results Dialog */}
|
||||
<Dialog
|
||||
open={aiValidationDetails.isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setAiValidationDetails((prev) => ({ ...prev, isOpen: open }))
|
||||
}
|
||||
>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the changes and warnings suggested by the AI
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{aiValidationDetails.changeDetails &&
|
||||
aiValidationDetails.changeDetails.length > 0 ? (
|
||||
<div className="mb-6 space-y-6">
|
||||
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
|
||||
{aiValidationDetails.changeDetails.map((product, i) => {
|
||||
// Find the title change if it exists
|
||||
const titleChange = product.changes.find(
|
||||
(c) => c.field === "title"
|
||||
);
|
||||
const titleValue = titleChange
|
||||
? titleChange.corrected
|
||||
: product.title;
|
||||
|
||||
return (
|
||||
<div key={`product-${i}`} className="border rounded-md p-4">
|
||||
<h4 className="font-medium text-base mb-3">
|
||||
{titleValue || `Product ${product.productIndex + 1}`}
|
||||
</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">Field</TableHead>
|
||||
<TableHead>Original Value</TableHead>
|
||||
<TableHead>Corrected Value</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{product.changes.map((change, j) => {
|
||||
const field = fields.find(
|
||||
(f) => f.key === change.field
|
||||
);
|
||||
const fieldLabel = field
|
||||
? field.label
|
||||
: change.field;
|
||||
const isReverted = isChangeReverted(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
|
||||
// Get highlighted differences
|
||||
const { originalHtml, correctedHtml } =
|
||||
getFieldDisplayValueWithHighlight(
|
||||
change.field,
|
||||
change.original,
|
||||
change.corrected
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={`change-${j}`}>
|
||||
<TableCell className="font-medium">
|
||||
{fieldLabel}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: originalHtml,
|
||||
}}
|
||||
className={isReverted ? "font-medium" : ""}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: correctedHtml,
|
||||
}}
|
||||
className={!isReverted ? "font-medium" : ""}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="mt-2">
|
||||
{isReverted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-green-600 bg-green-50 hover:bg-green-100 hover:text-green-700"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 mr-1" />
|
||||
Reverted
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Call the revert function directly
|
||||
revertAiChange(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
}}
|
||||
>
|
||||
Revert Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{aiValidationDetails.warnings &&
|
||||
aiValidationDetails.warnings.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-4">
|
||||
No changes were made, but the AI provided some warnings:
|
||||
</p>
|
||||
<ul className="list-disc pl-8 text-left">
|
||||
{aiValidationDetails.warnings.map((warning, i) => (
|
||||
<li key={`warning-${i}`} className="mb-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p>No changes or warnings were suggested by the AI.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,8 @@ 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'
|
||||
import { Protected } from '@/components/auth/Protected'
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
*
|
||||
@@ -49,7 +49,6 @@ const ValidationContainer = <T extends string>({
|
||||
validationErrors,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
applyTemplate,
|
||||
@@ -60,7 +59,10 @@ const ValidationContainer = <T extends string>({
|
||||
loadTemplates,
|
||||
setData,
|
||||
fields,
|
||||
isLoadingTemplates } = validationState
|
||||
isLoadingTemplates,
|
||||
validatingCells,
|
||||
setValidatingCells
|
||||
} = validationState
|
||||
|
||||
// Use product lines fetching hook
|
||||
const {
|
||||
@@ -72,9 +74,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 +143,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 +299,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 +396,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 +960,7 @@ const ValidationContainer = <T extends string>({
|
||||
isLoadingLines={isLoadingLines}
|
||||
isLoadingSublines={isLoadingSublines}
|
||||
upcValidation={upcValidation}
|
||||
itemNumbers={upcValidation.itemNumbers}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
@@ -1150,9 +1069,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 +1086,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 +1101,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 +1116,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);
|
||||
@@ -1230,15 +1150,17 @@ const ValidationContainer = <T extends string>({
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show Prompt Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={aiValidation.showCurrentPrompt}
|
||||
disabled={aiValidation.isAiValidating}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Show Prompt
|
||||
</Button>
|
||||
<Protected permission="admin:debug">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={aiValidation.showCurrentPrompt}
|
||||
disabled={aiValidation.isAiValidating}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Show Prompt
|
||||
</Button>
|
||||
</Protected>
|
||||
|
||||
{/* AI Validate Button */}
|
||||
<Button
|
||||
@@ -1281,6 +1203,7 @@ const ValidationContainer = <T extends string>({
|
||||
isChangeReverted={aiValidation.isChangeReverted}
|
||||
getFieldDisplayValueWithHighlight={aiValidation.getFieldDisplayValueWithHighlight}
|
||||
fields={fields}
|
||||
debugData={aiValidation.currentPrompt.debugData}
|
||||
/>
|
||||
|
||||
{/* Product Search Dialog */}
|
||||
@@ -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" : ""
|
||||
)}
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -42,6 +42,25 @@ export interface CurrentPrompt {
|
||||
isOpen: boolean;
|
||||
prompt: string | null;
|
||||
isLoading: boolean;
|
||||
debugData?: {
|
||||
taxonomyStats: {
|
||||
categories: number;
|
||||
themes: number;
|
||||
colors: number;
|
||||
taxCodes: number;
|
||||
sizeCategories: number;
|
||||
suppliers: number;
|
||||
companies: number;
|
||||
artists: number;
|
||||
} | null;
|
||||
basePrompt: string;
|
||||
sampleFullPrompt: string;
|
||||
promptLength: number;
|
||||
estimatedProcessingTime?: {
|
||||
seconds: number | null;
|
||||
sampleCount: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Declare global interface for the timer
|
||||
@@ -250,7 +269,11 @@ export const useAiValidation = <T extends string>(
|
||||
// Function to show current prompt
|
||||
const showCurrentPrompt = useCallback(async () => {
|
||||
try {
|
||||
setCurrentPrompt(prev => ({ ...prev, isLoading: true, isOpen: true }));
|
||||
setCurrentPrompt(prev => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
isOpen: true
|
||||
}));
|
||||
|
||||
// Debug log the data being sent
|
||||
console.log('Sending products data:', {
|
||||
@@ -272,7 +295,7 @@ export const useAiValidation = <T extends string>(
|
||||
});
|
||||
|
||||
// Use POST to send products in request body
|
||||
const response = await fetch(`${getApiUrl()}/ai-validation/debug`, {
|
||||
const response = await fetch(`${await getApiUrl()}/ai-validation/debug`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -294,7 +317,14 @@ export const useAiValidation = <T extends string>(
|
||||
setCurrentPrompt(prev => ({
|
||||
...prev,
|
||||
prompt: promptContent,
|
||||
isLoading: false
|
||||
isLoading: false,
|
||||
debugData: {
|
||||
taxonomyStats: result.taxonomyStats || null,
|
||||
basePrompt: result.basePrompt || '',
|
||||
sampleFullPrompt: result.sampleFullPrompt || '',
|
||||
promptLength: result.promptLength || (promptContent ? promptContent.length : 0),
|
||||
estimatedProcessingTime: result.estimatedProcessingTime
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
throw new Error('No prompt returned from server');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user