Compare commits
31 Commits
af067f7360
...
add-compan
| Author | SHA1 | Date | |
|---|---|---|---|
| 00a02aa788 | |||
| 114018080a | |||
| 228ae8b2a9 | |||
| dd4b3f7145 | |||
| 7eb4077224 | |||
| 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' });
|
||||
|
||||
@@ -23,6 +23,32 @@ CREATE TABLE IF NOT EXISTS templates (
|
||||
UNIQUE(company, product_type)
|
||||
);
|
||||
|
||||
-- AI Prompts table for storing validation prompts
|
||||
CREATE TABLE IF NOT EXISTS ai_prompts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
prompt_text TEXT NOT NULL,
|
||||
prompt_type TEXT NOT NULL CHECK (prompt_type IN ('general', 'company_specific', 'system')),
|
||||
company TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_company_prompt UNIQUE (company),
|
||||
CONSTRAINT company_required_for_specific CHECK (
|
||||
(prompt_type = 'general' AND company IS NULL) OR
|
||||
(prompt_type = 'system' AND company IS NULL) OR
|
||||
(prompt_type = 'company_specific' AND company IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Create a unique partial index to ensure only one general prompt
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_general_prompt
|
||||
ON ai_prompts (prompt_type)
|
||||
WHERE prompt_type = 'general';
|
||||
|
||||
-- Create a unique partial index to ensure only one system prompt
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_system_prompt
|
||||
ON ai_prompts (prompt_type)
|
||||
WHERE prompt_type = 'system';
|
||||
|
||||
-- AI Validation Performance Tracking
|
||||
CREATE TABLE IF NOT EXISTS ai_validation_performance (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -50,4 +76,10 @@ $$ language 'plpgsql';
|
||||
CREATE TRIGGER update_templates_updated_at
|
||||
BEFORE UPDATE ON templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Trigger to automatically update the updated_at column for ai_prompts
|
||||
CREATE TRIGGER update_ai_prompts_updated_at
|
||||
BEFORE UPDATE ON ai_prompts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
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,226 +0,0 @@
|
||||
I will provide a JSON array with product data. Process the array by combining all products from validData and invalidData arrays into a single array, excluding any fields starting with “__”, such as “__index” or “__errors”. Process each product according to the reference guidelines below. If a field is not included in the data, do not include it in your response (e.g. do not include its key or any value) unless the specific field guidelines below say otherwise. If a product appears to be from an empty or entirely invalid line, do not include it in your response.
|
||||
|
||||
Your response should be a JSON object with the following structure:
|
||||
{
|
||||
"correctedData": [], // Array of corrected products
|
||||
"changes": [], // Array of strings describing each change made
|
||||
"warnings": [] // Array of strings with warnings or suggestions for manual review (see below for details)
|
||||
}
|
||||
|
||||
IMPORTANT: For all fields that use IDs (categories, supplier, company, line, subline, ship_restrictions, tax_cat, artist, themes, etc.), you MUST return the ID values, not the display names. The system will handle converting IDs to display names.
|
||||
|
||||
Using the provided guidelines, focus on:
|
||||
1. Correcting typos and any incorrect spelling or grammar
|
||||
2. Standardizing product names
|
||||
3. Correcting and enhancing descriptions by adding details, keywords, and SEO-friendly language
|
||||
4. Fixing any obvious errors or inconsistencies between similar products in measurements, prices, or quantities
|
||||
5. Adding correct categories, themes, and colors
|
||||
|
||||
Use only the provided data and your own knowledge to make changes. Do not make assumptions or make up information that you're not sure about. If you're unable to make a change you're confident about, leave the field as is. All data passed in should be validated, corrected, and returned. All values returned should be strings, not numbers. Do not leave out any fields that were present in the original data.
|
||||
|
||||
Possible reasons for including a warning in the warnings array:
|
||||
- If you're unable to make a change you're confident about but you believe one needs to be made
|
||||
- If there are inconsistencies in the data that could be valid but need to be reviewed
|
||||
- If not enough information is provided to make a change that you believe is needed
|
||||
- If you infer a value for a required field based on context
|
||||
|
||||
|
||||
----------PRODUCT FIELD GUIDELINES----------
|
||||
|
||||
Fields: supplier, private_notes, company, line, subline, artist
|
||||
Changes: Not allowed
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, return these fields exactly as provided with no changes
|
||||
|
||||
Fields: upc, supplier_no, notions_no, item_number
|
||||
Changes: Formatting only
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, trim outside white space and return these fields exactly as provided with no other changes
|
||||
|
||||
Fields: hts_code
|
||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, trim white space and any non-numeric characters, then return as a string. Do not validate in any other way.
|
||||
|
||||
Fields: image_url
|
||||
Changes: Formatting only
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, convert all comma-separated values to valid https:// URLs and return
|
||||
|
||||
Fields: msrp, cost_each
|
||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, strip any currency symbols and return as a string with exactly two decimal places, even if the last place is a 0.
|
||||
|
||||
Fields: qty_per_unit, case_qty
|
||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, strip non-numeric characters and return
|
||||
|
||||
Fields: ship_restrictions
|
||||
Changes: Only add a value if it's not already present
|
||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0.
|
||||
Instructions: Always return a value exactly as provided, or return 0 if no value is provided.
|
||||
|
||||
Fields: eta
|
||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, return a full month name, day is optional, no year ever (e.g. “January” or “March 3”). This value is not required if not provided.
|
||||
|
||||
Fields: name
|
||||
Changes: Allowed to conform to guidelines, to fix typos or formatting
|
||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most reasonable value possible based on the naming guidelines and the other information you have.
|
||||
Instructions: Always return a value that is corrected and enhanced per additional guidelines below
|
||||
|
||||
Fields: description
|
||||
Changes: Full creative control allowed within guidelines
|
||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most accurate description possible based on the description guidelines and the other information you have.
|
||||
Instructions: Always return a value that is corrected and enhanced per additional guidelines below
|
||||
|
||||
Fields: weight, length, width, height
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return your best guess based on the other information you have or the dimensions for similar products.
|
||||
Instructions: Always return a reasonable value (weights in ounces and dimensions in inches) that is validated against similar provided products and your knowledge of general object measurements (e.g. a sheet of paper is not going to be 3 inches thick, a pack of stickers is not going to be 250 ounces, this sheet of paper is very likely going to be the same size as that other sheet of paper from the same line). If a value is unusual or unreasonable, even wildly so, change it to match similar products or to be more reasonable. When correcting unreasonable weights or dimensions, prioritize comparisons to products from the same company and product line first, then broader category matches or common knowledge if necessary.Do not return 0 or null for any of these fields.
|
||||
|
||||
Fields: coo
|
||||
Changes: Formatting only
|
||||
Required: Return if present in the original data. Do not return if not present.
|
||||
Instructions: If present, convert all country names and abbreviations to the official ISO 3166-1 alpha-2 two-character country code. Convert any value with more than two characters to two characters only (e.g. "United States" or "USA" should both return "US").
|
||||
|
||||
Fields: tax_cat
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0.
|
||||
Instructions: Always return a valid numerical tax code ID from the Available Tax Codes array below. Give preference to the value provided, but correct it if another value is more accurate. You must return a value for this field. 0 should be the default value in most cases.
|
||||
|
||||
Fields: size_cat
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: Return if present in the original data or if not present and applicable. Do not return if not applicable (e.g. if no size categories apply based on what you know about the product).
|
||||
Instructions: If present or if applicable, return one valid numerical size category ID from the Available Size Categories array below. Give preference to the value provided, but correct it if another value is more accurate. If the product name contains a match for one of the size categories (such as 12x12, 6x6, 2oz, etc) you MUST return that size category with the results. A value is not required if none of the size categories apply.
|
||||
|
||||
Fields: themes
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no themes apply based on what you know about the product).
|
||||
Instructions: If present, confirm that each provided theme matches what you understand to be a theme of the product. Remove any themes that do not match and add any themes that are missing. Most products will have zero or one theme. Return a comma-separated list of numerical theme IDs from the Available Themes array below. If you choose a sub-theme, you do not need to include its parent theme in the list.
|
||||
|
||||
Fields: colors
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no colors apply based on what you know about the product).
|
||||
Instructions: If present or if applicable, return a comma-separated list of numerical color IDs from the Available Colors array below, using the product name as the primary guide (e.g. if the name contains Blue or a blue variant, you should return the blue color ID). A value is not required if none of the colors apply. Most products will have zero colors.
|
||||
|
||||
Fields: categories
|
||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
||||
Required: You must always return at least one value for this field, even if it's not provided in the original data. If no value is provided, return the most appropriate category or categories based on the other information you have.
|
||||
Instructions: Always return a comma-separated list of one or more valid numerical category IDs from the Available Categories array below. Give preference to the values provided, particularly if the other information isn't enough to determine a category, but correct them or add new categories if another value is more accurate. Do not return categories in the Deals or Black Friday categories, and strip these from the list if present. If you choose a subcategory at any level, you do not need to include its parent categories in the list. You must return at least one category and you can return multiple categories if applicable. All categories have equal value so their order is not important. Always try to return the most specific categories possible (e.g. one in the third level of the category hierarchy is better than one in the second level).
|
||||
|
||||
----------PRODUCT NAMING GUIDELINES----------
|
||||
If there's only one of this type of product in a line: [Line Name] [Product Name] - [Company]
|
||||
Example: "Cosmos Infinity Chipboard - Stamperia"
|
||||
Example: "Serene Petals 6x6 Paper Pad - Prima"
|
||||
|
||||
Multiple similar products in a line: [Differentiator] [Product Type] - [Line Name] - [Company]
|
||||
Example: "Ice & Shells Stencil - Arctic Antarctic - Stamperia"
|
||||
Example: "Astronomy Paper - Cosmos Infinity - Stamperia"
|
||||
|
||||
Standalone products: [Product Name] - [Company]
|
||||
Example: "Hedwig Puffy Stickers - Paper House Productions"
|
||||
Example: "Heart Tree Dies - Lawn Fawn"
|
||||
|
||||
Color-based products: [Color] [Product Name] - [Company]
|
||||
Example: "Green Valley Enamel Dots - Altenew"
|
||||
Example: "Magenta Aqua Pigment - Brutus Monroe"
|
||||
|
||||
Complex products: [Differentiator] [Line] [Product Type] - [Company]
|
||||
Example: "Size 6 Round Black Velvet Watercolor Brush - Silver Brush Limited" (Size 6 Round is the differentiator, Black Velvet is the line, Watercolor Brush is the product type)
|
||||
|
||||
These should not be included in the name, unless there are multiple products that are otherwise identical:
|
||||
- Product size
|
||||
- Product weight
|
||||
- Number of pages
|
||||
- How many are in the package
|
||||
|
||||
Naming Conventions:
|
||||
- Paper sizes: Use "12x12", "8x8", "6x6" (no spaces or units of measure)
|
||||
- Company names must match backend exactly
|
||||
- Always capitalize every word in the name, including short articles like "The" and "An"
|
||||
- Use "Idea-ology" (not "idea-ology" or "Ideaology")
|
||||
- All stamps are "Stamp Set" (not "Clear Stamps" or "Rubber Stamps")
|
||||
- All dies are "Dies" or "Die" (not "Die Set")
|
||||
- Brands with their own naming conventions should be respected, such as "Doodle Cuts" for dies from Doodlebug
|
||||
|
||||
Special Brand Rules - Ranger:
|
||||
Format: [Product Name] - [Designer Line] - Ranger
|
||||
Possible Designers: Dylusions, Dina Wakley MEdia, Simon Hurley create., Wendy Vecchi
|
||||
Example: "Stacked Stencil - Dina Wakley MEdia - Ranger"
|
||||
|
||||
Special Brand Rules - Tim Holtz products from Ranger:
|
||||
Format: [Color] [Product Name/Type] - Tim Holtz Distress - Ranger
|
||||
Example: "Mermaid Lagoon Tim Holtz Distress Oxide Ink Pad - Ranger"
|
||||
|
||||
Special Brand Rules - Tim Holtz products from Sizzix or Stampers Anonymous:
|
||||
Format: [Product Name] [Product Type] by Tim Holtz - [Company]
|
||||
Example: "Leaf Fragments Thinlits Dies by Tim Holtz - Sizzix"
|
||||
|
||||
Special Brand Rules - Tim Holtz products from Advantus/Idea-ology:
|
||||
Format: [Product Name] - Tim Holtz Idea-ology
|
||||
Example: "Tiny Vials - Tim Holtz Idea-ology"
|
||||
|
||||
Special Brand Rules - Dies from Sizzix:
|
||||
Include die type plus "Dies" or "Die"
|
||||
Examples:
|
||||
"Art Nouveau 3-D Textured Impressions Embossing Folder - Sizzix"
|
||||
"Pocket Pals Thinlits Dies - Sizzix"
|
||||
"Butterfly Wishes Framelits Dies & Stamps - Sizzix"
|
||||
|
||||
Important Notes
|
||||
- Ensure that product names are consistent across all products of the same type
|
||||
- Use the minimum amount of information needed to uniquely identify the product
|
||||
- Put detailed specifications in the product description, not its name
|
||||
|
||||
Edge Cases
|
||||
- If the product is missing a company name, infer one from the other products included in the data
|
||||
- If the product is missing a clear differentiator and needs one to be unique, infer and add one from the other data provided (e.g. the description, existing size categories, etc.)
|
||||
|
||||
Incorrect example: MVP Rugby - Collection Pack - Photoplay
|
||||
Notes: there should be no dash between the line and the product
|
||||
|
||||
Incorrect Example: A2 Easel Cards - Black - Photoplay
|
||||
Notes: the differentiating factor should come first: “Black A2 Easel Cards - Photoplay”. Size is ok to include here because this is the name printed on the package.
|
||||
|
||||
Incorrect Example: 6” - Scriber Needle Modeling Tool
|
||||
Notes: this product only comes in one size, so 6” isn’t needed. The company name should also be included.
|
||||
|
||||
Incorrect Example: Slick - White - Tulip Dimensional Fabric Paint 4oz
|
||||
Notes: color should be first, then type, then product, then company, so “White Slick Dimensional Fabric Paint - Tulip”. It appears there’s only one size available so no need to differentiate in the name.
|
||||
|
||||
Incorrect Example: Silhouette Adhesive Cork Sheets 5”X7” 8/Pkg
|
||||
Notes: should be “Adhesive Cork Sheets - Silhouette”
|
||||
|
||||
Incorrect Example: Galaxy - Opaque - American Crafts Color Pour Resin Dyes
|
||||
Notes: “Galaxy Opaque Dye Set - Color Pour Resin - American Crafts”
|
||||
|
||||
Incorrect Example: Slate - Lion Brand Truboo Yarn
|
||||
Notes: [Differentiator] [Line] [Product Type] - [Company] : “Slate Truboo Yarn - Lion Brand”
|
||||
|
||||
Incorrect Example: Rose Quartz Dylusions Shimmer Paint
|
||||
Notes: “Rose Quartz Shimmer Paint - Dylusions - Ranger”
|
||||
|
||||
|
||||
----------PRODUCT DESCRIPTION GUIDELINES----------
|
||||
Product descriptions are an extremely important part of the listing and are the most important part of your response. Care should be taken to ensure they are correct, helpful, and SEO-friendly.
|
||||
|
||||
If a description is provided in the data, use it as a starting point. Correct any spelling errors, typos, poor grammar, or awkward phrasing. If necessary and you have the information, add more details, describe how the customer could use it, etc. Use complete sentences and keep SEO in mind.
|
||||
|
||||
If no description is provided, make one up using the product name, the information you have, and the other provided guidelines. At minimum, a description should be one complete sentence that starts with a capital letter and ends with a period. Unless the product is extremely complex, 2-4 sentences is usually sufficient if you have enough information.
|
||||
|
||||
Important Notes:
|
||||
- Every description should state exactly what's included in the product (e.g. "Includes one 12x12 sheet of patterned cardstock." or "Includes one 6x12 sheet with 27 unique stickers." or "Includes 55 pieces." or "Package includes machine, power cord, 12 sheets of cardstock, 3 dies, and project instructions.")
|
||||
- Do not use the word "our" in the description (this usually shows up when we copy a description from the manufacturer). Instead use "these" or "[Company name] [product]" or similar. (e.g. don't use "Our journals are hand-made in the USA", instead use "These journals are hand made..." or "Archer & Olive journals are handmade...")
|
||||
- Don't include statements that add no value like “this is perfect for all your paper crafts”. If the product helps to solve a unique problem or has a unique feature, by all means describe it, but if it’s just a normal sheet of paper or pack of stickers, you don’t have to pretend like it’s the best thing ever. At the same time, ensure that you add enough copy to ensure good SEO.
|
||||
- State as many facts as you can about the product, considering the viewpoint of the customer and what they would want to know when looking at it. They probably want to know dimensions, what products it’s compatible with, how thick the paper is, how many sheets are included, whether the sheets are double-sided or not, which items are in the kit, etc. Say as much as you possibly can with the information that you have.
|
||||
- !!DO NOT make up information if you aren't sure about it. A minimal correct description is better than a long incorrect one!!
|
||||
|
||||
Avoid/remove:
|
||||
- The word "Imported"
|
||||
- Any warnings about Prop 65, choking hazards, etc
|
||||
- The manufacturer's name if it's included as the very first thing in the description
|
||||
- Any statement similar to "comes in a variety of colors, each sold separately"
|
||||
335
inventory-server/src/routes/ai-prompts.js
Normal file
335
inventory-server/src/routes/ai-prompts.js
Normal file
@@ -0,0 +1,335 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all AI prompts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
ORDER BY prompt_type ASC, company ASC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI prompts:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch AI prompts',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get prompt by ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'AI prompt not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI prompt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get prompt by company
|
||||
router.get('/company/:companyId', async (req, res) => {
|
||||
try {
|
||||
const { companyId } = req.params;
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE company = $1
|
||||
`, [companyId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'AI prompt not found for this company' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI prompt by company:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch AI prompt by company',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get general prompt
|
||||
router.get('/type/general', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'general'
|
||||
`);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'General AI prompt not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching general AI prompt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch general AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get system prompt
|
||||
router.get('/type/system', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'system'
|
||||
`);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'System AI prompt not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system AI prompt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch system AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create new AI prompt
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
prompt_text,
|
||||
prompt_type,
|
||||
company
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!prompt_text || !prompt_type) {
|
||||
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||
}
|
||||
|
||||
// Validate prompt type
|
||||
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
||||
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
||||
}
|
||||
|
||||
// Validate company is provided for company-specific prompts
|
||||
if (prompt_type === 'company_specific' && !company) {
|
||||
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
|
||||
}
|
||||
|
||||
// Validate company is not provided for general or system prompts
|
||||
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
||||
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
||||
}
|
||||
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO ai_prompts (
|
||||
prompt_text,
|
||||
prompt_type,
|
||||
company
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`, [
|
||||
prompt_text,
|
||||
prompt_type,
|
||||
company
|
||||
]);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating AI prompt:', error);
|
||||
|
||||
// Check for unique constraint violations
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
if (error.message.includes('unique_company_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A prompt already exists for this company',
|
||||
details: error.message
|
||||
});
|
||||
} else if (error.message.includes('idx_unique_general_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A general prompt already exists',
|
||||
details: error.message
|
||||
});
|
||||
} else if (error.message.includes('idx_unique_system_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A system prompt already exists',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to create AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update AI prompt
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
prompt_text,
|
||||
prompt_type,
|
||||
company
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!prompt_text || !prompt_type) {
|
||||
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||
}
|
||||
|
||||
// Validate prompt type
|
||||
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
||||
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
||||
}
|
||||
|
||||
// Validate company is provided for company-specific prompts
|
||||
if (prompt_type === 'company_specific' && !company) {
|
||||
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
|
||||
}
|
||||
|
||||
// Validate company is not provided for general or system prompts
|
||||
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
||||
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
||||
}
|
||||
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
// Check if the prompt exists
|
||||
const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]);
|
||||
if (checkResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'AI prompt not found' });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE ai_prompts
|
||||
SET
|
||||
prompt_text = $1,
|
||||
prompt_type = $2,
|
||||
company = $3
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`, [
|
||||
prompt_text,
|
||||
prompt_type,
|
||||
company,
|
||||
id
|
||||
]);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating AI prompt:', error);
|
||||
|
||||
// Check for unique constraint violations
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
if (error.message.includes('unique_company_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A prompt already exists for this company',
|
||||
details: error.message
|
||||
});
|
||||
} else if (error.message.includes('idx_unique_general_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A general prompt already exists',
|
||||
details: error.message
|
||||
});
|
||||
} else if (error.message.includes('idx_unique_system_prompt')) {
|
||||
return res.status(409).json({
|
||||
error: 'A system prompt already exists',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to update AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete AI prompt
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query('DELETE FROM ai_prompts WHERE id = $1 RETURNING *', [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'AI prompt not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'AI prompt deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting AI prompt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete AI prompt',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
router.use((err, req, res, next) => {
|
||||
console.error('AI prompts route error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
details: err.message
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -289,8 +289,108 @@ async function generateDebugResponse(productsToUse, res) {
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = await loadPrompt(promptConnection, productsToUse);
|
||||
const fullPrompt = prompt + "\n" + JSON.stringify(productsToUse);
|
||||
// Get the local PostgreSQL pool to fetch prompts
|
||||
const pool = res.app.locals.pool;
|
||||
if (!pool) {
|
||||
console.warn("⚠️ Local database pool not available for prompts");
|
||||
throw new Error("Database connection not available");
|
||||
}
|
||||
|
||||
// First, fetch the system prompt
|
||||
const systemPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'system'
|
||||
`);
|
||||
|
||||
// Get system prompt or use default
|
||||
let systemPrompt = null;
|
||||
if (systemPromptResult.rows.length > 0) {
|
||||
systemPrompt = systemPromptResult.rows[0];
|
||||
console.log("📝 Loaded system prompt from database, ID:", systemPrompt.id);
|
||||
} else {
|
||||
console.warn("⚠️ No system prompt found in database, will use default");
|
||||
}
|
||||
|
||||
// Then, fetch the general prompt
|
||||
const generalPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'general'
|
||||
`);
|
||||
|
||||
if (generalPromptResult.rows.length === 0) {
|
||||
console.warn("⚠️ No general prompt found in database");
|
||||
throw new Error("No general prompt found in database");
|
||||
}
|
||||
|
||||
// Get the general prompt text and info
|
||||
const generalPrompt = generalPromptResult.rows[0];
|
||||
console.log("📝 Loaded general prompt from database, ID:", generalPrompt.id);
|
||||
|
||||
// Fetch company-specific prompts if we have products to validate
|
||||
let companyPrompts = [];
|
||||
if (productsToUse && Array.isArray(productsToUse)) {
|
||||
// Extract unique company IDs from products
|
||||
const companyIds = new Set();
|
||||
productsToUse.forEach(product => {
|
||||
if (product.company) {
|
||||
companyIds.add(String(product.company));
|
||||
}
|
||||
});
|
||||
|
||||
if (companyIds.size > 0) {
|
||||
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
||||
|
||||
// Fetch company-specific prompts
|
||||
const companyPromptsResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'company_specific'
|
||||
AND company = ANY($1)
|
||||
`, [Array.from(companyIds)]);
|
||||
|
||||
companyPrompts = companyPromptsResult.rows;
|
||||
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find company names from taxonomy for the validation endpoint
|
||||
const companyPromptsWithNames = companyPrompts.map(prompt => {
|
||||
let companyName = "Unknown Company";
|
||||
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||
const companyData = taxonomy.companies.find(company =>
|
||||
String(company[0]) === String(prompt.company)
|
||||
);
|
||||
if (companyData && companyData[1]) {
|
||||
companyName = companyData[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: prompt.id,
|
||||
company: prompt.company,
|
||||
companyName: companyName,
|
||||
prompt_text: prompt.prompt_text
|
||||
};
|
||||
});
|
||||
|
||||
// Now use loadPrompt to get the actual combined prompt
|
||||
const promptData = await loadPrompt(promptConnection, productsToUse, res.app.locals.pool);
|
||||
const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(productsToUse);
|
||||
const promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics
|
||||
console.log("📝 Generated prompt length:", promptLength);
|
||||
console.log("📝 System instructions length:", promptData.systemInstructions.length);
|
||||
console.log("📝 User content length:", fullUserPrompt.length);
|
||||
|
||||
// Format the messages as they would be sent to the API
|
||||
const apiMessages = [
|
||||
{
|
||||
role: "system",
|
||||
content: promptData.systemInstructions
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: fullUserPrompt
|
||||
}
|
||||
];
|
||||
|
||||
// Create the response with taxonomy stats
|
||||
let categoriesCount = 0;
|
||||
@@ -330,9 +430,28 @@ async function generateDebugResponse(productsToUse, res) {
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
basePrompt: prompt,
|
||||
sampleFullPrompt: fullPrompt,
|
||||
promptLength: fullPrompt.length,
|
||||
basePrompt: systemPrompt ? systemPrompt.prompt_text + "\n\n" + generalPrompt.prompt_text : generalPrompt.prompt_text,
|
||||
sampleFullPrompt: fullUserPrompt,
|
||||
promptLength: promptLength,
|
||||
apiFormat: apiMessages,
|
||||
promptSources: {
|
||||
...(systemPrompt ? {
|
||||
systemPrompt: {
|
||||
id: systemPrompt.id,
|
||||
prompt_text: systemPrompt.prompt_text
|
||||
}
|
||||
} : {
|
||||
systemPrompt: {
|
||||
id: 0,
|
||||
prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`
|
||||
}
|
||||
}),
|
||||
generalPrompt: {
|
||||
id: generalPrompt.id,
|
||||
prompt_text: generalPrompt.prompt_text
|
||||
},
|
||||
companyPrompts: companyPromptsWithNames
|
||||
}
|
||||
};
|
||||
|
||||
console.log("Sending response with taxonomy stats:", response.taxonomyStats);
|
||||
@@ -513,22 +632,101 @@ SELECT t.cat_id,t.name,null as master_cat_id,1 AS level_order FROM product_categ
|
||||
}
|
||||
}
|
||||
|
||||
// Load the prompt from file and inject taxonomy data
|
||||
async function loadPrompt(connection, productsToValidate = null) {
|
||||
// Load prompts from database and inject taxonomy data
|
||||
async function loadPrompt(connection, productsToValidate = null, appPool = null) {
|
||||
try {
|
||||
const promptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"prompts",
|
||||
"product-validation.txt"
|
||||
);
|
||||
const basePrompt = await fs.readFile(promptPath, "utf8");
|
||||
|
||||
// Get taxonomy data using the provided MySQL connection
|
||||
const taxonomy = await getTaxonomyData(connection);
|
||||
|
||||
// Add system instructions to the prompt
|
||||
const systemInstructions = `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`;
|
||||
// Use the provided pool parameter instead of global.app
|
||||
const pool = appPool;
|
||||
if (!pool) {
|
||||
console.warn("⚠️ Local database pool not available for prompts");
|
||||
throw new Error("Database connection not available");
|
||||
}
|
||||
|
||||
// Fetch the system prompt
|
||||
const systemPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'system'
|
||||
`);
|
||||
|
||||
// Default system instructions in case the system prompt is not found
|
||||
let systemInstructions = `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`;
|
||||
|
||||
// If system prompt exists in the database, use it
|
||||
if (systemPromptResult.rows.length > 0) {
|
||||
systemInstructions = systemPromptResult.rows[0].prompt_text;
|
||||
console.log("📝 Loaded system prompt from database");
|
||||
} else {
|
||||
console.warn("⚠️ No system prompt found in database, using default");
|
||||
}
|
||||
|
||||
// Fetch the general prompt
|
||||
const generalPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'general'
|
||||
`);
|
||||
|
||||
if (generalPromptResult.rows.length === 0) {
|
||||
console.warn("⚠️ No general prompt found in database");
|
||||
throw new Error("No general prompt found in database");
|
||||
}
|
||||
|
||||
// Get the general prompt text
|
||||
const basePrompt = generalPromptResult.rows[0].prompt_text;
|
||||
console.log("📝 Loaded general prompt from database");
|
||||
|
||||
// Fetch company-specific prompts if we have products to validate
|
||||
let companyPrompts = [];
|
||||
if (productsToValidate && Array.isArray(productsToValidate)) {
|
||||
// Extract unique company IDs from products
|
||||
const companyIds = new Set();
|
||||
productsToValidate.forEach(product => {
|
||||
if (product.company) {
|
||||
companyIds.add(String(product.company));
|
||||
}
|
||||
});
|
||||
|
||||
if (companyIds.size > 0) {
|
||||
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
||||
|
||||
// Fetch company-specific prompts
|
||||
const companyPromptsResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'company_specific'
|
||||
AND company = ANY($1)
|
||||
`, [Array.from(companyIds)]);
|
||||
|
||||
companyPrompts = companyPromptsResult.rows;
|
||||
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine prompts - start with the general prompt
|
||||
let combinedPrompt = basePrompt;
|
||||
|
||||
// Add any company-specific prompts with annotations
|
||||
if (companyPrompts.length > 0) {
|
||||
combinedPrompt += "\n\n--- COMPANY-SPECIFIC INSTRUCTIONS ---\n";
|
||||
|
||||
for (const prompt of companyPrompts) {
|
||||
// Find company name from taxonomy
|
||||
let companyName = "Unknown Company";
|
||||
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||
const companyData = taxonomy.companies.find(company =>
|
||||
String(company[0]) === String(prompt.company)
|
||||
);
|
||||
if (companyData && companyData[1]) {
|
||||
companyName = companyData[1];
|
||||
}
|
||||
}
|
||||
|
||||
combinedPrompt += `\n[SPECIFIC TO COMPANY: ${companyName} (ID: ${prompt.company})]:\n${prompt.prompt_text}\n`;
|
||||
}
|
||||
|
||||
combinedPrompt += "\n--- END COMPANY-SPECIFIC INSTRUCTIONS ---\n";
|
||||
}
|
||||
|
||||
// If we have products to validate, create a filtered prompt
|
||||
if (productsToValidate) {
|
||||
@@ -655,11 +853,14 @@ ${JSON.stringify(mixedTaxonomy.sizeCategories)}${
|
||||
|
||||
----------Here is the product data to validate----------`;
|
||||
|
||||
// Return the filtered prompt
|
||||
return systemInstructions + basePrompt + "\n" + taxonomySection;
|
||||
// Return both system instructions and user content separately
|
||||
return {
|
||||
systemInstructions,
|
||||
userContent: combinedPrompt + "\n" + taxonomySection
|
||||
};
|
||||
}
|
||||
|
||||
// Generate the full unfiltered prompt
|
||||
// Generate the full unfiltered prompt for taxonomy section
|
||||
const taxonomySection = `
|
||||
Available Categories:
|
||||
${JSON.stringify(taxonomy.categories)}
|
||||
@@ -687,7 +888,11 @@ ${JSON.stringify(taxonomy.artists)}
|
||||
|
||||
Here is the product data to validate:`;
|
||||
|
||||
return systemInstructions + basePrompt + "\n" + taxonomySection;
|
||||
// Return both system instructions and user content separately
|
||||
return {
|
||||
systemInstructions,
|
||||
userContent: combinedPrompt + "\n" + taxonomySection
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading prompt:", error);
|
||||
throw error; // Re-throw to be handled by the calling function
|
||||
@@ -735,18 +940,24 @@ router.post("/validate", async (req, res) => {
|
||||
|
||||
// Load the prompt with the products data to filter taxonomy
|
||||
console.log("🔄 Loading prompt with filtered taxonomy...");
|
||||
const prompt = await loadPrompt(connection, products);
|
||||
const fullPrompt = prompt + "\n" + JSON.stringify(products);
|
||||
promptLength = fullPrompt.length; // Store prompt length for performance metrics
|
||||
const promptData = await loadPrompt(connection, products, req.app.locals.pool);
|
||||
const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(products);
|
||||
const promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics
|
||||
console.log("📝 Generated prompt length:", promptLength);
|
||||
console.log("📝 System instructions length:", promptData.systemInstructions.length);
|
||||
console.log("📝 User content length:", fullUserPrompt.length);
|
||||
|
||||
console.log("🤖 Sending request to OpenAI...");
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "o3-mini",
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: promptData.systemInstructions,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: fullPrompt,
|
||||
content: fullUserPrompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
@@ -884,7 +1095,94 @@ router.post("/validate", async (req, res) => {
|
||||
console.error("⚠️ Failed to record performance metrics:", metricError);
|
||||
}
|
||||
|
||||
// Include performance metrics in the response
|
||||
// Get sources of the prompts for tracking
|
||||
let promptSources = null;
|
||||
|
||||
try {
|
||||
// Get system prompt
|
||||
const systemPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts WHERE prompt_type = 'system'
|
||||
`);
|
||||
|
||||
// Get general prompt
|
||||
const generalPromptResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts WHERE prompt_type = 'general'
|
||||
`);
|
||||
|
||||
// Extract unique company IDs from products
|
||||
const companyIds = new Set();
|
||||
products.forEach(product => {
|
||||
if (product.company) {
|
||||
companyIds.add(String(product.company));
|
||||
}
|
||||
});
|
||||
|
||||
let companyPrompts = [];
|
||||
if (companyIds.size > 0) {
|
||||
// Fetch company-specific prompts
|
||||
const companyPromptsResult = await pool.query(`
|
||||
SELECT * FROM ai_prompts
|
||||
WHERE prompt_type = 'company_specific'
|
||||
AND company = ANY($1)
|
||||
`, [Array.from(companyIds)]);
|
||||
|
||||
companyPrompts = companyPromptsResult.rows;
|
||||
}
|
||||
|
||||
// Find company names from taxonomy for the validation endpoint
|
||||
const companyPromptsWithNames = companyPrompts.map(prompt => {
|
||||
let companyName = "Unknown Company";
|
||||
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||
const companyData = taxonomy.companies.find(company =>
|
||||
String(company[0]) === String(prompt.company)
|
||||
);
|
||||
if (companyData && companyData[1]) {
|
||||
companyName = companyData[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: prompt.id,
|
||||
company: prompt.company,
|
||||
companyName: companyName,
|
||||
prompt_text: prompt.prompt_text
|
||||
};
|
||||
});
|
||||
|
||||
// Set prompt sources
|
||||
if (generalPromptResult.rows.length > 0) {
|
||||
const generalPrompt = generalPromptResult.rows[0];
|
||||
let systemPrompt = null;
|
||||
|
||||
if (systemPromptResult.rows.length > 0) {
|
||||
systemPrompt = systemPromptResult.rows[0];
|
||||
}
|
||||
|
||||
promptSources = {
|
||||
...(systemPrompt ? {
|
||||
systemPrompt: {
|
||||
id: systemPrompt.id,
|
||||
prompt_text: systemPrompt.prompt_text
|
||||
}
|
||||
} : {
|
||||
systemPrompt: {
|
||||
id: 0,
|
||||
prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`
|
||||
}
|
||||
}),
|
||||
generalPrompt: {
|
||||
id: generalPrompt.id,
|
||||
prompt_text: generalPrompt.prompt_text
|
||||
},
|
||||
companyPrompts: companyPromptsWithNames
|
||||
};
|
||||
}
|
||||
} catch (promptSourceError) {
|
||||
console.error("⚠️ Error getting prompt sources:", promptSourceError);
|
||||
// Don't fail the entire validation if just prompt sources retrieval fails
|
||||
}
|
||||
|
||||
// Include prompt sources in the response
|
||||
res.json({
|
||||
success: true,
|
||||
changeDetails: changeDetails,
|
||||
@@ -895,6 +1193,7 @@ router.post("/validate", async (req, res) => {
|
||||
isEstimate: true,
|
||||
productCount: products.length
|
||||
},
|
||||
promptSources: promptSources,
|
||||
...aiResponse,
|
||||
});
|
||||
} catch (parseError) {
|
||||
|
||||
@@ -18,6 +18,7 @@ const categoriesRouter = require('./routes/categories');
|
||||
const importRouter = require('./routes/import');
|
||||
const aiValidationRouter = require('./routes/ai-validation');
|
||||
const templatesRouter = require('./routes/templates');
|
||||
const aiPromptsRouter = require('./routes/ai-prompts');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = '/var/www/html/inventory/.env';
|
||||
@@ -103,6 +104,7 @@ async function startServer() {
|
||||
app.use('/api/import', importRouter);
|
||||
app.use('/api/ai-validation', aiValidationRouter);
|
||||
app.use('/api/templates', templatesRouter);
|
||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||
|
||||
// Basic health check route
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
const { Pool, Client } = require('pg');
|
||||
const { Client: SSHClient } = require('ssh2');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
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 = new Pool(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,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AiValidationDialogs } from './components/AiValidationDialogs';
|
||||
import { Product } from '../../../../types/products';
|
||||
import {
|
||||
AiValidationProgress,
|
||||
AiValidationDetails,
|
||||
CurrentPrompt as AiValidationCurrentPrompt
|
||||
} from './hooks/useAiValidation';
|
||||
|
||||
const ValidationStepNew: React.FC = () => {
|
||||
const [aiValidationProgress, setAiValidationProgress] = useState<AiValidationProgress>({
|
||||
isOpen: false,
|
||||
status: 'idle',
|
||||
step: 0
|
||||
});
|
||||
|
||||
const [aiValidationDetails, setAiValidationDetails] = useState<AiValidationDetails>({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
changeDetails: [],
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
const [currentPrompt, setCurrentPrompt] = useState<AiValidationCurrentPrompt>({
|
||||
isOpen: false,
|
||||
prompt: '',
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// Track reversion state (for internal use)
|
||||
const [reversionState, setReversionState] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [fieldData] = useState<Product[]>([]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const revertAiChange = (productIndex: number, fieldKey: string) => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
setReversionState(prev => ({
|
||||
...prev,
|
||||
[key]: true
|
||||
}));
|
||||
};
|
||||
|
||||
const isChangeReverted = (productIndex: number, fieldKey: string): boolean => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
return !!reversionState[key];
|
||||
};
|
||||
|
||||
const getFieldDisplayValueWithHighlight = (
|
||||
_fieldKey: string,
|
||||
originalValue: any,
|
||||
correctedValue: any
|
||||
) => {
|
||||
return {
|
||||
originalHtml: String(originalValue),
|
||||
correctedHtml: String(correctedValue)
|
||||
};
|
||||
};
|
||||
|
||||
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,897 @@
|
||||
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, XIcon } 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";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
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;
|
||||
apiFormat?: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
promptSources?: {
|
||||
systemPrompt?: { id: number; prompt_text: string };
|
||||
generalPrompt?: { id: number; prompt_text: string };
|
||||
companyPrompts?: Array<{
|
||||
id: number;
|
||||
company: string;
|
||||
companyName?: string;
|
||||
prompt_text: string;
|
||||
}>;
|
||||
};
|
||||
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
|
||||
const hasCompanyPrompts =
|
||||
currentPrompt.debugData?.promptSources?.companyPrompts &&
|
||||
currentPrompt.debugData.promptSources.companyPrompts.length > 0;
|
||||
|
||||
// Create our own state to track changes
|
||||
const [localReversionState, setLocalReversionState] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Initialize local state from the isChangeReverted function when component mounts
|
||||
// or when aiValidationDetails changes
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
aiValidationDetails.changeDetails &&
|
||||
aiValidationDetails.changeDetails.length > 0
|
||||
) {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
|
||||
aiValidationDetails.changeDetails.forEach((product) => {
|
||||
product.changes.forEach((change) => {
|
||||
const key = `${product.productIndex}-${change.field}`;
|
||||
initialState[key] = isChangeReverted(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
setLocalReversionState(initialState);
|
||||
}
|
||||
}, [aiValidationDetails.changeDetails, isChangeReverted]);
|
||||
|
||||
// This function will toggle the local state for a given change
|
||||
const toggleChangeAcceptance = (productIndex: number, fieldKey: string) => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
const currentlyRejected = !!localReversionState[key];
|
||||
|
||||
// Toggle the local state
|
||||
setLocalReversionState((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
|
||||
// Only call revertAiChange when toggling to rejected state
|
||||
// Since revertAiChange is specifically for rejecting changes
|
||||
if (!currentlyRejected) {
|
||||
revertAiChange(productIndex, fieldKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check local reversion state
|
||||
const isChangeLocallyReverted = (
|
||||
productIndex: number,
|
||||
fieldKey: string
|
||||
): boolean => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
return !!localReversionState[key];
|
||||
};
|
||||
|
||||
// Use "full" as the default tab
|
||||
const defaultTab = "full";
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
// Update activeTab when the dialog is opened with new data
|
||||
React.useEffect(() => {
|
||||
if (currentPrompt.isOpen) {
|
||||
setActiveTab("full");
|
||||
}
|
||||
}, [currentPrompt.isOpen]);
|
||||
|
||||
// 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 - Fixed at the top */}
|
||||
<div className="flex-shrink-0">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex justify-center items-center h-[100px]"></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-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">
|
||||
{currentPrompt.debugData?.estimatedProcessingTime ? (
|
||||
currentPrompt.debugData.estimatedProcessingTime
|
||||
.seconds ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Estimated time:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{formatTime(
|
||||
currentPrompt.debugData
|
||||
.estimatedProcessingTime.seconds
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Based on{" "}
|
||||
{
|
||||
currentPrompt.debugData
|
||||
.estimatedProcessingTime.sampleCount
|
||||
}{" "}
|
||||
similar validation
|
||||
{currentPrompt.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 - Scrollable content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentPrompt.debugData?.apiFormat ? (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Prompt Sources Card - Fixed at the top of the content area */}
|
||||
<Card className="py-2 mb-4 flex-shrink-0">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Prompt Sources
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-100 hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("system-message")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
System
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-100 hover:bg-green-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("general-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
General
|
||||
</Badge>
|
||||
|
||||
{currentPrompt.debugData.promptSources?.companyPrompts?.map(
|
||||
(company, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="bg-blue-100 hover:bg-blue-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("company-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
{company.companyName ||
|
||||
`Company ${company.company}`}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-amber-100 hover:bg-amber-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("taxonomy-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Taxonomy
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-pink-100 hover:bg-pink-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("product-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Products
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ScrollArea className="flex-1 w-full overflow-y-auto">
|
||||
{currentPrompt.debugData.apiFormat.map(
|
||||
(message, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border rounded-md p-2 mb-4"
|
||||
>
|
||||
<div
|
||||
id={
|
||||
message.role === "system"
|
||||
? "system-message"
|
||||
: ""
|
||||
}
|
||||
className={`p-2 mb-2 rounded-sm font-medium ${
|
||||
message.role === "system"
|
||||
? "bg-purple-50 text-purple-800"
|
||||
: "bg-green-50 text-green-800"
|
||||
}`}
|
||||
>
|
||||
Role: {message.role}
|
||||
</div>
|
||||
|
||||
<Code
|
||||
className={`whitespace-pre-wrap p-4 break-normal max-w-full ${
|
||||
message.role === "system"
|
||||
? "bg-purple-50/30"
|
||||
: "bg-green-50/30"
|
||||
}`}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<div className="text-wrapper">
|
||||
{(() => {
|
||||
const content = message.content;
|
||||
|
||||
// Find section boundaries by looking for specific markers
|
||||
const companySpecificStartIndex =
|
||||
content.indexOf(
|
||||
"--- COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
);
|
||||
const companySpecificEndIndex =
|
||||
content.indexOf(
|
||||
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
);
|
||||
|
||||
const taxonomyStartIndex =
|
||||
content.indexOf(
|
||||
"All Available Categories:"
|
||||
);
|
||||
const taxonomyFallbackStartIndex =
|
||||
content.indexOf(
|
||||
"Available Categories:"
|
||||
);
|
||||
const actualTaxonomyStartIndex =
|
||||
taxonomyStartIndex >= 0
|
||||
? taxonomyStartIndex
|
||||
: taxonomyFallbackStartIndex;
|
||||
|
||||
const productDataStartIndex =
|
||||
content.indexOf(
|
||||
"----------Here is the product data to validate----------"
|
||||
);
|
||||
|
||||
// If we can't find any markers, just return the content as-is
|
||||
if (
|
||||
actualTaxonomyStartIndex < 0 &&
|
||||
productDataStartIndex < 0 &&
|
||||
companySpecificStartIndex < 0
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Determine section indices
|
||||
let generalEndIndex = content.length;
|
||||
|
||||
if (companySpecificStartIndex >= 0) {
|
||||
generalEndIndex =
|
||||
companySpecificStartIndex;
|
||||
} else if (
|
||||
actualTaxonomyStartIndex >= 0
|
||||
) {
|
||||
generalEndIndex =
|
||||
actualTaxonomyStartIndex;
|
||||
} else if (productDataStartIndex >= 0) {
|
||||
generalEndIndex = productDataStartIndex;
|
||||
}
|
||||
|
||||
// Determine where taxonomy starts
|
||||
let taxonomyEndIndex = content.length;
|
||||
if (productDataStartIndex >= 0) {
|
||||
taxonomyEndIndex =
|
||||
productDataStartIndex;
|
||||
}
|
||||
|
||||
// Segments to render with appropriate styling
|
||||
const segments = [];
|
||||
|
||||
// General section (beginning to company/taxonomy/product)
|
||||
if (generalEndIndex > 0) {
|
||||
segments.push(
|
||||
<div
|
||||
id="general-section"
|
||||
key="general"
|
||||
className="border-l-4 border-green-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-green-700 mb-2">
|
||||
General Prompt
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{content.substring(
|
||||
0,
|
||||
generalEndIndex
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Company-specific section if present
|
||||
if (
|
||||
companySpecificStartIndex >= 0 &&
|
||||
companySpecificEndIndex >= 0
|
||||
) {
|
||||
segments.push(
|
||||
<div
|
||||
id="company-section"
|
||||
key="company"
|
||||
className="border-l-4 border-blue-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-blue-700 mb-2">
|
||||
Company-Specific Instructions
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{content.substring(
|
||||
companySpecificStartIndex,
|
||||
companySpecificEndIndex +
|
||||
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
.length
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Taxonomy section
|
||||
if (actualTaxonomyStartIndex >= 0) {
|
||||
const taxEnd = taxonomyEndIndex;
|
||||
segments.push(
|
||||
<div
|
||||
id="taxonomy-section"
|
||||
key="taxonomy"
|
||||
className="border-l-4 border-amber-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-amber-700 mb-2">
|
||||
Taxonomy Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{content.substring(
|
||||
actualTaxonomyStartIndex,
|
||||
taxEnd
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Product data section
|
||||
if (productDataStartIndex >= 0) {
|
||||
segments.push(
|
||||
<div
|
||||
id="product-section"
|
||||
key="product"
|
||||
className="border-l-4 border-pink-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-pink-700 mb-2">
|
||||
Product Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{content.substring(
|
||||
productDataStartIndex
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{segments}</>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</pre>
|
||||
)}
|
||||
</Code>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<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 !== undefined
|
||||
? Math.round(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 !== undefined
|
||||
? Math.round(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-6xl w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the changes and warnings suggested by the AI
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
{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="">Field</TableHead>
|
||||
<TableHead className="w-[35%]">
|
||||
Original Value
|
||||
</TableHead>
|
||||
<TableHead className="w-[35%]">
|
||||
Corrected Value
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Accept Changes?
|
||||
</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 = isChangeLocallyReverted(
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: correctedHtml,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right align-top">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Toggle to Accepted state if currently rejected
|
||||
toggleChangeAcceptance(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
}}
|
||||
className={
|
||||
!isReverted
|
||||
? "bg-green-100 text-green-600 border-green-300 flex items-center"
|
||||
: "border-gray-200 text-gray-600 hover:bg-green-50 hover:text-green-600 hover:border-green-200 flex items-center"
|
||||
}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Toggle to Rejected state if currently accepted
|
||||
toggleChangeAcceptance(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
}}
|
||||
className={
|
||||
isReverted
|
||||
? "bg-red-100 text-red-600 border-red-300 flex items-center"
|
||||
: "border-gray-200 text-gray-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 flex items-center"
|
||||
}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</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" : ""
|
||||
)}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user