129 Commits

Author SHA1 Message Date
108181c63d Fix more import script bugs/missing data 2025-03-25 22:23:06 -04:00
5dd779cb4a Fix purchase orders import 2025-03-25 19:12:41 -04:00
7b0e792d03 Merge branch 'master' into move-to-postgresql 2025-03-25 12:15:07 -04:00
517bbe72f4 Add in image library feature 2025-03-25 12:14:36 -04:00
87d4b9e804 Fixes/improvements for import scripts 2025-03-24 22:27:44 -04:00
75da2c6772 Get all import scripts running again 2025-03-24 21:58:00 -04:00
00a02aa788 Enhance ai validation changes dialog 2025-03-24 14:17:02 -04:00
114018080a Enhance ai debug dialog 2025-03-24 13:25:18 -04:00
228ae8b2a9 Layout/style tweaks, remove text file prompts, integrate system prompt into database/settings 2025-03-24 12:26:21 -04:00
dd4b3f7145 Add prompts table and settings page to create/read/update/delete from it, incorporate company specific prompts into ai validation 2025-03-24 11:30:15 -04:00
7eb4077224 Clean up build errors 2025-03-23 22:15:11 -04:00
d60a8cbc6e Hide debug components without permission 2025-03-23 22:06:51 -04:00
1fcbf54989 Layout/style tweaks, fix performance metrics settings page 2025-03-23 22:01:41 -04:00
ce75496770 Clean up unused permissions, take user to first page/component they can access 2025-03-23 17:18:31 -04:00
7eae4a0b29 More permissions setup, simplify to one component 2025-03-23 16:04:32 -04:00
f421154c1d Get user management page working, add permission checking in more places 2025-03-22 22:27:50 -04:00
03dc119a15 Initial permissions framework and setup 2025-03-22 22:11:03 -04:00
1963bee00c Merge branch 'add-product-upload-page' 2025-03-22 21:11:10 -04:00
387e7e5e73 Clean up 2025-03-22 21:05:24 -04:00
a51a48ce89 Fix item number not getting updated when applying template 2025-03-22 20:55:34 -04:00
aacb3a2fd0 Fix validating required cells when applying template 2025-03-22 17:21:27 -04:00
35d2f0df7c Refactor validation hooks into smaller files 2025-03-21 00:33:06 -04:00
7d46ebd6ba Add skeleton loading state to template field, remove duplicated or unused code in validate step hooks 2025-03-19 14:30:39 -04:00
1496aa57b1 Remove remaining chakra-ui dependencies, clean up files, clean up build errors, move react-spreadsheet-import directory into main component structure 2025-03-19 12:56:56 -04:00
fc9ef2f0d7 Style tweaks, fix image uploads, refactor image upload step into smaller files 2025-03-19 11:27:26 -04:00
af067f7360 Fix line and subline showing as inputs instead of selects 2025-03-18 15:27:55 -04:00
949b543d1f Fix issues with validation errors showing and problems with concurrent editing, improve scroll position saving 2025-03-18 12:38:23 -04:00
8fdb68fb19 Move UPC validation table adapter out of ValidationContainer 2025-03-17 16:36:26 -04:00
136f767309 Move product line fetching out of ValidationContainer, clean up some unused files 2025-03-17 16:24:47 -04:00
aa9664c459 Move UPC validation out of ValidationContainer, add code lines tracking 2025-03-17 16:03:21 -04:00
f60f0b1b5c Merge separate itemNumberCell in to ValidationCell 2025-03-17 14:18:49 -04:00
676cd44d9d Clean up linter errors and add sequential thinking 2025-03-17 14:13:22 -04:00
1d081bb218 Optimize error processing and re-rendering in ValidationCell component. Implemented a centralized processErrors function, improved memoization, and enhanced batch updates to reduce redundancy and improve performance. 2025-03-16 15:25:23 -04:00
52ae7e10aa Refactor validation error handling to use a single source of truth (validationErrors Map), speed up item number generation and loading lines/sublines 2025-03-16 14:09:58 -04:00
153bbecc44 Add standardized error handling with new enums and interfaces for validation errors 2025-03-15 22:11:36 -04:00
cb46970808 Restore line and subline fields 2025-03-15 18:50:33 -04:00
97fa7f3495 Update doc 2025-03-14 19:25:36 -04:00
a88dbb8486 Remove artificial delays from copydown function, fix issues with select components, ensure pointer cursor shows in copydown state, ensure table scroll position is reset on unmount 2025-03-14 19:23:47 -04:00
d0a83c04ca Improve copy down functionality with loading state and ability to select end cell instead of defaulting to the bottom 2025-03-14 16:59:07 -04:00
f95c1f2d43 Set UPC validation loading state to only show on item number field 2025-03-14 01:32:27 -04:00
0ef27a3229 Fix text overflowing template dropdown trigger, add new MultilineInput component with popover for editing, remove MultiInputCell component except for code to create new MultiSelectCell component 2025-03-14 00:44:44 -04:00
0f89373d11 Fix horizontal scrollbar, rearrange error and copy icons in cells 2025-03-13 00:27:36 -04:00
f55d35e301 Fix row highlighting, header alignment, make header sticky 2025-03-11 21:08:02 -04:00
1aee18a025 More validation table optimizations + create doc to track remaining fixes 2025-03-11 16:21:17 -04:00
0068d77ad9 Optimize validation table 2025-03-10 21:59:24 -04:00
b69182e2c7 Fix validation table scroll location saving issues 2025-03-10 00:17:55 -04:00
1c8709f520 Rearrange docs 2025-03-09 22:07:14 -04:00
de1408bd58 Fix validation indicators on validation step table 2025-03-09 17:44:03 -04:00
c295c330ff Add copy down functionality to validate table 2025-03-09 16:30:11 -04:00
7cc723ce83 Fix creating template from validate table row 2025-03-09 16:11:49 -04:00
c3c48669ad Fix data coming in correctly when copying template from an existing product, automatically strip out deals and black friday categories 2025-03-09 15:38:13 -04:00
78a0018940 Fix total sold count in search-products endpoint 2025-03-09 14:12:32 -04:00
851cc3c4cc Fix product search dialog for adding templates, pull out component to use independently, add to template management settings page 2025-03-09 13:42:33 -04:00
74454cdc7f Show templates from all brands when selected brand has no templates 2025-03-08 14:34:49 -05:00
31c838197a Optimize validation table 2025-03-08 14:30:11 -05:00
45fa583ce8 Fix validation again I hope? 2025-03-08 12:23:43 -05:00
c96f514bcd Fix dropdown scrolling and keep multi-selects open 2025-03-08 11:12:42 -05:00
6a5e6d2bfb Style tweaks, fix section hiding in map columns step 2025-03-07 22:23:42 -05:00
875d0b8f55 Fix applying templates to or discarding multiple rows 2025-03-07 16:16:57 -05:00
b15387041b Fix validation timing issues with templates 2025-03-07 15:30:09 -05:00
60cdb1cee3 Fix navigating between steps on start from scratch flow 2025-03-06 18:55:02 -05:00
52fd47a921 Fix templates loading on page load 2025-03-06 13:38:11 -05:00
b723ec3c0f Clean up old validationstep, clean up various type errors 2025-03-06 12:04:35 -05:00
68ca7e93a1 Fix dropdown values saving, add back checkbox column, mostly fix validation, fix some field types 2025-03-06 01:45:05 -05:00
bc5607f48c Fix upc validation api call 2025-03-05 22:08:50 -05:00
36a5186c17 Validate step - fix memoization and reduce unnecessary re-renders 2025-03-05 17:02:55 -05:00
05bac73c45 More validate step changes/fixes 2025-03-04 23:51:40 -05:00
7a43428e76 More validate step changes to get closer to original, made the default step now 2025-03-03 21:46:22 -05:00
e21da8330e Rebuild validationstep to make it actually manageable 2025-03-03 14:25:26 -05:00
56c3f0534d Fix company field changes erasing data (hopefully) 2025-03-02 16:09:55 -05:00
98e3b89d46 Add UPC validation and automatic item number generation/validation 2025-03-01 19:37:51 -05:00
8271c9f95a Improve template search in validate step 2025-03-01 14:48:10 -05:00
f7bdefb0a3 Add product search and template creation functionality to validation step 2025-03-01 12:24:04 -05:00
e0a7787139 Make upload by URL input always visible, fix deleting URL images 2025-02-27 13:22:04 -05:00
c1159f518c Add copy buttons to IDs on image upload and fix upload by URL 2025-02-27 10:48:33 -05:00
a19a8ba412 Move image from URL option from validate step to add images step 2025-02-27 01:16:01 -05:00
bb455b3c37 Match columns tweaks 2025-02-27 00:50:31 -05:00
ca35a67e9f Optimize match columns step 2025-02-27 00:25:47 -05:00
88f1853b09 Style tweaks 2025-02-26 22:14:11 -05:00
3ca72674af Fix header/footer placement on image upload step 2025-02-26 21:39:09 -05:00
c185d4e3ca More drag and drop tweaks 2025-02-26 20:53:28 -05:00
2d62cac5f7 Drag between products fix 2025-02-26 19:04:35 -05:00
e3361cf098 Make images draggable between products, add zoom 2025-02-26 18:20:49 -05:00
41f7f33746 Make images rearrange-able with drag and drop 2025-02-26 16:31:56 -05:00
8141fafb34 Add bulk image upload with auto assign 2025-02-26 16:25:56 -05:00
42af434bd7 Add image upload 2025-02-26 16:15:18 -05:00
fbb200c4ee Highlight diffs on validation changes 2025-02-26 14:16:32 -05:00
b96a9f412a Improve AI validate revert visuals, fix some regressions 2025-02-26 11:24:05 -05:00
6b101a91f6 Fix line/subline regressions, add in AI validation tracking and improve AI results dialog 2025-02-26 00:38:17 -05:00
2df5428712 Fix AI regressions 2025-02-25 15:20:37 -05:00
5d7e05172d Add floating toolbar to validate step, clean up upload page and fix up start from scratch functionality 2025-02-25 01:58:26 -05:00
41058ff5c6 Rearrange and improve match columns step 2025-02-25 00:27:38 -05:00
54a87ca3dc Add required fields display to match columns step 2025-02-24 22:31:57 -05:00
6bf93d33ea Add global options to pass in to validate step, move remove empty/duplicate button to select header row step 2025-02-24 15:03:32 -05:00
441a2c74ad Add remaining templates elements 2025-02-24 00:02:27 -05:00
f628774267 Merge branch 'master' into add-product-upload-page 2025-02-23 15:40:54 -05:00
3f16413769 AI validation tweaks, add templates settings page and schema and routes 2025-02-23 15:14:12 -05:00
959a64aebc Enhance AI validation with progress tracking and prompt debugging 2025-02-22 20:53:13 -05:00
694014934c Tweaks to prompt data format 2025-02-21 12:01:15 -05:00
cff176e7a3 Split off AI prompt into separate file, auto include taxonomy in prompt, create prompt debug page 2025-02-21 11:50:46 -05:00
7f7e6fdd1f AI tweaks and make column name matching case insensitive 2025-02-20 15:49:48 -05:00
45a52cbc33 Validation step styles and functionality tweaks, add initial AI functionality 2025-02-20 15:11:14 -05:00
bba7362641 Get multi select popover to stay open 2025-02-20 01:38:59 -05:00
468f85c45d Clean up linter errors 2025-02-19 22:05:21 -05:00
24e2d01ccc Connect with database for dropdowns, more validate data step fixes 2025-02-19 21:32:23 -05:00
43d7775d08 Add in multi-input and multi-select fields, fix/enhance data validation 2025-02-19 17:11:17 -05:00
527dec4d49 Adjust validation table, add custom fields 2025-02-19 12:35:24 -05:00
fe70b56d24 Fix up validation step to show proper components 2025-02-19 11:37:09 -05:00
ed62f03ba0 Remove unneeded files, more shadcn conversions 2025-02-19 11:13:16 -05:00
e034e83198 Shadcn conversion, lots of styling 2025-02-19 10:47:23 -05:00
110f4ec332 Shadcn conversion, more styling, sheet select step 2025-02-19 01:59:51 -05:00
5bf265ed46 Shadcn conversion + styling, match columns page 2025-02-19 01:41:06 -05:00
528fe7c024 Shadcn conversion, select header 2025-02-18 16:47:55 -05:00
08be0658cb Shadcn conversion, more validate page 2025-02-18 16:02:30 -05:00
f823841b15 Converting to shadcn, steps, validate page 2025-02-18 15:41:00 -05:00
9ce3793067 Begin converting to shadcn, alerts 2025-02-18 12:49:01 -05:00
89d4605577 Add in and integrate react-spreadsheet-import 2025-02-18 11:58:22 -05:00
675a0fc374 Fix incorrect columns in import scripts 2025-02-18 10:46:16 -05:00
ca2653ea1a Update import scripts through POs 2025-02-17 22:17:01 -05:00
a8d3fd8033 Update import scripts through orders 2025-02-17 00:53:07 -05:00
702b956ff1 Fix main import script issue 2025-02-16 11:54:28 -05:00
9b8577f258 Update import scripts through products 2025-02-14 21:46:50 -05:00
9623681a15 Update import scripts, working through categories 2025-02-14 13:30:14 -05:00
cc22fd8c35 Update backend/frontend 2025-02-14 11:26:02 -05:00
0ef1b6100e Clean up old files 2025-02-14 09:37:05 -05:00
a519746ccb Move authentication to postgres 2025-02-14 09:10:15 -05:00
f29dd8ef8b Clean up build errors 2025-02-13 20:02:11 -05:00
f2a5c06005 Fixes for re-running reset scripts 2025-02-13 10:25:04 -05:00
fb9f959fe5 Update schemas and reset scripts 2025-02-12 16:14:25 -05:00
217 changed files with 38169 additions and 4927 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

9
.gitignore vendored
View File

@@ -50,6 +50,11 @@ dashboard-server/meta-server/._package-lock.json
dashboard-server/meta-server/._services dashboard-server/meta-server/._services
*.tsbuildinfo *.tsbuildinfo
uploads/*
uploads/**/*
**/uploads/*
**/uploads/**/*
# CSV data files # CSV data files
*.csv *.csv
csv/* csv/*
@@ -59,3 +64,7 @@ csv/**/*
!csv/.gitkeep !csv/.gitkeep
inventory/tsconfig.tsbuildinfo inventory/tsconfig.tsbuildinfo
inventory-server/scripts/.fuse_hidden00000fa20000000a inventory-server/scripts/.fuse_hidden00000fa20000000a
.VSCodeCounter/
.VSCodeCounter/*
.VSCodeCounter/**/*

172
docs/PERMISSIONS.md Normal file
View 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>
```

View File

@@ -0,0 +1,396 @@
# ValidationStep Component Refactoring Plan
## Overview
This document outlines a comprehensive plan to refactor the current ValidationStep component (4000+ lines) into a more maintainable, modular structure. The new implementation will be developed alongside the existing component without modifying the original code. Once completed, the previous step in the workflow will offer the option to continue to either the original ValidationStep or the new implementation.
## Table of Contents
1. [Current Component Analysis](#current-component-analysis)
2. [New Architecture Design](#new-architecture-design)
3. [Component Structure](#component-structure)
4. [State Management](#state-management)
5. [Key Features Implementation](#key-features-implementation)
6. [Integration Plan](#integration-plan)
7. [Testing Strategy](#testing-strategy)
8. [Project Timeline](#project-timeline)
9. [Design Principles](#design-principles)
10. [Appendix: Function Reference](#appendix-function-reference)
## Current Component Analysis
The current ValidationStep component has several issues:
- **Size**: At over 4000 lines, it's difficult to maintain and understand
- **Multiple responsibilities**: Handles validation, UI rendering, template management, and more
- **Special cases**: Contains numerous special case handlers and exceptions
- **Complex state management**: State is distributed across multiple useState calls
- **Tightly coupled concerns**: UI, validation logic, and business rules are intertwined
### Key Features to Preserve
1. **Data Validation**
- Field-level validation (required, regex, unique)
- Row-level validation (supplier, company fields)
- UPC validation with API integration
- AI-assisted validation
2. **Template Management**
- Saving, loading, and applying templates
- Template-based validation
3. **UI Components**
- Editable table with specialized cell renderers
- Error display and management
- Filtering and sorting capabilities
- Status indicators and progress tracking
4. **Special Field Handling**
- Input fields with price formatting
- Multi-input fields with separator configuration
- Select fields with dropdown options
- Checkbox fields with boolean value mapping
- UPC fields with specialized validation
5. **User Interaction Flows**
- Tab and keyboard navigation
- Bulk operations (select all, apply template)
- Row validation on value change
- Error reporting and display
## New Architecture Design
The new architecture will follow these principles:
1. **Separation of Concerns**
- UI rendering separate from business logic
- Validation logic isolated from state management
- Clear interfaces between components
2. **Composable Components**
- Small, focused components with single responsibilities
- Reusable pattern for different field types
3. **Centralized State Management**
- Custom hooks for state management
- Clear data flow patterns
- Reduced prop drilling
4. **Consistent Error Handling**
- Standardized error structure
- Predictable error propagation
- User-friendly error display
5. **Performance Optimization**
- Virtualized table rendering
- Memoization of expensive computations
- Deferred validation for better user experience
## Component Structure
The new ValidationStepNew folder has the following structure:
```
ValidationStepNew/
├── index.tsx # Main entry point that composes all pieces
├── components/ # UI Components
│ ├── ValidationContainer.tsx # Main wrapper component
│ ├── ValidationTable.tsx # Table implementation
│ ├── ValidationCell.tsx # Cell component
│ ├── ValidationSidebar.tsx # Sidebar with controls
│ ├── ValidationToolbar.tsx # Top toolbar (removed as unnecessary)
│ ├── TemplateManager.tsx # Template management
│ ├── FilterPanel.tsx # Filtering interface (integrated into Container)
│ └── cells/ # Specialized cell renderers
│ ├── InputCell.tsx
│ ├── SelectCell.tsx
│ ├── MultiInputCell.tsx
│ └── CheckboxCell.tsx
├── hooks/ # Custom hooks
│ ├── useValidationState.tsx # Main state management
│ ├── useTemplates.tsx # Template-related logic (integrated into ValidationState)
│ ├── useFilters.tsx # Filtering logic (integrated into ValidationState)
│ └── useUpcValidation.tsx # UPC-specific validation
└── utils/ # Utility functions
├── validationUtils.ts # Validation helper functions
├── formatters.ts # Value formatting utilities
└── constants.ts # Constant values and configuration
```
### Component Responsibilities
#### ValidationContainer
- Main container component
- Coordinates between subcomponents
- Manages global state
- Handles navigation events (next, back)
- Contains filter controls
#### ValidationTable
- Displays the data in tabular form
- Manages selection state
- Handles keyboard navigation
- Integrates with TanStack Table
- Displays properly styled rows and cells
#### ValidationCell
- Factory component that renders appropriate cell type
- Manages cell-level state
- Handles validation errors display
- Manages edit mode
- Shows consistent error indicators
#### TemplateManager
- Handles template selection UI
- Provides template save/load functionality
- Manages template application to rows
#### Cell Components
- **InputCell**: Handles text input with multiline and price support
- **MultiInputCell**: Handles multiple values with separator configuration
- **SelectCell**: Command/popover component for single selection
- **CheckboxCell**: Boolean value selection with mapping support
## State Management
### Core State Interface
```typescript
interface ValidationState<T extends string> {
// Core data
data: RowData<T>[];
filteredData: RowData<T>[];
// Validation state
isValidating: boolean;
validationErrors: Map<number, Record<string, Error[]>>;
rowValidationStatus: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
// Selection state
rowSelection: RowSelectionState;
// Template state
templates: Template[];
selectedTemplateId: string | null;
// Filter state
filters: FilterState;
// Methods
updateRow: (rowIndex: number, key: T, value: any) => void;
validateRow: (rowIndex: number) => Promise<void>;
validateUpc: (rowIndex: number, upcValue: string) => Promise<void>;
applyTemplate: (templateId: string, rowIndexes: number[]) => void;
saveTemplate: (name: string, type: string) => void;
setFilters: (newFilters: Partial<FilterState>) => void;
// Additional methods...
}
```
### useValidationState Hook
The main state management hook handles:
- Data manipulation (update, sort, filter)
- Selection management
- Validation coordination
- Integration with validation utilities
- Template management
- Filtering and sorting
## Key Features Implementation
### 1. Field Type Handling
Implemented a strategy pattern for different field types:
```typescript
// In ValidationCell
const renderCellContent = () => {
const fieldType = field.fieldType.type
switch (fieldType) {
case 'input':
return <InputCell<T> field={field} value={value} onChange={onChange} ... />
case 'multi-input':
return <MultiInputCell<T> field={field} value={value} onChange={onChange} ... />
case 'select':
return <SelectCell<T> field={field} value={value} onChange={onChange} ... />
// etc.
}
}
```
### 2. Validation Logic
Validation is broken down into clear steps:
1. **Field Validation**: Apply field-level validations (required, regex, etc.)
2. **Row Validation**: Apply row-level validations and rowHook
3. **Table Validation**: Apply table-level validations (unique) and tableHook
Validation now happens automatically without explicit buttons, with immediate feedback on field blur.
### 3. UI Components
UI components follow these principles:
1. **Consistent Styling**: All components use shadcn UI for consistent look and feel
2. **Visual Feedback**: Errors are clearly indicated with icons and border styling
3. **Intuitive Editing**: Fields show outlines even when not in focus, and edit on click
4. **Proper Command Pattern**: Select and multi-select fields use command/popover pattern for better UX
5. **Focus Management**: Fields close when clicking away and perform validation on blur
## Design Principles
Based on user preferences and best practices, the following design principles guide this refactoring:
1. **Automatic Validation**
- Validation should happen automatically without explicit buttons
- All validation should run on initial data load
- Fields should validate on blur (when user clicks away)
2. **Modern UI Patterns**
- Command/popover components for all selects and multi-selects
- Consistent field outlines and borders even when not in focus
- Badge patterns for multi-select items
- Clear visual indicators for errors
3. **Reduced Complexity**
- Remove unnecessary UI elements like "validate all" buttons
- Eliminate redundant state and toast notifications
- Simplify component hierarchy where possible
- Find root causes rather than adding special cases
4. **Consistent Component Behavior**
- Fields should close when clicking away
- All inputs should follow the same editing pattern
- Error handling should be consistent across all field types
- Multi-select fields should allow selecting multiple items with clear visual feedback
## Integration Plan
### 1. Creating the New Component Structure
Folder structure has been created without modifying the existing code:
```bash
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/{components,hooks,utils}
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells
```
### 2. Implementing Basic Components
Core components have been implemented:
1. Created index.tsx as the main entry point
2. Implemented ValidationContainer with basic state management
3. Created ValidationTable for data display
4. Implemented basic cell rendering with specialized cell types
### 3. Implementing State Management
State management has been implemented:
1. Created useValidationState hook
2. Implemented data transformation utilities
3. Added validation logic
### 4. Integrating with Previous Step
The previous step component allows choosing between validation implementations, enabling gradual testing and adoption.
## Testing Strategy
1. **Unit Tests**
- Test individual utility functions
- Test hooks in isolation
- Test individual UI components
2. **Integration Tests**
- Test component interactions
- Test state management flow
- Test validation logic integration
3. **Comparison Tests**
- Compare output of new component with original
- Verify that all functionality works the same
4. **Performance Tests**
- Measure render times
- Evaluate memory usage
- Compare against original component
## Project Timeline
1. **Phase 1: Initial Structure (Completed)**
- Set up folder structure
- Implement basic components
- Create core state management
2. **Phase 2: Core Functionality (In Progress)**
- Implement validation logic (completed)
- Create cell renderers (completed)
- Add template management (in progress)
3. **Phase 3: Special Features (Upcoming)**
- Implement UPC validation
- Add AI validation
- Handle special cases
4. **Phase 4: UI Refinement (Ongoing)**
- Improve error display (completed)
- Enhance user interactions (completed)
- Optimize performance (in progress)
5. **Phase 5: Testing and Integration (Upcoming)**
- Write tests
- Fix bugs
- Integrate with previous step
## Appendix: Function Reference
This section documents the core functions from the original ValidationStep that need to be preserved in the new implementation.
### Validation Functions
1. **validateRegex** - Validates values against regex patterns
2. **getValidationError** - Determines field-level validation errors
3. **validateAndCommit** - Validates and commits new values
4. **validateData** - Validates all data rows
5. **validateUpcAndGenerateItemNumbers** - Validates UPC codes and generates item numbers
### Formatting Functions
1. **formatPrice** - Formats price values
2. **getDisplayValue** - Gets formatted display value based on field type
3. **isMultiInputType** - Checks if field is multi-input type
4. **getMultiInputSeparator** - Gets separator for multi-input fields
5. **isPriceField** - Checks if field should be formatted as price
### Template Functions
1. **loadTemplates** - Loads templates from storage
2. **saveTemplate** - Saves a new template
3. **applyTemplate** - Applies a template to selected rows
4. **getTemplateDisplayText** - Gets display text for a template
### AI Validation Functions
1. **handleAiValidation** - Triggers AI validation
2. **showCurrentPrompt** - Shows current AI prompt
3. **getFieldDisplayValue** - Gets display value for a field
4. **highlightDifferences** - Highlights differences between original and corrected values
5. **getFieldDisplayValueWithHighlight** - Gets display value with highlighted differences
6. **revertAiChange** - Reverts an AI-suggested change
7. **isChangeReverted** - Checks if an AI change has been reverted
### Event Handlers
1. **handleUpcValueUpdate** - Handles UPC value updates
2. **handleBlur** - Handles input blur events
3. **handleWheel** - Handles wheel events for navigation
4. **copyValueDown** - Copies a value to cells below
5. **handleSkuGeneration** - Generates SKUs
By following this refactoring plan, we continue to transform the monolithic ValidationStep component into a modular, maintainable set of components while preserving all existing functionality and aligning with user preferences for design and behavior.

View File

@@ -0,0 +1,137 @@
# ValidationStepNew Implementation Status
## Overview
This document outlines the current status of the ValidationStepNew implementation, a refactored version of the original ValidationStep component. The goal is to create a more maintainable, modular component that preserves all functionality of the original while eliminating technical debt and implementing modern UI patterns.
## Design Principles
Based on the user's preferences, we're following these core design principles:
1. **Automatic Validation**
- ✅ Validation runs automatically on data load
- ✅ No explicit "validate all" button needed
- ✅ Fields validate on blur when user clicks away
- ✅ Immediate visual feedback for validation errors
2. **Modern UI Patterns**
- ✅ Command/popover components for selects and multi-selects
- ✅ Consistent field outlines and borders even when not in focus
- ✅ Badge pattern for multi-select field items
- ✅ Visual indicators for errors with appropriate styling
3. **Reduced Complexity**
- ✅ Removed unnecessary UI elements like "validate all" button
- ✅ Eliminated redundant toast notifications
- ✅ Simplified component hierarchy
- ✅ Fixed root causes rather than adding special cases
4. **Consistent Behavior**
- ✅ Fields close when clicking away
- ✅ All inputs follow the same editing pattern
- ✅ Error handling is consistent across field types
- ✅ Multi-select fields allow selecting multiple items
## Completed Components
### Core Structure
- ✅ Main component structure
- ✅ Directory organization
- ✅ TypeScript interfaces
- ✅ Props definition and passing
### State Management
-`useValidationState` hook for centralized state
- ✅ Data validation logic
- ✅ Integration with rowHook and tableHook
- ✅ Error tracking and management
- ✅ Row selection
- ✅ Automatic validation on data load
### UI Components
- ✅ ValidationContainer with appropriate layout
- ✅ ValidationTable with shadcn UI components
- ✅ ValidationCell factory component
- ✅ Row select/deselect functionality
- ✅ Error display and indicators
- ✅ Selection action bar
### Cell Components
- ✅ InputCell with price and multiline support
- ✅ MultiInputCell with separator configuration
- ✅ SelectCell using command/popover pattern
- ✅ CheckboxCell with boolean mapping
- ✅ Consistent styling across all field types
- ✅ Proper edit/view state management
- ✅ Outlined borders in both edit and view modes
### Utility Functions
- ✅ Value formatting for display
- ✅ Field type detection
- ✅ Error creation and management
- ✅ Price formatting
### UI Improvements
- ✅ Consistent borders and field outlines
- ✅ Fields that properly close when clicking away
- ✅ Multi-select with badge UI pattern
- ✅ Command pattern for searchable select menus
- ✅ Better visual error indication
## Pending Tasks
### Enhanced Validation
- ⏳ AI validation system
- ⏳ Custom validation hooks
- ⏳ Enhanced UPC validation with API integration
- ⏳ Validation visualizations
### Advanced UI Features
- ⏳ Table virtualization for performance
- ⏳ Drag-and-drop reordering
- ⏳ Bulk operations (copy down, fill all, etc.)
- ⏳ Keyboard navigation improvements
- ⏳ Template dialogs and management UI
### Special Features
- ⏳ Image preview integration
- ⏳ SKU generation system
- ⏳ Item number generation
- ⏳ Dependent dropdown values
### Testing
- ⏳ Unit tests for utility functions
- ⏳ Component tests
- ⏳ Integration tests
- ⏳ Performance benchmarks
## Known Issues
1. TypeScript error for `validationDisabled` property in ValidationCell.tsx
2. Some type casting is needed due to complex generic types
3. Need to address edge cases for multi-select fields validation
4. Proper error handling for API calls needs implementation
## Next Steps
1. Fix TypeScript errors in ValidationCell and related components
2. Complete template management functionality
3. Implement UPC validation with API integration
4. Make multi-select field validation more robust
5. Add comprehensive tests
## Performance Improvements
We've already implemented several performance optimizations:
1. ✅ More efficient state updates by removing unnecessary re-renders
2. ✅ Better error handling to prevent cascading validations
3. ✅ Improved component isolation to prevent unnecessary re-renders
4. ✅ Automatic validation that doesn't block the UI
Additional planned improvements:
1. Virtualized table rendering for large datasets
2. Memoization of expensive calculations
3. Optimized state updates to minimize re-renders
4. Batched API calls for validation

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

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

View File

@@ -0,0 +1,342 @@
# MySQL to PostgreSQL Import Process Documentation
This document outlines the data import process from the production MySQL database to the local PostgreSQL database, focusing on column mappings, data transformations, and the overall import architecture.
## Table of Contents
1. [Overview](#overview)
2. [Import Architecture](#import-architecture)
3. [Column Mappings](#column-mappings)
- [Categories](#categories)
- [Products](#products)
- [Product Categories (Relationship)](#product-categories-relationship)
- [Orders](#orders)
- [Purchase Orders](#purchase-orders)
- [Metadata Tables](#metadata-tables)
4. [Special Calculations](#special-calculations)
5. [Implementation Notes](#implementation-notes)
## Overview
The import process extracts data from a MySQL 5.7 production database and imports it into a PostgreSQL database. It can operate in two modes:
- **Full Import**: Imports all data regardless of last sync time
- **Incremental Import**: Only imports data that has changed since the last import
The process handles four main data types:
- Categories (product categorization hierarchy)
- Products (inventory items)
- Orders (sales records)
- Purchase Orders (vendor orders)
## Import Architecture
The import process follows these steps:
1. **Establish Connection**: Creates a SSH tunnel to the production server and establishes database connections
2. **Setup Import History**: Creates a record of the current import operation
3. **Import Categories**: Processes product categories in hierarchical order
4. **Import Products**: Processes products with their attributes and category relationships
5. **Import Orders**: Processes customer orders with line items, taxes, and discounts
6. **Import Purchase Orders**: Processes vendor purchase orders with line items
7. **Record Results**: Updates the import history with results
8. **Close Connections**: Cleans up connections and resources
Each import step uses temporary tables for processing and wraps operations in transactions to ensure data consistency.
## Column Mappings
### Categories
| PostgreSQL Column | MySQL Source | Transformation |
|-------------------|---------------------------------|----------------------------------------------|
| cat_id | product_categories.cat_id | Direct mapping |
| name | product_categories.name | Direct mapping |
| type | product_categories.type | Direct mapping |
| parent_id | product_categories.master_cat_id| NULL for top-level categories (types 10, 20) |
| description | product_categories.combined_name| Direct mapping |
| status | N/A | Hard-coded 'active' |
| created_at | N/A | Current timestamp |
| updated_at | N/A | Current timestamp |
**Notes:**
- Categories are processed in hierarchical order by type: [10, 20, 11, 21, 12, 13]
- Type 10/20 are top-level categories with no parent
- Types 11/21/12/13 are child categories that reference parent categories
### Products
| PostgreSQL Column | MySQL Source | Transformation |
|----------------------|----------------------------------|---------------------------------------------------------------|
| pid | products.pid | Direct mapping |
| title | products.description | Direct mapping |
| description | products.notes | Direct mapping |
| sku | products.itemnumber | Fallback to 'NO-SKU' if empty |
| stock_quantity | shop_inventory.available_local | Capped at 5000, minimum 0 |
| preorder_count | current_inventory.onpreorder | Default 0 |
| notions_inv_count | product_notions_b2b.inventory | Default 0 |
| price | product_current_prices.price_each| Default 0, filtered on active=1 |
| regular_price | products.sellingprice | Default 0 |
| cost_price | product_inventory | Weighted average: SUM(costeach * count) / SUM(count) when count > 0, or latest costeach |
| vendor | suppliers.companyname | Via supplier_item_data.supplier_id |
| vendor_reference | supplier_item_data | supplier_itemnumber or notions_itemnumber based on vendor |
| notions_reference | supplier_item_data.notions_itemnumber | Direct mapping |
| brand | product_categories.name | Linked via products.company |
| line | product_categories.name | Linked via products.line |
| subline | product_categories.name | Linked via products.subline |
| artist | product_categories.name | Linked via products.artist |
| categories | product_category_index | Comma-separated list of category IDs |
| created_at | products.date_created | Validated date, NULL if invalid |
| first_received | products.datein | Validated date, NULL if invalid |
| landing_cost_price | NULL | Not set |
| barcode | products.upc | Direct mapping |
| harmonized_tariff_code| products.harmonized_tariff_code | Direct mapping |
| updated_at | products.stamp | Validated date, NULL if invalid |
| visible | shop_inventory | Calculated from show + buyable > 0 |
| managing_stock | N/A | Hard-coded true |
| replenishable | Multiple fields | Complex calculation based on reorder, dates, etc. |
| permalink | N/A | Constructed URL with product ID |
| moq | supplier_item_data | notions_qty_per_unit or supplier_qty_per_unit, minimum 1 |
| uom | N/A | Hard-coded 1 |
| rating | products.rating | Direct mapping |
| reviews | products.rating_votes | Direct mapping |
| weight | products.weight | Direct mapping |
| length | products.length | Direct mapping |
| width | products.width | Direct mapping |
| height | products.height | Direct mapping |
| country_of_origin | products.country_of_origin | Direct mapping |
| location | products.location | Direct mapping |
| total_sold | order_items | SUM(qty_ordered) for all order_items where prod_pid = pid |
| baskets | mybasket | COUNT of records where mb.item = pid and qty > 0 |
| notifies | product_notify | COUNT of records where pn.pid = pid |
| date_last_sold | product_last_sold.date_sold | Validated date, NULL if invalid |
| image | N/A | Constructed from pid and image URL pattern |
| image_175 | N/A | Constructed from pid and image URL pattern |
| image_full | N/A | Constructed from pid and image URL pattern |
| options | NULL | Not set |
| tags | NULL | Not set |
**Notes:**
- Replenishable calculation:
```javascript
CASE
WHEN p.reorder < 0 THEN 0
WHEN (
(COALESCE(pls.date_sold, '0000-00-00') = '0000-00-00' OR pls.date_sold <= DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR))
AND (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR))
AND (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR))
) THEN 0
ELSE 1
END
```
In business terms, a product is considered NOT replenishable only if:
- It was manually flagged as not replenishable (negative reorder value)
- OR it shows no activity across ALL metrics (no sales AND no receipts AND no refills in the past 5 years)
- Image URLs are constructed using this pattern:
```javascript
const paddedPid = pid.toString().padStart(6, '0');
const prefix = paddedPid.slice(0, 3);
const basePath = `${imageUrlBase}${prefix}/${pid}`;
return {
image: `${basePath}-t-${iid}.jpg`,
image_175: `${basePath}-175x175-${iid}.jpg`,
image_full: `${basePath}-o-${iid}.jpg`
};
```
### Product Categories (Relationship)
| PostgreSQL Column | MySQL Source | Transformation |
|-------------------|-----------------------------------|---------------------------------------------------------------|
| pid | products.pid | Direct mapping |
| cat_id | product_category_index.cat_id | Direct mapping, filtered by category types |
**Notes:**
- Only categories of types 10, 20, 11, 21, 12, 13 are imported
- Categories 16 and 17 are explicitly excluded
### Orders
| PostgreSQL Column | MySQL Source | Transformation |
|-------------------|-----------------------------------|---------------------------------------------------------------|
| order_number | order_items.order_id | Direct mapping |
| pid | order_items.prod_pid | Direct mapping |
| sku | order_items.prod_itemnumber | Fallback to 'NO-SKU' if empty |
| date | _order.date_placed_onlydate | Via join to _order table |
| price | order_items.prod_price | Direct mapping |
| quantity | order_items.qty_ordered | Direct mapping |
| discount | Multiple sources | Complex calculation (see notes) |
| tax | order_tax_info_products.item_taxes_to_collect | Via latest order_tax_info record |
| tax_included | N/A | Hard-coded false |
| shipping | N/A | Hard-coded 0 |
| customer | _order.order_cid | Direct mapping |
| customer_name | users | CONCAT(users.firstname, ' ', users.lastname) |
| status | _order.order_status | Direct mapping |
| canceled | _order.date_cancelled | Boolean: true if date_cancelled is not '0000-00-00 00:00:00' |
| costeach | order_costs | From latest record or fallback to price * 0.5 |
**Notes:**
- Only orders with order_status >= 15 and with a valid date_placed are processed
- For incremental imports, only orders modified since last sync are processed
- Discount calculation combines three sources:
1. Base discount: order_items.prod_price_reg - order_items.prod_price
2. Promo discount: SUM of order_discount_items.amount
3. Proportional order discount: Calculation based on order subtotal proportion
```javascript
(oi.base_discount +
COALESCE(ot.promo_discount, 0) +
CASE
WHEN om.summary_discount > 0 AND om.summary_subtotal > 0 THEN
ROUND((om.summary_discount * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 2)
ELSE 0
END)::DECIMAL(10,2)
```
- Taxes are taken from the latest tax record for an order
- Cost data is taken from the latest non-pending cost record
### Purchase Orders
| PostgreSQL Column | MySQL Source | Transformation |
|-------------------|-----------------------------------|---------------------------------------------------------------|
| po_id | po.po_id | Default 0 if NULL |
| pid | po_products.pid | Direct mapping |
| sku | products.itemnumber | Fallback to 'NO-SKU' if empty |
| name | products.description | Fallback to 'Unknown Product' |
| cost_price | po_products.cost_each | Direct mapping |
| po_cost_price | po_products.cost_each | Duplicate of cost_price |
| vendor | suppliers.companyname | Fallback to 'Unknown Vendor' if empty |
| date | po.date_ordered | Fallback to po.date_created if NULL |
| expected_date | po.date_estin | Direct mapping |
| status | po.status | Default 1 if NULL |
| notes | po.short_note | Fallback to po.notes if NULL |
| ordered | po_products.qty_each | Direct mapping |
| received | N/A | Hard-coded 0 |
| receiving_status | N/A | Hard-coded 1 |
**Notes:**
- Only POs created within last 1 year (incremental) or 5 years (full) are processed
- For incremental imports, only POs modified since last sync are processed
### Metadata Tables
#### import_history
| PostgreSQL Column | Source | Notes |
|-------------------|-----------------------------------|---------------------------------------------------------------|
| id | Auto-increment | Primary key |
| table_name | Code | 'all_tables' for overall import |
| start_time | NOW() | Import start time |
| end_time | NOW() | Import completion time |
| duration_seconds | Calculation | Elapsed seconds |
| is_incremental | INCREMENTAL_UPDATE | Flag from config |
| records_added | Calculation | Sum from all imports |
| records_updated | Calculation | Sum from all imports |
| status | Code | 'running', 'completed', 'failed', or 'cancelled' |
| error_message | Exception | Error message if failed |
| additional_info | JSON | Configuration and results |
#### sync_status
| PostgreSQL Column | Source | Notes |
|----------------------|--------------------------------|---------------------------------------------------------------|
| table_name | Code | Name of imported table |
| last_sync_timestamp | NOW() | Timestamp of successful sync |
| last_sync_id | NULL | Not used currently |
## Special Calculations
### Date Validation
MySQL dates are validated before insertion into PostgreSQL:
```javascript
function validateDate(mysqlDate) {
if (!mysqlDate || mysqlDate === '0000-00-00' || mysqlDate === '0000-00-00 00:00:00') {
return null;
}
// Check if the date is valid
const date = new Date(mysqlDate);
return isNaN(date.getTime()) ? null : mysqlDate;
}
```
### Retry Mechanism
Operations that might fail temporarily are retried with exponential backoff:
```javascript
async function withRetry(operation, errorMessage) {
let lastError;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
console.error(`${errorMessage} (Attempt ${attempt}/${MAX_RETRIES}):`, error);
if (attempt < MAX_RETRIES) {
const backoffTime = RETRY_DELAY * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, backoffTime));
}
}
}
throw lastError;
}
```
### Progress Tracking
Progress is tracked with estimated time remaining:
```javascript
function estimateRemaining(startTime, current, total) {
if (current === 0) return "Calculating...";
const elapsedSeconds = (Date.now() - startTime) / 1000;
const itemsPerSecond = current / elapsedSeconds;
const remainingItems = total - current;
const remainingSeconds = remainingItems / itemsPerSecond;
return formatElapsedTime(remainingSeconds);
}
```
## Implementation Notes
### Transaction Management
All imports use transactions to ensure data consistency:
- **Categories**: Uses savepoints for each category type
- **Products**: Uses a single transaction for the entire import
- **Orders**: Uses a single transaction with temporary tables
- **Purchase Orders**: Uses a single transaction with temporary tables
### Memory Usage Optimization
To minimize memory usage when processing large datasets:
1. Data is processed in batches (100-5000 records per batch)
2. Temporary tables are used for intermediate data
3. Some queries use cursors to avoid loading all results at once
### MySQL vs PostgreSQL Compatibility
The scripts handle differences between MySQL and PostgreSQL:
1. MySQL-specific syntax like `USE INDEX` is removed for PostgreSQL
2. `GROUP_CONCAT` in MySQL becomes string operations in PostgreSQL
3. Transaction syntax differences are abstracted in the connection wrapper
4. PostgreSQL's `ON CONFLICT` replaces MySQL's `ON DUPLICATE KEY UPDATE`
### SSH Tunnel
Database connections go through an SSH tunnel for security:
```javascript
ssh.forwardOut(
"127.0.0.1",
0,
sshConfig.prodDbConfig.host,
sshConfig.prodDbConfig.port,
async (err, stream) => {
if (err) reject(err);
resolve({ ssh, stream });
}
);
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,209 @@
// ecosystem.config.js
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables safely with error handling
const loadEnvFile = (envPath) => {
try {
console.log('Loading env from:', envPath);
const result = dotenv.config({ path: envPath });
if (result.error) {
console.warn(`Warning: .env file not found or invalid at ${envPath}:`, result.error.message);
return {};
}
console.log('Env variables loaded from', envPath, ':', Object.keys(result.parsed || {}));
return result.parsed || {};
} catch (error) {
console.warn(`Warning: Error loading .env file at ${envPath}:`, error.message);
return {};
}
}
// Load environment variables for each server
const authEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/auth-server/.env'));
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/aircall-server/.env'));
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/klaviyo-server/.env'));
const metaEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/meta-server/.env'));
const googleAnalyticsEnv = require('dotenv').config({
path: path.resolve(__dirname, 'dashboard/google-server/.env')
}).parsed || {};
const typeformEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/typeform-server/.env'));
const inventoryEnv = loadEnvFile(path.resolve(__dirname, 'inventory/.env'));
// Common log settings for all apps
const logSettings = {
log_rotate: true,
max_size: '10M',
retain: '10',
log_date_format: 'YYYY-MM-DD HH:mm:ss'
};
// Common app settings
const commonSettings = {
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
time: true,
...logSettings,
ignore_watch: [
'node_modules',
'logs',
'.git',
'*.log'
],
min_uptime: 5000,
max_restarts: 5,
restart_delay: 4000,
listen_timeout: 50000,
kill_timeout: 5000,
node_args: '--max-old-space-size=1536'
};
module.exports = { module.exports = {
apps: [ apps: [
{
...commonSettings,
name: 'auth-server',
script: './dashboard/auth-server/index.js',
env: {
NODE_ENV: 'production',
PORT: 3003,
...authEnv
},
error_file: 'dashboard/auth-server/logs/pm2/err.log',
out_file: 'dashboard/auth-server/logs/pm2/out.log',
log_file: 'dashboard/auth-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3003
},
env_development: {
NODE_ENV: 'development',
PORT: 3003
}
},
{
...commonSettings,
name: 'aircall-server',
script: './dashboard/aircall-server/server.js',
env: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002,
...aircallEnv
},
error_file: 'dashboard/aircall-server/logs/pm2/err.log',
out_file: 'dashboard/aircall-server/logs/pm2/out.log',
log_file: 'dashboard/aircall-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002
}
},
{
...commonSettings,
name: 'klaviyo-server',
script: './dashboard/klaviyo-server/server.js',
env: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004,
...klaviyoEnv
},
error_file: 'dashboard/klaviyo-server/logs/pm2/err.log',
out_file: 'dashboard/klaviyo-server/logs/pm2/out.log',
log_file: 'dashboard/klaviyo-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004
}
},
{
...commonSettings,
name: 'meta-server',
script: './dashboard/meta-server/server.js',
env: {
NODE_ENV: 'production',
PORT: 3005,
...metaEnv
},
error_file: 'dashboard/meta-server/logs/pm2/err.log',
out_file: 'dashboard/meta-server/logs/pm2/out.log',
log_file: 'dashboard/meta-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3005
}
},
{
name: "gorgias-server",
script: "./dashboard/gorgias-server/server.js",
env: {
NODE_ENV: "development",
PORT: 3006
},
env_production: {
NODE_ENV: "production",
PORT: 3006
},
error_file: "dashboard/logs/gorgias-server-error.log",
out_file: "dashboard/logs/gorgias-server-out.log",
log_file: "dashboard/logs/gorgias-server-combined.log",
time: true
},
{
...commonSettings,
name: 'google-server',
script: path.resolve(__dirname, 'dashboard/google-server/server.js'),
watch: false,
env: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007,
...googleAnalyticsEnv
},
error_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/err.log'),
out_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/out.log'),
log_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/combined.log'),
env_production: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007
}
},
{
...commonSettings,
name: 'typeform-server',
script: './dashboard/typeform-server/server.js',
env: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008,
...typeformEnv
},
error_file: 'dashboard/typeform-server/logs/pm2/err.log',
out_file: 'dashboard/typeform-server/logs/pm2/out.log',
log_file: 'dashboard/typeform-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008
}
},
{
...commonSettings,
name: 'inventory-server',
script: './inventory/src/server.js',
env: {
NODE_ENV: 'production',
PORT: 3010,
...inventoryEnv
},
error_file: 'inventory/logs/pm2/err.log',
out_file: 'inventory/logs/pm2/out.log',
log_file: 'inventory/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3010,
...inventoryEnv
}
},
{ {
...commonSettings, ...commonSettings,
name: 'new-auth-server', name: 'new-auth-server',
@@ -7,16 +211,12 @@ module.exports = {
env: { env: {
NODE_ENV: 'production', NODE_ENV: 'production',
AUTH_PORT: 3011, AUTH_PORT: 3011,
...inventoryEnv,
JWT_SECRET: process.env.JWT_SECRET JWT_SECRET: process.env.JWT_SECRET
}, },
error_file: 'inventory-server/auth/logs/pm2/err.log', error_file: 'inventory-server/auth/logs/pm2/err.log',
out_file: 'inventory-server/auth/logs/pm2/out.log', out_file: 'inventory-server/auth/logs/pm2/out.log',
log_file: 'inventory-server/auth/logs/pm2/combined.log', log_file: 'inventory-server/auth/logs/pm2/combined.log'
env_production: {
NODE_ENV: 'production',
AUTH_PORT: 3011,
JWT_SECRET: process.env.JWT_SECRET
}
} }
] ]
}; };

View File

@@ -0,0 +1,103 @@
require('dotenv').config({ path: '../.env' });
const bcrypt = require('bcrypt');
const { Pool } = require('pg');
const inquirer = require('inquirer');
// Log connection details for debugging (remove in production)
console.log('Attempting to connect with:', {
host: process.env.DB_HOST,
user: process.env.DB_USER,
database: process.env.DB_NAME,
port: process.env.DB_PORT
});
const 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,
});
async function promptUser() {
const questions = [
{
type: 'input',
name: 'username',
message: 'Enter username:',
validate: (input) => {
if (input.length < 3) {
return 'Username must be at least 3 characters long';
}
return true;
}
},
{
type: 'password',
name: 'password',
message: 'Enter password:',
mask: '*',
validate: (input) => {
if (input.length < 8) {
return 'Password must be at least 8 characters long';
}
return true;
}
},
{
type: 'password',
name: 'confirmPassword',
message: 'Confirm password:',
mask: '*',
validate: (input, answers) => {
if (input !== answers.password) {
return 'Passwords do not match';
}
return true;
}
}
];
return inquirer.prompt(questions);
}
async function addUser() {
try {
// Get user input
const answers = await promptUser();
const { username, password } = answers;
// Hash password
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Check if user already exists
const checkResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (checkResult.rows.length > 0) {
console.error('Error: Username already exists');
process.exit(1);
}
// Insert new user
const result = await pool.query(
'INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id',
[username, hashedPassword]
);
console.log(`User ${username} created successfully with id ${result.rows[0].id}`);
} catch (error) {
console.error('Error creating user:', error);
console.error('Error details:', error.message);
if (error.code) {
console.error('Error code:', error.code);
}
} finally {
await pool.end();
}
}
addUser();

View File

@@ -1,41 +0,0 @@
const bcrypt = require('bcrypt');
const mysql = require('mysql2/promise');
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
});
require('dotenv').config({ path: '../.env' });
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
};
async function addUser() {
const username = await askQuestion('Enter username: ');
const password = await askQuestion('Enter password: ');
const hashedPassword = await bcrypt.hash(password, 10);
const connection = await mysql.createConnection(dbConfig);
try {
await connection.query('INSERT INTO users (username, password) VALUES (?, ?)', [username, hashedPassword]);
console.log(`User ${username} added successfully.`);
} catch (error) {
console.error('Error adding user:', error);
} finally {
connection.end();
readline.close();
}
}
function askQuestion(query) {
return new Promise(resolve => readline.question(query, ans => {
resolve(ans);
}));
}
addUser();

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,19 @@
{ {
"name": "auth-server", "name": "inventory-auth-server",
"version": "1.0.0", "version": "1.0.0",
"description": "Authentication server for inventory management", "description": "Authentication server for inventory management system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js"
"dev": "nodemon server.js",
"add_user": "node add_user.js"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2" "inquirer": "^8.2.6",
}, "jsonwebtoken": "^9.0.2",
"devDependencies": { "morgan": "^1.10.0",
"nodemon": "^3.1.0" "pg": "^8.11.3"
} }
} }

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

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

View File

@@ -1,6 +1,89 @@
CREATE TABLE `users` ( CREATE TABLE users (
`id` INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
`username` VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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;

View File

@@ -1,135 +1,164 @@
require('dotenv').config({ path: '../.env' });
const express = require('express'); const express = require('express');
const cors = require('cors');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const cors = require('cors'); const { Pool } = require('pg');
const mysql = require('mysql2/promise'); const morgan = require('morgan');
require('dotenv').config({ path: '../.env' }); const authRoutes = require('./routes');
// Log startup configuration
console.log('Starting auth server with config:', {
host: process.env.DB_HOST,
user: process.env.DB_USER,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
auth_port: process.env.AUTH_PORT
});
const app = express(); const app = express();
const PORT = process.env.AUTH_PORT || 3011; const port = process.env.AUTH_PORT || 3011;
// Database configuration // Database configuration
const dbConfig = { const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
}; port: process.env.DB_PORT,
});
// Create a connection pool // Make pool available globally
const pool = mysql.createPool(dbConfig); global.pool = pool;
app.use(cors({ // Middleware
origin: [
'https://inventory.kent.pw',
'http://localhost:5173',
'http://127.0.0.1:5173',
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
],
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true,
exposedHeaders: ['set-cookie']
}));
app.use(express.json()); app.use(express.json());
app.use(morgan('combined'));
// Debug middleware to log request details app.use(cors({
app.use((req, res, next) => { origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'],
console.log('Request details:', { credentials: true
method: req.method, }));
url: req.url,
origin: req.get('Origin'),
headers: req.headers,
body: req.body,
});
next();
});
// Registration endpoint
app.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
const connection = await pool.getConnection();
await connection.query('INSERT INTO users (username, password) VALUES (?, ?)', [username, hashedPassword]);
connection.release();
res.status(201).json({ message: 'User registered successfully' });
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
// Login endpoint // Login endpoint
app.post('/login', async (req, res) => { app.post('/login', async (req, res) => {
const { username, password } = req.body;
try { try {
const { username, password } = req.body; // Get user from database
console.log(`Login attempt for user: ${username}`); const result = await pool.query(
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
const connection = await pool.getConnection(); [username]
const [rows] = await connection.query(
'SELECT * FROM users WHERE username = ?',
[username],
); );
connection.release();
if (rows.length === 1) { const user = result.rows[0];
const user = rows[0];
const passwordMatch = await bcrypt.compare(password, user.password);
if (passwordMatch) { // Check if user exists and password is correct
console.log(`User ${username} authenticated successfully`); if (!user || !(await bcrypt.compare(password, user.password))) {
const token = jwt.sign( return res.status(401).json({ error: 'Invalid username or password' });
{ username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' },
);
res.json({ token });
} else {
console.error(`Invalid password for user: ${username}`);
res.status(401).json({ error: 'Invalid credentials' });
}
} else {
console.error(`User not found: ${username}`);
res.status(401).json({ error: 'Invalid credentials' });
} }
// 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(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
// 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) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
// Protected endpoint example // User info endpoint
app.get('/protected', async (req, res) => { app.get('/me', async (req, res) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Unauthorized' }); if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
} }
const token = authHeader.split(' ')[1];
try { try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Optionally, you can fetch the user from the database here // Get user details from database
// to verify that the user still exists or to get more user information const userResult = await pool.query(
const connection = await pool.getConnection(); 'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
const [rows] = await connection.query('SELECT * FROM users WHERE username = ?', [decoded.username]); [decoded.userId]
connection.release(); );
if (rows.length === 0) { if (userResult.rows.length === 0) {
return res.status(401).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
res.json({ message: 'Protected resource accessed', user: decoded }); 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) { } catch (error) {
console.error('Protected endpoint error:', error); console.error('Token verification error:', error);
res.status(403).json({ error: 'Invalid token' }); res.status(401).json({ error: 'Invalid token' });
} }
}); });
app.listen(PORT, "0.0.0.0", () => { // Mount all routes from routes.js
console.log(`Auth server running on port ${PORT}`); app.use('/', authRoutes);
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something broke!' });
});
// Start server
app.listen(port, () => {
console.log(`Auth server running on port ${port}`);
});

View File

@@ -1,150 +1,207 @@
-- Configuration tables schema -- Configuration tables schema
-- Create function for updating timestamps if it doesn't exist
CREATE OR REPLACE FUNCTION update_updated_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create function for updating updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Stock threshold configurations -- Stock threshold configurations
CREATE TABLE IF NOT EXISTS stock_thresholds ( CREATE TABLE stock_thresholds (
id INT NOT NULL, id INTEGER NOT NULL,
category_id BIGINT, -- NULL means default/global threshold category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors vendor VARCHAR(100), -- NULL means applies to all vendors
critical_days INT NOT NULL DEFAULT 7, critical_days INTEGER NOT NULL DEFAULT 7,
reorder_days INT NOT NULL DEFAULT 14, reorder_days INTEGER NOT NULL DEFAULT 14,
overstock_days INT NOT NULL DEFAULT 90, overstock_days INTEGER NOT NULL DEFAULT 90,
low_stock_threshold INT NOT NULL DEFAULT 5, low_stock_threshold INTEGER NOT NULL DEFAULT 5,
min_reorder_quantity INT NOT NULL DEFAULT 1, min_reorder_quantity INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor), UNIQUE (category_id, vendor)
INDEX idx_st_metrics (category_id, vendor)
); );
CREATE TRIGGER update_stock_thresholds_updated
BEFORE UPDATE ON stock_thresholds
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE INDEX idx_st_metrics ON stock_thresholds(category_id, vendor);
-- Lead time threshold configurations -- Lead time threshold configurations
CREATE TABLE IF NOT EXISTS lead_time_thresholds ( CREATE TABLE lead_time_thresholds (
id INT NOT NULL, id INTEGER NOT NULL,
category_id BIGINT, -- NULL means default/global threshold category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors vendor VARCHAR(100), -- NULL means applies to all vendors
target_days INT NOT NULL DEFAULT 14, target_days INTEGER NOT NULL DEFAULT 14,
warning_days INT NOT NULL DEFAULT 21, warning_days INTEGER NOT NULL DEFAULT 21,
critical_days INT NOT NULL DEFAULT 30, critical_days INTEGER NOT NULL DEFAULT 30,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor) UNIQUE (category_id, vendor)
); );
CREATE TRIGGER update_lead_time_thresholds_updated
BEFORE UPDATE ON lead_time_thresholds
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Sales velocity window configurations -- Sales velocity window configurations
CREATE TABLE IF NOT EXISTS sales_velocity_config ( CREATE TABLE sales_velocity_config (
id INT NOT NULL, id INTEGER NOT NULL,
category_id BIGINT, -- NULL means default/global threshold category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors vendor VARCHAR(100), -- NULL means applies to all vendors
daily_window_days INT NOT NULL DEFAULT 30, daily_window_days INTEGER NOT NULL DEFAULT 30,
weekly_window_days INT NOT NULL DEFAULT 7, weekly_window_days INTEGER NOT NULL DEFAULT 7,
monthly_window_days INT NOT NULL DEFAULT 90, monthly_window_days INTEGER NOT NULL DEFAULT 90,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor), UNIQUE (category_id, vendor)
INDEX idx_sv_metrics (category_id, vendor)
); );
CREATE TRIGGER update_sales_velocity_config_updated
BEFORE UPDATE ON sales_velocity_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE INDEX idx_sv_metrics ON sales_velocity_config(category_id, vendor);
-- ABC Classification configurations -- ABC Classification configurations
CREATE TABLE IF NOT EXISTS abc_classification_config ( CREATE TABLE abc_classification_config (
id INT NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
a_threshold DECIMAL(5,2) NOT NULL DEFAULT 20.0, a_threshold DECIMAL(5,2) NOT NULL DEFAULT 20.0,
b_threshold DECIMAL(5,2) NOT NULL DEFAULT 50.0, b_threshold DECIMAL(5,2) NOT NULL DEFAULT 50.0,
classification_period_days INT NOT NULL DEFAULT 90, classification_period_days INTEGER NOT NULL DEFAULT 90,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );
CREATE TRIGGER update_abc_classification_config_updated
BEFORE UPDATE ON abc_classification_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Safety stock configurations -- Safety stock configurations
CREATE TABLE IF NOT EXISTS safety_stock_config ( CREATE TABLE safety_stock_config (
id INT NOT NULL, id INTEGER NOT NULL,
category_id BIGINT, -- NULL means default/global threshold category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors vendor VARCHAR(100), -- NULL means applies to all vendors
coverage_days INT NOT NULL DEFAULT 14, coverage_days INTEGER NOT NULL DEFAULT 14,
service_level DECIMAL(5,2) NOT NULL DEFAULT 95.0, service_level DECIMAL(5,2) NOT NULL DEFAULT 95.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor), UNIQUE (category_id, vendor)
INDEX idx_ss_metrics (category_id, vendor)
); );
CREATE TRIGGER update_safety_stock_config_updated
BEFORE UPDATE ON safety_stock_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE INDEX idx_ss_metrics ON safety_stock_config(category_id, vendor);
-- Turnover rate configurations -- Turnover rate configurations
CREATE TABLE IF NOT EXISTS turnover_config ( CREATE TABLE turnover_config (
id INT NOT NULL, id INTEGER NOT NULL,
category_id BIGINT, -- NULL means default/global threshold category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors vendor VARCHAR(100), -- NULL means applies to all vendors
calculation_period_days INT NOT NULL DEFAULT 30, calculation_period_days INTEGER NOT NULL DEFAULT 30,
target_rate DECIMAL(10,2) NOT NULL DEFAULT 1.0, target_rate DECIMAL(10,2) NOT NULL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor) UNIQUE (category_id, vendor)
); );
CREATE TRIGGER update_turnover_config_updated
BEFORE UPDATE ON turnover_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Create table for sales seasonality factors -- Create table for sales seasonality factors
CREATE TABLE IF NOT EXISTS sales_seasonality ( CREATE TABLE sales_seasonality (
month INT NOT NULL, month INTEGER NOT NULL,
seasonality_factor DECIMAL(5,3) DEFAULT 0, seasonality_factor DECIMAL(5,3) DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (month), PRIMARY KEY (month),
CHECK (month BETWEEN 1 AND 12), CONSTRAINT month_range CHECK (month BETWEEN 1 AND 12),
CHECK (seasonality_factor BETWEEN -1.0 AND 1.0) CONSTRAINT seasonality_range CHECK (seasonality_factor BETWEEN -1.0 AND 1.0)
); );
-- Insert default global thresholds if not exists CREATE TRIGGER update_sales_seasonality_updated
BEFORE UPDATE ON sales_seasonality
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Insert default global thresholds
INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days) INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days)
VALUES (1, NULL, NULL, 7, 14, 90) VALUES (1, NULL, NULL, 7, 14, 90)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
critical_days = VALUES(critical_days), critical_days = EXCLUDED.critical_days,
reorder_days = VALUES(reorder_days), reorder_days = EXCLUDED.reorder_days,
overstock_days = VALUES(overstock_days); overstock_days = EXCLUDED.overstock_days;
INSERT INTO lead_time_thresholds (id, category_id, vendor, target_days, warning_days, critical_days) INSERT INTO lead_time_thresholds (id, category_id, vendor, target_days, warning_days, critical_days)
VALUES (1, NULL, NULL, 14, 21, 30) VALUES (1, NULL, NULL, 14, 21, 30)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
target_days = VALUES(target_days), target_days = EXCLUDED.target_days,
warning_days = VALUES(warning_days), warning_days = EXCLUDED.warning_days,
critical_days = VALUES(critical_days); critical_days = EXCLUDED.critical_days;
INSERT INTO sales_velocity_config (id, category_id, vendor, daily_window_days, weekly_window_days, monthly_window_days) INSERT INTO sales_velocity_config (id, category_id, vendor, daily_window_days, weekly_window_days, monthly_window_days)
VALUES (1, NULL, NULL, 30, 7, 90) VALUES (1, NULL, NULL, 30, 7, 90)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
daily_window_days = VALUES(daily_window_days), daily_window_days = EXCLUDED.daily_window_days,
weekly_window_days = VALUES(weekly_window_days), weekly_window_days = EXCLUDED.weekly_window_days,
monthly_window_days = VALUES(monthly_window_days); monthly_window_days = EXCLUDED.monthly_window_days;
INSERT INTO abc_classification_config (id, a_threshold, b_threshold, classification_period_days) INSERT INTO abc_classification_config (id, a_threshold, b_threshold, classification_period_days)
VALUES (1, 20.0, 50.0, 90) VALUES (1, 20.0, 50.0, 90)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
a_threshold = VALUES(a_threshold), a_threshold = EXCLUDED.a_threshold,
b_threshold = VALUES(b_threshold), b_threshold = EXCLUDED.b_threshold,
classification_period_days = VALUES(classification_period_days); classification_period_days = EXCLUDED.classification_period_days;
INSERT INTO safety_stock_config (id, category_id, vendor, coverage_days, service_level) INSERT INTO safety_stock_config (id, category_id, vendor, coverage_days, service_level)
VALUES (1, NULL, NULL, 14, 95.0) VALUES (1, NULL, NULL, 14, 95.0)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
coverage_days = VALUES(coverage_days), coverage_days = EXCLUDED.coverage_days,
service_level = VALUES(service_level); service_level = EXCLUDED.service_level;
INSERT INTO turnover_config (id, category_id, vendor, calculation_period_days, target_rate) INSERT INTO turnover_config (id, category_id, vendor, calculation_period_days, target_rate)
VALUES (1, NULL, NULL, 30, 1.0) VALUES (1, NULL, NULL, 30, 1.0)
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
calculation_period_days = VALUES(calculation_period_days), calculation_period_days = EXCLUDED.calculation_period_days,
target_rate = VALUES(target_rate); target_rate = EXCLUDED.target_rate;
-- Insert default seasonality factors (neutral) -- Insert default seasonality factors (neutral)
INSERT INTO sales_seasonality (month, seasonality_factor) INSERT INTO sales_seasonality (month, seasonality_factor)
VALUES VALUES
(1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0),
(7, 0), (8, 0), (9, 0), (10, 0), (11, 0), (12, 0) (7, 0), (8, 0), (9, 0), (10, 0), (11, 0), (12, 0)
ON DUPLICATE KEY UPDATE last_updated = CURRENT_TIMESTAMP; ON CONFLICT (month) DO UPDATE SET
last_updated = CURRENT_TIMESTAMP;
-- View to show thresholds with category names -- View to show thresholds with category names
CREATE OR REPLACE VIEW stock_thresholds_view AS CREATE OR REPLACE VIEW stock_thresholds_view AS
@@ -153,9 +210,9 @@ SELECT
c.name as category_name, c.name as category_name,
CASE CASE
WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 'Global Default' WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 'Global Default'
WHEN st.category_id IS NULL THEN CONCAT('Vendor: ', st.vendor) WHEN st.category_id IS NULL THEN 'Vendor: ' || st.vendor
WHEN st.vendor IS NULL THEN CONCAT('Category: ', c.name) WHEN st.vendor IS NULL THEN 'Category: ' || c.name
ELSE CONCAT('Category: ', c.name, ' / Vendor: ', st.vendor) ELSE 'Category: ' || c.name || ' / Vendor: ' || st.vendor
END as threshold_scope END as threshold_scope
FROM FROM
stock_thresholds st stock_thresholds st
@@ -171,59 +228,51 @@ ORDER BY
c.name, c.name,
st.vendor; st.vendor;
-- History and status tables
CREATE TABLE IF NOT EXISTS calculate_history ( CREATE TABLE IF NOT EXISTS calculate_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, start_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP NULL, end_time TIMESTAMP WITH TIME ZONE NULL,
duration_seconds INT, duration_seconds INTEGER,
duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds / 60.0) STORED, duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds::decimal / 60.0) STORED,
total_products INT DEFAULT 0, total_products INTEGER DEFAULT 0,
total_orders INT DEFAULT 0, total_orders INTEGER DEFAULT 0,
total_purchase_orders INT DEFAULT 0, total_purchase_orders INTEGER DEFAULT 0,
processed_products INT DEFAULT 0, processed_products INTEGER DEFAULT 0,
processed_orders INT DEFAULT 0, processed_orders INTEGER DEFAULT 0,
processed_purchase_orders INT DEFAULT 0, processed_purchase_orders INTEGER DEFAULT 0,
status ENUM('running', 'completed', 'failed', 'cancelled') DEFAULT 'running', status calculation_status DEFAULT 'running',
error_message TEXT, error_message TEXT,
additional_info JSON, additional_info JSONB
INDEX idx_status_time (status, start_time)
); );
CREATE TABLE IF NOT EXISTS calculate_status ( CREATE TABLE IF NOT EXISTS calculate_status (
module_name ENUM( module_name module_name PRIMARY KEY,
'product_metrics', last_calculation_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
'time_aggregates',
'financial_metrics',
'vendor_metrics',
'category_metrics',
'brand_metrics',
'sales_forecasts',
'abc_classification'
) PRIMARY KEY,
last_calculation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_last_calc (last_calculation_timestamp)
); );
CREATE TABLE IF NOT EXISTS sync_status ( CREATE TABLE IF NOT EXISTS sync_status (
table_name VARCHAR(50) PRIMARY KEY, table_name VARCHAR(50) PRIMARY KEY,
last_sync_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_sync_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_sync_id BIGINT, last_sync_id BIGINT
INDEX idx_last_sync (last_sync_timestamp)
); );
CREATE TABLE IF NOT EXISTS import_history ( CREATE TABLE IF NOT EXISTS import_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(50) NOT NULL, table_name VARCHAR(50) NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, start_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP NULL, end_time TIMESTAMP WITH TIME ZONE NULL,
duration_seconds INT, duration_seconds INTEGER,
duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds / 60.0) STORED, duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds::decimal / 60.0) STORED,
records_added INT DEFAULT 0, records_added INTEGER DEFAULT 0,
records_updated INT DEFAULT 0, records_updated INTEGER DEFAULT 0,
is_incremental BOOLEAN DEFAULT FALSE, is_incremental BOOLEAN DEFAULT FALSE,
status ENUM('running', 'completed', 'failed', 'cancelled') DEFAULT 'running', status calculation_status DEFAULT 'running',
error_message TEXT, error_message TEXT,
additional_info JSON, additional_info JSONB
INDEX idx_table_time (table_name, start_time), );
INDEX idx_status (status)
); -- Create all indexes after tables are fully created
CREATE INDEX IF NOT EXISTS idx_last_calc ON calculate_status(last_calculation_timestamp);
CREATE INDEX IF NOT EXISTS idx_last_sync ON sync_status(last_sync_timestamp);
CREATE INDEX IF NOT EXISTS idx_table_time ON import_history(table_name, start_time);

View File

@@ -1,8 +1,8 @@
-- Disable foreign key checks -- Disable foreign key checks
SET FOREIGN_KEY_CHECKS = 0; SET session_replication_role = 'replica';
-- Temporary tables for batch metrics processing -- Temporary tables for batch metrics processing
CREATE TABLE IF NOT EXISTS temp_sales_metrics ( CREATE TABLE temp_sales_metrics (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
daily_sales_avg DECIMAL(10,3), daily_sales_avg DECIMAL(10,3),
weekly_sales_avg DECIMAL(10,3), weekly_sales_avg DECIMAL(10,3),
@@ -14,9 +14,9 @@ CREATE TABLE IF NOT EXISTS temp_sales_metrics (
PRIMARY KEY (pid) PRIMARY KEY (pid)
); );
CREATE TABLE IF NOT EXISTS temp_purchase_metrics ( CREATE TABLE temp_purchase_metrics (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
avg_lead_time_days INT, avg_lead_time_days INTEGER,
last_purchase_date DATE, last_purchase_date DATE,
first_received_date DATE, first_received_date DATE,
last_received_date DATE, last_received_date DATE,
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS temp_purchase_metrics (
); );
-- New table for product metrics -- New table for product metrics
CREATE TABLE IF NOT EXISTS product_metrics ( CREATE TABLE product_metrics (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Sales velocity metrics -- Sales velocity metrics
@@ -32,16 +32,16 @@ CREATE TABLE IF NOT EXISTS product_metrics (
weekly_sales_avg DECIMAL(10,3), weekly_sales_avg DECIMAL(10,3),
monthly_sales_avg DECIMAL(10,3), monthly_sales_avg DECIMAL(10,3),
avg_quantity_per_order DECIMAL(10,3), avg_quantity_per_order DECIMAL(10,3),
number_of_orders INT, number_of_orders INTEGER,
first_sale_date DATE, first_sale_date DATE,
last_sale_date DATE, last_sale_date DATE,
-- Stock metrics -- Stock metrics
days_of_inventory INT, days_of_inventory INTEGER,
weeks_of_inventory INT, weeks_of_inventory INTEGER,
reorder_point INT, reorder_point INTEGER,
safety_stock INT, safety_stock INTEGER,
reorder_qty INT DEFAULT 0, reorder_qty INTEGER DEFAULT 0,
overstocked_amt INT DEFAULT 0, overstocked_amt INTEGER DEFAULT 0,
-- Financial metrics -- Financial metrics
avg_margin_percent DECIMAL(10,3), avg_margin_percent DECIMAL(10,3),
total_revenue DECIMAL(10,3), total_revenue DECIMAL(10,3),
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS product_metrics (
gross_profit DECIMAL(10,3), gross_profit DECIMAL(10,3),
gmroi DECIMAL(10,3), gmroi DECIMAL(10,3),
-- Purchase metrics -- Purchase metrics
avg_lead_time_days INT, avg_lead_time_days INTEGER,
last_purchase_date DATE, last_purchase_date DATE,
first_received_date DATE, first_received_date DATE,
last_received_date DATE, last_received_date DATE,
@@ -60,48 +60,50 @@ CREATE TABLE IF NOT EXISTS product_metrics (
-- Turnover metrics -- Turnover metrics
turnover_rate DECIMAL(12,3), turnover_rate DECIMAL(12,3),
-- Lead time metrics -- Lead time metrics
current_lead_time INT, current_lead_time INTEGER,
target_lead_time INT, target_lead_time INTEGER,
lead_time_status VARCHAR(20), lead_time_status VARCHAR(20),
-- Forecast metrics -- Forecast metrics
forecast_accuracy DECIMAL(5,2) DEFAULT NULL, forecast_accuracy DECIMAL(5,2) DEFAULT NULL,
forecast_bias DECIMAL(5,2) DEFAULT NULL, forecast_bias DECIMAL(5,2) DEFAULT NULL,
last_forecast_date DATE DEFAULT NULL, last_forecast_date DATE DEFAULT NULL,
PRIMARY KEY (pid), PRIMARY KEY (pid),
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
INDEX idx_metrics_revenue (total_revenue),
INDEX idx_metrics_stock_status (stock_status),
INDEX idx_metrics_lead_time (lead_time_status),
INDEX idx_metrics_turnover (turnover_rate),
INDEX idx_metrics_last_calculated (last_calculated_at),
INDEX idx_metrics_abc (abc_class),
INDEX idx_metrics_sales (daily_sales_avg, weekly_sales_avg, monthly_sales_avg),
INDEX idx_metrics_forecast (forecast_accuracy, forecast_bias)
); );
CREATE INDEX idx_metrics_revenue ON product_metrics(total_revenue);
CREATE INDEX idx_metrics_stock_status ON product_metrics(stock_status);
CREATE INDEX idx_metrics_lead_time ON product_metrics(lead_time_status);
CREATE INDEX idx_metrics_turnover ON product_metrics(turnover_rate);
CREATE INDEX idx_metrics_last_calculated ON product_metrics(last_calculated_at);
CREATE INDEX idx_metrics_abc ON product_metrics(abc_class);
CREATE INDEX idx_metrics_sales ON product_metrics(daily_sales_avg, weekly_sales_avg, monthly_sales_avg);
CREATE INDEX idx_metrics_forecast ON product_metrics(forecast_accuracy, forecast_bias);
-- New table for time-based aggregates -- New table for time-based aggregates
CREATE TABLE IF NOT EXISTS product_time_aggregates ( CREATE TABLE product_time_aggregates (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
year INT NOT NULL, year INTEGER NOT NULL,
month INT NOT NULL, month INTEGER NOT NULL,
-- Sales metrics -- Sales metrics
total_quantity_sold INT DEFAULT 0, total_quantity_sold INTEGER DEFAULT 0,
total_revenue DECIMAL(10,3) DEFAULT 0, total_revenue DECIMAL(10,3) DEFAULT 0,
total_cost DECIMAL(10,3) DEFAULT 0, total_cost DECIMAL(10,3) DEFAULT 0,
order_count INT DEFAULT 0, order_count INTEGER DEFAULT 0,
-- Stock changes -- Stock changes
stock_received INT DEFAULT 0, stock_received INTEGER DEFAULT 0,
stock_ordered INT DEFAULT 0, stock_ordered INTEGER DEFAULT 0,
-- Calculated fields -- Calculated fields
avg_price DECIMAL(10,3), avg_price DECIMAL(10,3),
profit_margin DECIMAL(10,3), profit_margin DECIMAL(10,3),
inventory_value DECIMAL(10,3), inventory_value DECIMAL(10,3),
gmroi DECIMAL(10,3), gmroi DECIMAL(10,3),
PRIMARY KEY (pid, year, month), PRIMARY KEY (pid, year, month),
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
INDEX idx_date (year, month)
); );
CREATE INDEX idx_date ON product_time_aggregates(year, month);
-- Create vendor_details table -- Create vendor_details table
CREATE TABLE vendor_details ( CREATE TABLE vendor_details (
vendor VARCHAR(100) PRIMARY KEY, vendor VARCHAR(100) PRIMARY KEY,
@@ -110,45 +112,47 @@ CREATE TABLE vendor_details (
phone VARCHAR(50), phone VARCHAR(50),
status VARCHAR(20) DEFAULT 'active', status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
INDEX idx_status (status) );
) ENGINE=InnoDB;
CREATE INDEX idx_vendor_details_status ON vendor_details(status);
-- New table for vendor metrics -- New table for vendor metrics
CREATE TABLE IF NOT EXISTS vendor_metrics ( CREATE TABLE vendor_metrics (
vendor VARCHAR(100) NOT NULL, vendor VARCHAR(100) NOT NULL,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Performance metrics -- Performance metrics
avg_lead_time_days DECIMAL(10,3), avg_lead_time_days DECIMAL(10,3),
on_time_delivery_rate DECIMAL(5,2), on_time_delivery_rate DECIMAL(5,2),
order_fill_rate DECIMAL(5,2), order_fill_rate DECIMAL(5,2),
total_orders INT DEFAULT 0, total_orders INTEGER DEFAULT 0,
total_late_orders INT DEFAULT 0, total_late_orders INTEGER DEFAULT 0,
total_purchase_value DECIMAL(10,3) DEFAULT 0, total_purchase_value DECIMAL(10,3) DEFAULT 0,
avg_order_value DECIMAL(10,3), avg_order_value DECIMAL(10,3),
-- Product metrics -- Product metrics
active_products INT DEFAULT 0, active_products INTEGER DEFAULT 0,
total_products INT DEFAULT 0, total_products INTEGER DEFAULT 0,
-- Financial metrics -- Financial metrics
total_revenue DECIMAL(10,3) DEFAULT 0, total_revenue DECIMAL(10,3) DEFAULT 0,
avg_margin_percent DECIMAL(5,2), avg_margin_percent DECIMAL(5,2),
-- Status -- Status
status VARCHAR(20) DEFAULT 'active', status VARCHAR(20) DEFAULT 'active',
PRIMARY KEY (vendor), PRIMARY KEY (vendor),
FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE, FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE
INDEX idx_vendor_performance (on_time_delivery_rate),
INDEX idx_vendor_status (status),
INDEX idx_metrics_last_calculated (last_calculated_at),
INDEX idx_vendor_metrics_orders (total_orders, total_late_orders)
); );
CREATE INDEX idx_vendor_performance ON vendor_metrics(on_time_delivery_rate);
CREATE INDEX idx_vendor_status ON vendor_metrics(status);
CREATE INDEX idx_vendor_metrics_last_calculated ON vendor_metrics(last_calculated_at);
CREATE INDEX idx_vendor_metrics_orders ON vendor_metrics(total_orders, total_late_orders);
-- New table for category metrics -- New table for category metrics
CREATE TABLE IF NOT EXISTS category_metrics ( CREATE TABLE category_metrics (
category_id BIGINT NOT NULL, category_id BIGINT NOT NULL,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Product metrics -- Product metrics
product_count INT DEFAULT 0, product_count INTEGER DEFAULT 0,
active_products INT DEFAULT 0, active_products INTEGER DEFAULT 0,
-- Financial metrics -- Financial metrics
total_value DECIMAL(15,3) DEFAULT 0, total_value DECIMAL(15,3) DEFAULT 0,
avg_margin DECIMAL(5,2), avg_margin DECIMAL(5,2),
@@ -157,255 +161,215 @@ CREATE TABLE IF NOT EXISTS category_metrics (
-- Status -- Status
status VARCHAR(20) DEFAULT 'active', status VARCHAR(20) DEFAULT 'active',
PRIMARY KEY (category_id), PRIMARY KEY (category_id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE
INDEX idx_category_status (status),
INDEX idx_category_growth (growth_rate),
INDEX idx_metrics_last_calculated (last_calculated_at),
INDEX idx_category_metrics_products (product_count, active_products)
); );
CREATE INDEX idx_category_status ON category_metrics(status);
CREATE INDEX idx_category_growth ON category_metrics(growth_rate);
CREATE INDEX idx_metrics_last_calculated_cat ON category_metrics(last_calculated_at);
CREATE INDEX idx_category_metrics_products ON category_metrics(product_count, active_products);
-- New table for vendor time-based metrics -- New table for vendor time-based metrics
CREATE TABLE IF NOT EXISTS vendor_time_metrics ( CREATE TABLE vendor_time_metrics (
vendor VARCHAR(100) NOT NULL, vendor VARCHAR(100) NOT NULL,
year INT NOT NULL, year INTEGER NOT NULL,
month INT NOT NULL, month INTEGER NOT NULL,
-- Order metrics -- Order metrics
total_orders INT DEFAULT 0, total_orders INTEGER DEFAULT 0,
late_orders INT DEFAULT 0, late_orders INTEGER DEFAULT 0,
avg_lead_time_days DECIMAL(10,3), avg_lead_time_days DECIMAL(10,3),
-- Financial metrics -- Financial metrics
total_purchase_value DECIMAL(10,3) DEFAULT 0, total_purchase_value DECIMAL(10,3) DEFAULT 0,
total_revenue DECIMAL(10,3) DEFAULT 0, total_revenue DECIMAL(10,3) DEFAULT 0,
avg_margin_percent DECIMAL(5,2), avg_margin_percent DECIMAL(5,2),
PRIMARY KEY (vendor, year, month), PRIMARY KEY (vendor, year, month),
FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE, FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE
INDEX idx_vendor_date (year, month)
); );
CREATE INDEX idx_vendor_date ON vendor_time_metrics(year, month);
-- New table for category time-based metrics -- New table for category time-based metrics
CREATE TABLE IF NOT EXISTS category_time_metrics ( CREATE TABLE category_time_metrics (
category_id BIGINT NOT NULL, category_id BIGINT NOT NULL,
year INT NOT NULL, year INTEGER NOT NULL,
month INT NOT NULL, month INTEGER NOT NULL,
-- Product metrics -- Product metrics
product_count INT DEFAULT 0, product_count INTEGER DEFAULT 0,
active_products INT DEFAULT 0, active_products INTEGER DEFAULT 0,
-- Financial metrics -- Financial metrics
total_value DECIMAL(15,3) DEFAULT 0, total_value DECIMAL(15,3) DEFAULT 0,
total_revenue DECIMAL(15,3) DEFAULT 0, total_revenue DECIMAL(15,3) DEFAULT 0,
avg_margin DECIMAL(5,2), avg_margin DECIMAL(5,2),
turnover_rate DECIMAL(12,3), turnover_rate DECIMAL(12,3),
PRIMARY KEY (category_id, year, month), PRIMARY KEY (category_id, year, month),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE
INDEX idx_category_date (year, month)
); );
CREATE INDEX idx_category_date ON category_time_metrics(year, month);
-- New table for category-based sales metrics -- New table for category-based sales metrics
CREATE TABLE IF NOT EXISTS category_sales_metrics ( CREATE TABLE category_sales_metrics (
category_id BIGINT NOT NULL, category_id BIGINT NOT NULL,
brand VARCHAR(100) NOT NULL, brand VARCHAR(100) NOT NULL,
period_start DATE NOT NULL, period_start DATE NOT NULL,
period_end DATE NOT NULL, period_end DATE NOT NULL,
avg_daily_sales DECIMAL(10,3) DEFAULT 0, avg_daily_sales DECIMAL(10,3) DEFAULT 0,
total_sold INT DEFAULT 0, total_sold INTEGER DEFAULT 0,
num_products INT DEFAULT 0, num_products INTEGER DEFAULT 0,
avg_price DECIMAL(10,3) DEFAULT 0, avg_price DECIMAL(10,3) DEFAULT 0,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (category_id, brand, period_start, period_end), PRIMARY KEY (category_id, brand, period_start, period_end),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE
INDEX idx_category_brand (category_id, brand),
INDEX idx_period (period_start, period_end)
); );
CREATE INDEX idx_category_brand ON category_sales_metrics(category_id, brand);
CREATE INDEX idx_period ON category_sales_metrics(period_start, period_end);
-- New table for brand metrics -- New table for brand metrics
CREATE TABLE IF NOT EXISTS brand_metrics ( CREATE TABLE brand_metrics (
brand VARCHAR(100) NOT NULL, brand VARCHAR(100) NOT NULL,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Product metrics -- Product metrics
product_count INT DEFAULT 0, product_count INTEGER DEFAULT 0,
active_products INT DEFAULT 0, active_products INTEGER DEFAULT 0,
-- Stock metrics -- Stock metrics
total_stock_units INT DEFAULT 0, total_stock_units INTEGER DEFAULT 0,
total_stock_cost DECIMAL(15,2) DEFAULT 0, total_stock_cost DECIMAL(15,2) DEFAULT 0,
total_stock_retail DECIMAL(15,2) DEFAULT 0, total_stock_retail DECIMAL(15,2) DEFAULT 0,
-- Sales metrics -- Sales metrics
total_revenue DECIMAL(15,2) DEFAULT 0, total_revenue DECIMAL(15,2) DEFAULT 0,
avg_margin DECIMAL(5,2) DEFAULT 0, avg_margin DECIMAL(5,2) DEFAULT 0,
growth_rate DECIMAL(5,2) DEFAULT 0, growth_rate DECIMAL(5,2) DEFAULT 0,
PRIMARY KEY (brand), PRIMARY KEY (brand)
INDEX idx_brand_metrics_last_calculated (last_calculated_at),
INDEX idx_brand_metrics_revenue (total_revenue),
INDEX idx_brand_metrics_growth (growth_rate)
); );
CREATE INDEX idx_brand_metrics_last_calculated ON brand_metrics(last_calculated_at);
CREATE INDEX idx_brand_metrics_revenue ON brand_metrics(total_revenue);
CREATE INDEX idx_brand_metrics_growth ON brand_metrics(growth_rate);
-- New table for brand time-based metrics -- New table for brand time-based metrics
CREATE TABLE IF NOT EXISTS brand_time_metrics ( CREATE TABLE brand_time_metrics (
brand VARCHAR(100) NOT NULL, brand VARCHAR(100) NOT NULL,
year INT NOT NULL, year INTEGER NOT NULL,
month INT NOT NULL, month INTEGER NOT NULL,
-- Product metrics -- Product metrics
product_count INT DEFAULT 0, product_count INTEGER DEFAULT 0,
active_products INT DEFAULT 0, active_products INTEGER DEFAULT 0,
-- Stock metrics -- Stock metrics
total_stock_units INT DEFAULT 0, total_stock_units INTEGER DEFAULT 0,
total_stock_cost DECIMAL(15,2) DEFAULT 0, total_stock_cost DECIMAL(15,2) DEFAULT 0,
total_stock_retail DECIMAL(15,2) DEFAULT 0, total_stock_retail DECIMAL(15,2) DEFAULT 0,
-- Sales metrics -- Sales metrics
total_revenue DECIMAL(15,2) DEFAULT 0, total_revenue DECIMAL(15,2) DEFAULT 0,
avg_margin DECIMAL(5,2) DEFAULT 0, avg_margin DECIMAL(5,2) DEFAULT 0,
PRIMARY KEY (brand, year, month), growth_rate DECIMAL(5,2) DEFAULT 0,
INDEX idx_brand_date (year, month) PRIMARY KEY (brand, year, month)
); );
CREATE INDEX idx_brand_time_date ON brand_time_metrics(year, month);
-- New table for sales forecasts -- New table for sales forecasts
CREATE TABLE IF NOT EXISTS sales_forecasts ( CREATE TABLE sales_forecasts (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
forecast_date DATE NOT NULL, forecast_date DATE NOT NULL,
forecast_units DECIMAL(10,2) DEFAULT 0, forecast_quantity INTEGER,
forecast_revenue DECIMAL(10,2) DEFAULT 0, confidence_level DECIMAL(5,2),
confidence_level DECIMAL(5,2) DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (pid, forecast_date), PRIMARY KEY (pid, forecast_date),
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
INDEX idx_forecast_date (forecast_date),
INDEX idx_forecast_last_calculated (last_calculated_at)
); );
CREATE INDEX idx_forecast_date ON sales_forecasts(forecast_date);
-- New table for category forecasts -- New table for category forecasts
CREATE TABLE IF NOT EXISTS category_forecasts ( CREATE TABLE category_forecasts (
category_id BIGINT NOT NULL, category_id BIGINT NOT NULL,
forecast_date DATE NOT NULL, forecast_date DATE NOT NULL,
forecast_units DECIMAL(10,2) DEFAULT 0, forecast_revenue DECIMAL(15,2),
forecast_revenue DECIMAL(10,2) DEFAULT 0, forecast_units INTEGER,
confidence_level DECIMAL(5,2) DEFAULT 0, confidence_level DECIMAL(5,2),
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (category_id, forecast_date), PRIMARY KEY (category_id, forecast_date),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE
INDEX idx_category_forecast_date (forecast_date),
INDEX idx_category_forecast_last_calculated (last_calculated_at)
); );
-- Create view for inventory health CREATE INDEX idx_cat_forecast_date ON category_forecasts(forecast_date);
-- Create views for common calculations
CREATE OR REPLACE VIEW inventory_health AS CREATE OR REPLACE VIEW inventory_health AS
WITH product_thresholds AS ( WITH stock_levels AS (
SELECT SELECT
p.pid, p.pid,
COALESCE( p.title,
-- Try category+vendor specific p.SKU,
(SELECT critical_days FROM stock_thresholds st p.stock_quantity,
JOIN product_categories pc ON st.category_id = pc.cat_id p.preorder_count,
WHERE pc.pid = p.pid pm.daily_sales_avg,
AND st.vendor = p.vendor LIMIT 1), pm.weekly_sales_avg,
-- Try category specific pm.monthly_sales_avg,
(SELECT critical_days FROM stock_thresholds st pm.reorder_point,
JOIN product_categories pc ON st.category_id = pc.cat_id pm.safety_stock,
WHERE pc.pid = p.pid pm.days_of_inventory,
AND st.vendor IS NULL LIMIT 1), pm.weeks_of_inventory,
-- Try vendor specific pm.stock_status,
(SELECT critical_days FROM stock_thresholds st pm.abc_class,
WHERE st.category_id IS NULL pm.turnover_rate,
AND st.vendor = p.vendor LIMIT 1), pm.avg_lead_time_days,
-- Fall back to default pm.current_lead_time,
(SELECT critical_days FROM stock_thresholds st pm.target_lead_time,
WHERE st.category_id IS NULL pm.lead_time_status,
AND st.vendor IS NULL LIMIT 1), p.cost_price,
7 p.price,
) as critical_days, pm.inventory_value,
COALESCE( pm.gmroi
-- Try category+vendor specific
(SELECT reorder_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.cat_id
WHERE pc.pid = p.pid
AND st.vendor = p.vendor LIMIT 1),
-- Try category specific
(SELECT reorder_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.cat_id
WHERE pc.pid = p.pid
AND st.vendor IS NULL LIMIT 1),
-- Try vendor specific
(SELECT reorder_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
-- Fall back to default
(SELECT reorder_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
14
) as reorder_days,
COALESCE(
-- Try category+vendor specific
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.cat_id
WHERE pc.pid = p.pid
AND st.vendor = p.vendor LIMIT 1),
-- Try category specific
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.cat_id
WHERE pc.pid = p.pid
AND st.vendor IS NULL LIMIT 1),
-- Try vendor specific
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
-- Fall back to default
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
90
) as overstock_days
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.managing_stock = true AND p.visible = true
) )
SELECT SELECT
p.pid, *,
p.SKU,
p.title,
p.stock_quantity,
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
COALESCE(pm.days_of_inventory, 0) as days_of_inventory,
COALESCE(pm.reorder_point, 0) as reorder_point,
COALESCE(pm.safety_stock, 0) as safety_stock,
CASE CASE
WHEN pm.daily_sales_avg = 0 THEN 'New' WHEN stock_quantity <= safety_stock THEN 'Critical'
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * pt.critical_days) THEN 'Critical' WHEN stock_quantity <= reorder_point THEN 'Low'
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * pt.reorder_days) THEN 'Reorder' WHEN stock_quantity > (reorder_point * 3) THEN 'Excess'
WHEN p.stock_quantity > (pm.daily_sales_avg * pt.overstock_days) THEN 'Overstocked'
ELSE 'Healthy' ELSE 'Healthy'
END as stock_status END as inventory_status,
FROM CASE
products p WHEN lead_time_status = 'delayed' AND stock_status = 'low' THEN 'High'
LEFT JOIN WHEN lead_time_status = 'delayed' OR stock_status = 'low' THEN 'Medium'
product_metrics pm ON p.pid = pm.pid ELSE 'Low'
LEFT JOIN END as risk_level
product_thresholds pt ON p.pid = pt.pid FROM stock_levels;
WHERE
p.managing_stock = true;
-- Create view for category performance trends -- Create view for category performance trends
CREATE OR REPLACE VIEW category_performance_trends AS CREATE OR REPLACE VIEW category_performance_trends AS
WITH monthly_trends AS (
SELECT
c.cat_id,
c.name as category_name,
ctm.year,
ctm.month,
ctm.product_count,
ctm.active_products,
ctm.total_value,
ctm.total_revenue,
ctm.avg_margin,
ctm.turnover_rate,
LAG(ctm.total_revenue) OVER (PARTITION BY c.cat_id ORDER BY ctm.year, ctm.month) as prev_month_revenue,
LAG(ctm.turnover_rate) OVER (PARTITION BY c.cat_id ORDER BY ctm.year, ctm.month) as prev_month_turnover
FROM categories c
JOIN category_time_metrics ctm ON c.cat_id = ctm.category_id
)
SELECT SELECT
c.cat_id as category_id, *,
c.name,
c.description,
p.name as parent_name,
c.status,
cm.product_count,
cm.active_products,
cm.total_value,
cm.avg_margin,
cm.turnover_rate,
cm.growth_rate,
CASE CASE
WHEN cm.growth_rate >= 20 THEN 'High Growth' WHEN prev_month_revenue IS NULL THEN 0
WHEN cm.growth_rate >= 5 THEN 'Growing' ELSE ((total_revenue - prev_month_revenue) / prev_month_revenue) * 100
WHEN cm.growth_rate >= -5 THEN 'Stable' END as revenue_growth_percent,
ELSE 'Declining' CASE
END as performance_rating WHEN prev_month_turnover IS NULL THEN 0
FROM ELSE ((turnover_rate - prev_month_turnover) / prev_month_turnover) * 100
categories c END as turnover_growth_percent
LEFT JOIN FROM monthly_trends;
categories p ON c.parent_id = p.cat_id
LEFT JOIN
category_metrics cm ON c.cat_id = cm.category_id;
-- Re-enable foreign key checks SET session_replication_role = 'origin';
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -1,6 +1,18 @@
-- Enable strict error reporting -- Enable strict error reporting
SET sql_mode = 'STRICT_ALL_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ZERO_DATE,NO_ZERO_IN_DATE,NO_ENGINE_SUBSTITUTION'; SET session_replication_role = 'replica'; -- Disable foreign key checks temporarily
SET FOREIGN_KEY_CHECKS = 0;
-- Create function for updating timestamps
CREATE OR REPLACE FUNCTION update_updated_column() RETURNS TRIGGER AS $func$
BEGIN
-- Check which table is being updated and use the appropriate column
IF TG_TABLE_NAME = 'categories' THEN
NEW.updated_at = CURRENT_TIMESTAMP;
ELSE
NEW.updated = CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$func$ language plpgsql;
-- Create tables -- Create tables
CREATE TABLE products ( CREATE TABLE products (
@@ -8,18 +20,18 @@ CREATE TABLE products (
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
SKU VARCHAR(50) NOT NULL, SKU VARCHAR(50) NOT NULL,
created_at TIMESTAMP NULL, created_at TIMESTAMP WITH TIME ZONE,
first_received TIMESTAMP NULL, first_received TIMESTAMP WITH TIME ZONE,
stock_quantity INT DEFAULT 0, stock_quantity INTEGER DEFAULT 0,
preorder_count INT DEFAULT 0, preorder_count INTEGER DEFAULT 0,
notions_inv_count INT DEFAULT 0, notions_inv_count INTEGER DEFAULT 0,
price DECIMAL(10, 3) NOT NULL, price DECIMAL(10, 3) NOT NULL,
regular_price DECIMAL(10, 3) NOT NULL, regular_price DECIMAL(10, 3) NOT NULL,
cost_price DECIMAL(10, 3), cost_price DECIMAL(10, 3),
landing_cost_price DECIMAL(10, 3), landing_cost_price DECIMAL(10, 3),
barcode VARCHAR(50), barcode VARCHAR(50),
harmonized_tariff_code VARCHAR(20), harmonized_tariff_code VARCHAR(20),
updated_at TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE,
visible BOOLEAN DEFAULT true, visible BOOLEAN DEFAULT true,
managing_stock BOOLEAN DEFAULT true, managing_stock BOOLEAN DEFAULT true,
replenishable BOOLEAN DEFAULT true, replenishable BOOLEAN DEFAULT true,
@@ -37,47 +49,64 @@ CREATE TABLE products (
artist VARCHAR(100), artist VARCHAR(100),
options TEXT, options TEXT,
tags TEXT, tags TEXT,
moq INT DEFAULT 1, moq INTEGER DEFAULT 1,
uom INT DEFAULT 1, uom INTEGER DEFAULT 1,
rating DECIMAL(10,2) DEFAULT 0.00, rating DECIMAL(10,2) DEFAULT 0.00,
reviews INT UNSIGNED DEFAULT 0, reviews INTEGER DEFAULT 0,
weight DECIMAL(10,3), weight DECIMAL(10,3),
length DECIMAL(10,3), length DECIMAL(10,3),
width DECIMAL(10,3), width DECIMAL(10,3),
height DECIMAL(10,3), height DECIMAL(10,3),
country_of_origin VARCHAR(5), country_of_origin VARCHAR(5),
location VARCHAR(50), location VARCHAR(50),
total_sold INT UNSIGNED DEFAULT 0, total_sold INTEGER DEFAULT 0,
baskets INT UNSIGNED DEFAULT 0, baskets INTEGER DEFAULT 0,
notifies INT UNSIGNED DEFAULT 0, notifies INTEGER DEFAULT 0,
date_last_sold DATE, date_last_sold DATE,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (pid), PRIMARY KEY (pid)
INDEX idx_sku (SKU), );
INDEX idx_vendor (vendor),
INDEX idx_brand (brand), -- Create trigger for products
INDEX idx_location (location), CREATE TRIGGER update_products_updated
INDEX idx_total_sold (total_sold), BEFORE UPDATE ON products
INDEX idx_date_last_sold (date_last_sold), FOR EACH ROW
INDEX idx_updated (updated) EXECUTE FUNCTION update_updated_column();
) ENGINE=InnoDB;
-- Create indexes for products table
CREATE INDEX idx_products_sku ON products(SKU);
CREATE INDEX idx_products_vendor ON products(vendor);
CREATE INDEX idx_products_brand ON products(brand);
CREATE INDEX idx_products_location ON products(location);
CREATE INDEX idx_products_total_sold ON products(total_sold);
CREATE INDEX idx_products_date_last_sold ON products(date_last_sold);
CREATE INDEX idx_products_updated ON products(updated);
-- Create categories table with hierarchy support -- Create categories table with hierarchy support
CREATE TABLE categories ( CREATE TABLE categories (
cat_id BIGINT PRIMARY KEY, cat_id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
type SMALLINT NOT NULL COMMENT '10=section, 11=category, 12=subcategory, 13=subsubcategory, 1=company, 2=line, 3=subline, 40=artist', type SMALLINT NOT NULL,
parent_id BIGINT, parent_id BIGINT,
description TEXT, description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'active', status VARCHAR(20) DEFAULT 'active',
FOREIGN KEY (parent_id) REFERENCES categories(cat_id), FOREIGN KEY (parent_id) REFERENCES categories(cat_id)
INDEX idx_parent (parent_id), );
INDEX idx_type (type),
INDEX idx_status (status), -- Create trigger for categories
INDEX idx_name_type (name, type) CREATE TRIGGER update_categories_updated_at
) ENGINE=InnoDB; BEFORE UPDATE ON categories
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN categories.type IS '10=section, 11=category, 12=subcategory, 13=subsubcategory, 1=company, 2=line, 3=subline, 40=artist';
CREATE INDEX idx_categories_parent ON categories(parent_id);
CREATE INDEX idx_categories_type ON categories(type);
CREATE INDEX idx_categories_status ON categories(status);
CREATE INDEX idx_categories_name_type ON categories(name, type);
-- Create product_categories junction table -- Create product_categories junction table
CREATE TABLE product_categories ( CREATE TABLE product_categories (
@@ -85,78 +114,98 @@ CREATE TABLE product_categories (
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
PRIMARY KEY (pid, cat_id), PRIMARY KEY (pid, cat_id),
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
FOREIGN KEY (cat_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (cat_id) REFERENCES categories(cat_id) ON DELETE CASCADE
INDEX idx_category (cat_id), );
INDEX idx_product (pid)
) ENGINE=InnoDB; CREATE INDEX idx_product_categories_category ON product_categories(cat_id);
CREATE INDEX idx_product_categories_product ON product_categories(pid);
-- Create orders table with its indexes -- Create orders table with its indexes
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGSERIAL PRIMARY KEY,
order_number VARCHAR(50) NOT NULL, order_number VARCHAR(50) NOT NULL,
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
SKU VARCHAR(50) NOT NULL, SKU VARCHAR(50) NOT NULL,
date DATE NOT NULL, date DATE NOT NULL,
price DECIMAL(10,3) NOT NULL, price DECIMAL(10,3) NOT NULL,
quantity INT NOT NULL, quantity INTEGER NOT NULL,
discount DECIMAL(10,3) DEFAULT 0.000, discount DECIMAL(10,3) DEFAULT 0.000,
tax DECIMAL(10,3) DEFAULT 0.000, tax DECIMAL(10,3) DEFAULT 0.000,
tax_included TINYINT(1) DEFAULT 0, tax_included BOOLEAN DEFAULT false,
shipping DECIMAL(10,3) DEFAULT 0.000, shipping DECIMAL(10,3) DEFAULT 0.000,
costeach DECIMAL(10,3) DEFAULT 0.000, costeach DECIMAL(10,3) DEFAULT 0.000,
customer VARCHAR(50) NOT NULL, customer VARCHAR(50) NOT NULL,
customer_name VARCHAR(100), customer_name VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
canceled TINYINT(1) DEFAULT 0, canceled BOOLEAN DEFAULT false,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE (order_number, pid)
UNIQUE KEY unique_order_line (order_number, pid), );
KEY order_number (order_number),
KEY pid (pid), -- Create trigger for orders
KEY customer (customer), CREATE TRIGGER update_orders_updated
KEY date (date), BEFORE UPDATE ON orders
KEY status (status), FOR EACH ROW
INDEX idx_orders_metrics (pid, date, canceled), EXECUTE FUNCTION update_updated_column();
INDEX idx_updated (updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE INDEX idx_orders_number ON orders(order_number);
CREATE INDEX idx_orders_pid ON orders(pid);
CREATE INDEX idx_orders_customer ON orders(customer);
CREATE INDEX idx_orders_date ON orders(date);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_metrics ON orders(pid, date, canceled);
CREATE INDEX idx_orders_updated ON orders(updated);
-- Create purchase_orders table with its indexes -- Create purchase_orders table with its indexes
CREATE TABLE purchase_orders ( CREATE TABLE purchase_orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
po_id VARCHAR(50) NOT NULL, po_id VARCHAR(50) NOT NULL,
vendor VARCHAR(100) NOT NULL, vendor VARCHAR(100) NOT NULL,
date DATE NOT NULL, date DATE NOT NULL,
expected_date DATE, expected_date DATE,
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
sku VARCHAR(50) NOT NULL, sku VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL COMMENT 'Product name from products.description', name VARCHAR(255) NOT NULL,
cost_price DECIMAL(10, 3) NOT NULL, cost_price DECIMAL(10, 3) NOT NULL,
po_cost_price DECIMAL(10, 3) NOT NULL COMMENT 'Original cost from PO, before receiving adjustments', po_cost_price DECIMAL(10, 3) NOT NULL,
status TINYINT UNSIGNED DEFAULT 1 COMMENT '0=canceled,1=created,10=electronically_ready_send,11=ordered,12=preordered,13=electronically_sent,15=receiving_started,50=done', status SMALLINT DEFAULT 1,
receiving_status TINYINT UNSIGNED DEFAULT 1 COMMENT '0=canceled,1=created,30=partial_received,40=full_received,50=paid', receiving_status SMALLINT DEFAULT 1,
notes TEXT, notes TEXT,
long_note TEXT, long_note TEXT,
ordered INT NOT NULL, ordered INTEGER NOT NULL,
received INT DEFAULT 0, received INTEGER DEFAULT 0,
received_date DATE COMMENT 'Date of first receiving', received_date DATE,
last_received_date DATE COMMENT 'Date of most recent receiving', last_received_date DATE,
received_by VARCHAR(100) COMMENT 'Name of person who first received this PO line', received_by VARCHAR,
receiving_history JSON COMMENT 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag', receiving_history JSONB,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid), FOREIGN KEY (pid) REFERENCES products(pid),
INDEX idx_po_id (po_id), UNIQUE (po_id, pid)
INDEX idx_vendor (vendor), );
INDEX idx_status (status),
INDEX idx_receiving_status (receiving_status),
INDEX idx_purchase_orders_metrics (pid, date, status, ordered, received),
INDEX idx_po_metrics (pid, date, receiving_status, received_date),
INDEX idx_po_product_date (pid, date),
INDEX idx_po_product_status (pid, status),
INDEX idx_updated (updated),
UNIQUE KEY unique_po_product (po_id, pid)
) ENGINE=InnoDB;
SET FOREIGN_KEY_CHECKS = 1; -- Create trigger for purchase_orders
CREATE TRIGGER update_purchase_orders_updated
BEFORE UPDATE ON purchase_orders
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description';
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO, before receiving adjustments';
COMMENT ON COLUMN purchase_orders.status IS '0=canceled,1=created,10=electronically_ready_send,11=ordered,12=preordered,13=electronically_sent,15=receiving_started,50=done';
COMMENT ON COLUMN purchase_orders.receiving_status IS '0=canceled,1=created,30=partial_received,40=full_received,50=paid';
COMMENT ON COLUMN purchase_orders.receiving_history IS 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag';
CREATE INDEX idx_po_id ON purchase_orders(po_id);
CREATE INDEX idx_po_vendor ON purchase_orders(vendor);
CREATE INDEX idx_po_status ON purchase_orders(status);
CREATE INDEX idx_po_receiving_status ON purchase_orders(receiving_status);
CREATE INDEX idx_po_metrics ON purchase_orders(pid, date, status, ordered, received);
CREATE INDEX idx_po_metrics_receiving ON purchase_orders(pid, date, receiving_status, received_date);
CREATE INDEX idx_po_product_date ON purchase_orders(pid, date);
CREATE INDEX idx_po_product_status ON purchase_orders(pid, status);
CREATE INDEX idx_po_updated ON purchase_orders(updated);
SET session_replication_role = 'origin'; -- Re-enable foreign key checks
-- Create views for common calculations -- Create views for common calculations
-- product_sales_trends view moved to metrics-schema.sql -- product_sales_trends view moved to metrics-schema.sql

View File

@@ -0,0 +1,115 @@
-- Templates table for storing import templates
CREATE TABLE IF NOT EXISTS templates (
id SERIAL PRIMARY KEY,
company TEXT NOT NULL,
product_type TEXT NOT NULL,
supplier TEXT,
msrp DECIMAL(10,2),
cost_each DECIMAL(10,2),
qty_per_unit INTEGER,
case_qty INTEGER,
hts_code TEXT,
description TEXT,
weight DECIMAL(10,2),
length DECIMAL(10,2),
width DECIMAL(10,2),
height DECIMAL(10,2),
tax_cat TEXT,
size_cat TEXT,
categories TEXT[],
ship_restrictions TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
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';
-- Reusable Images table for storing persistent images
CREATE TABLE IF NOT EXISTS reusable_images (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
image_url TEXT NOT NULL,
is_global BOOLEAN NOT NULL DEFAULT false,
company TEXT,
mime_type TEXT,
file_size INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT company_required_for_non_global CHECK (
(is_global = true AND company IS NULL) OR
(is_global = false AND company IS NOT NULL)
)
);
-- Create index on company for efficient querying
CREATE INDEX IF NOT EXISTS idx_reusable_images_company ON reusable_images(company);
-- Create index on is_global for efficient querying
CREATE INDEX IF NOT EXISTS idx_reusable_images_is_global ON reusable_images(is_global);
-- AI Validation Performance Tracking
CREATE TABLE IF NOT EXISTS ai_validation_performance (
id SERIAL PRIMARY KEY,
prompt_length INTEGER NOT NULL,
product_count INTEGER NOT NULL,
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
duration_seconds DECIMAL(10,2) GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (end_time - start_time))) STORED,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create index on prompt_length for efficient querying
CREATE INDEX IF NOT EXISTS idx_ai_validation_prompt_length ON ai_validation_performance(prompt_length);
-- 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';
-- Trigger to automatically update the updated_at column
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();
-- Trigger to automatically update the updated_at column for reusable_images
CREATE TRIGGER update_reusable_images_updated_at
BEFORE UPDATE ON reusable_images
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

File diff suppressed because it is too large Load Diff

View File

@@ -18,12 +18,18 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@types/diff": "^7.0.1",
"axios": "^1.8.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"diff": "^7.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"openai": "^4.85.3",
"pg": "^8.13.3",
"pm2": "^5.3.0", "pm2": "^5.3.0",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"uuid": "^9.0.1" "uuid": "^9.0.1"

View File

@@ -14,7 +14,15 @@ function outputProgress(data) {
function runScript(scriptPath) { function runScript(scriptPath) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn('node', [scriptPath], { const child = spawn('node', [scriptPath], {
stdio: ['inherit', 'pipe', 'pipe'] stdio: ['inherit', 'pipe', 'pipe'],
env: {
...process.env,
PGHOST: process.env.DB_HOST,
PGUSER: process.env.DB_USER,
PGPASSWORD: process.env.DB_PASSWORD,
PGDATABASE: process.env.DB_NAME,
PGPORT: process.env.DB_PORT || '5432'
}
}); });
let output = ''; let output = '';

View File

@@ -10,16 +10,15 @@ const importPurchaseOrders = require('./import/purchase-orders');
dotenv.config({ path: path.join(__dirname, "../.env") }); dotenv.config({ path: path.join(__dirname, "../.env") });
// Constants to control which imports run // Constants to control which imports run
const IMPORT_CATEGORIES = true; const IMPORT_CATEGORIES = false;
const IMPORT_PRODUCTS = true; const IMPORT_PRODUCTS = false;
const IMPORT_ORDERS = true; const IMPORT_ORDERS = false;
const IMPORT_PURCHASE_ORDERS = true; const IMPORT_PURCHASE_ORDERS = true;
// Add flag for incremental updates // Add flag for incremental updates
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
// SSH configuration // SSH configuration
// In import-from-prod.js
const sshConfig = { const sshConfig = {
ssh: { ssh: {
host: process.env.PROD_SSH_HOST, host: process.env.PROD_SSH_HOST,
@@ -31,6 +30,7 @@ const sshConfig = {
compress: true, // Enable SSH compression compress: true, // Enable SSH compression
}, },
prodDbConfig: { prodDbConfig: {
// MySQL config for production
host: process.env.PROD_DB_HOST || "localhost", host: process.env.PROD_DB_HOST || "localhost",
user: process.env.PROD_DB_USER, user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD, password: process.env.PROD_DB_PASSWORD,
@@ -39,21 +39,16 @@ const sshConfig = {
timezone: 'Z', timezone: 'Z',
}, },
localDbConfig: { localDbConfig: {
// PostgreSQL config for local
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
multipleStatements: true, port: process.env.DB_PORT || 5432,
waitForConnections: true, ssl: process.env.DB_SSL === 'true',
connectionLimit: 10, connectionTimeoutMillis: 60000,
queueLimit: 0, idleTimeoutMillis: 30000,
namedPlaceholders: true, max: 10 // connection pool max size
connectTimeout: 60000,
enableKeepAlive: true,
keepAliveInitialDelay: 10000,
compress: true,
timezone: 'Z',
stringifyObjects: false,
} }
}; };
@@ -108,7 +103,7 @@ async function main() {
SET SET
status = 'cancelled', status = 'cancelled',
end_time = NOW(), end_time = NOW(),
duration_seconds = TIMESTAMPDIFF(SECOND, start_time, NOW()), duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
error_message = 'Previous import was not completed properly' error_message = 'Previous import was not completed properly'
WHERE status = 'running' WHERE status = 'running'
`); `);
@@ -118,33 +113,45 @@ async function main() {
CREATE TABLE IF NOT EXISTS sync_status ( CREATE TABLE IF NOT EXISTS sync_status (
table_name VARCHAR(50) PRIMARY KEY, table_name VARCHAR(50) PRIMARY KEY,
last_sync_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_sync_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_sync_id BIGINT, last_sync_id BIGINT
INDEX idx_last_sync (last_sync_timestamp)
); );
CREATE INDEX IF NOT EXISTS idx_last_sync ON sync_status (last_sync_timestamp);
`); `);
// Create import history record for the overall session // Create import history record for the overall session
const [historyResult] = await localConnection.query(` try {
INSERT INTO import_history ( const [historyResult] = await localConnection.query(`
table_name, INSERT INTO import_history (
start_time, table_name,
is_incremental, start_time,
status, is_incremental,
additional_info status,
) VALUES ( additional_info
'all_tables', ) VALUES (
NOW(), 'all_tables',
?, NOW(),
'running', $1::boolean,
JSON_OBJECT( 'running',
'categories_enabled', ?, jsonb_build_object(
'products_enabled', ?, 'categories_enabled', $2::boolean,
'orders_enabled', ?, 'products_enabled', $3::boolean,
'purchase_orders_enabled', ? 'orders_enabled', $4::boolean,
) 'purchase_orders_enabled', $5::boolean
) )
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]); ) RETURNING id
importHistoryId = historyResult.insertId; `, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
importHistoryId = historyResult.rows[0].id;
} catch (error) {
console.error("Error creating import history record:", error);
outputProgress({
status: "error",
operation: "Import process",
message: "Failed to create import history record",
error: error.message
});
throw error;
}
const results = { const results = {
categories: null, categories: null,
@@ -162,8 +169,8 @@ async function main() {
if (isImportCancelled) throw new Error("Import cancelled"); if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++; completedSteps++;
console.log('Categories import result:', results.categories); console.log('Categories import result:', results.categories);
totalRecordsAdded += results.categories?.recordsAdded || 0; totalRecordsAdded += parseInt(results.categories?.recordsAdded || 0) || 0;
totalRecordsUpdated += results.categories?.recordsUpdated || 0; totalRecordsUpdated += parseInt(results.categories?.recordsUpdated || 0) || 0;
} }
if (IMPORT_PRODUCTS) { if (IMPORT_PRODUCTS) {
@@ -171,8 +178,8 @@ async function main() {
if (isImportCancelled) throw new Error("Import cancelled"); if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++; completedSteps++;
console.log('Products import result:', results.products); console.log('Products import result:', results.products);
totalRecordsAdded += results.products?.recordsAdded || 0; totalRecordsAdded += parseInt(results.products?.recordsAdded || 0) || 0;
totalRecordsUpdated += results.products?.recordsUpdated || 0; totalRecordsUpdated += parseInt(results.products?.recordsUpdated || 0) || 0;
} }
if (IMPORT_ORDERS) { if (IMPORT_ORDERS) {
@@ -180,17 +187,34 @@ async function main() {
if (isImportCancelled) throw new Error("Import cancelled"); if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++; completedSteps++;
console.log('Orders import result:', results.orders); console.log('Orders import result:', results.orders);
totalRecordsAdded += results.orders?.recordsAdded || 0; totalRecordsAdded += parseInt(results.orders?.recordsAdded || 0) || 0;
totalRecordsUpdated += results.orders?.recordsUpdated || 0; totalRecordsUpdated += parseInt(results.orders?.recordsUpdated || 0) || 0;
} }
if (IMPORT_PURCHASE_ORDERS) { if (IMPORT_PURCHASE_ORDERS) {
results.purchaseOrders = await importPurchaseOrders(prodConnection, localConnection, INCREMENTAL_UPDATE); try {
if (isImportCancelled) throw new Error("Import cancelled"); results.purchaseOrders = await importPurchaseOrders(prodConnection, localConnection, INCREMENTAL_UPDATE);
completedSteps++; if (isImportCancelled) throw new Error("Import cancelled");
console.log('Purchase orders import result:', results.purchaseOrders); completedSteps++;
totalRecordsAdded += results.purchaseOrders?.recordsAdded || 0; console.log('Purchase orders import result:', results.purchaseOrders);
totalRecordsUpdated += results.purchaseOrders?.recordsUpdated || 0;
// Handle potential error status
if (results.purchaseOrders?.status === 'error') {
console.error('Purchase orders import had an error:', results.purchaseOrders.error);
} else {
totalRecordsAdded += parseInt(results.purchaseOrders?.recordsAdded || 0) || 0;
totalRecordsUpdated += parseInt(results.purchaseOrders?.recordsUpdated || 0) || 0;
}
} catch (error) {
console.error('Error during purchase orders import:', error);
// Continue with other imports, don't fail the whole process
results.purchaseOrders = {
status: 'error',
error: error.message,
recordsAdded: 0,
recordsUpdated: 0
};
}
} }
const endTime = Date.now(); const endTime = Date.now();
@@ -201,25 +225,25 @@ async function main() {
UPDATE import_history UPDATE import_history
SET SET
end_time = NOW(), end_time = NOW(),
duration_seconds = ?, duration_seconds = $1,
records_added = ?, records_added = $2,
records_updated = ?, records_updated = $3,
status = 'completed', status = 'completed',
additional_info = JSON_OBJECT( additional_info = jsonb_build_object(
'categories_enabled', ?, 'categories_enabled', $4::boolean,
'products_enabled', ?, 'products_enabled', $5::boolean,
'orders_enabled', ?, 'orders_enabled', $6::boolean,
'purchase_orders_enabled', ?, 'purchase_orders_enabled', $7::boolean,
'categories_result', CAST(? AS JSON), 'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
'products_result', CAST(? AS JSON), 'products_result', COALESCE($9::jsonb, 'null'::jsonb),
'orders_result', CAST(? AS JSON), 'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
'purchase_orders_result', CAST(? AS JSON) 'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb)
) )
WHERE id = ? WHERE id = $12
`, [ `, [
totalElapsedSeconds, totalElapsedSeconds,
totalRecordsAdded, parseInt(totalRecordsAdded) || 0,
totalRecordsUpdated, parseInt(totalRecordsUpdated) || 0,
IMPORT_CATEGORIES, IMPORT_CATEGORIES,
IMPORT_PRODUCTS, IMPORT_PRODUCTS,
IMPORT_ORDERS, IMPORT_ORDERS,
@@ -259,10 +283,10 @@ async function main() {
UPDATE import_history UPDATE import_history
SET SET
end_time = NOW(), end_time = NOW(),
duration_seconds = ?, duration_seconds = $1,
status = ?, status = $2,
error_message = ? error_message = $3
WHERE id = ? WHERE id = $4
`, [totalElapsedSeconds, error.message === "Import cancelled" ? 'cancelled' : 'failed', error.message, importHistoryId]); `, [totalElapsedSeconds, error.message === "Import cancelled" ? 'cancelled' : 'failed', error.message, importHistoryId]);
} }
@@ -288,16 +312,23 @@ async function main() {
throw error; throw error;
} finally { } finally {
if (connections) { if (connections) {
await closeConnections(connections); await closeConnections(connections).catch(err => {
console.error("Error closing connections:", err);
});
} }
} }
} }
// Run the import only if this is the main module // Run the import only if this is the main module
if (require.main === module) { if (require.main === module) {
main().catch((error) => { main().then((results) => {
console.log('Import completed successfully:', results);
// Force exit after a small delay to ensure all logs are written
setTimeout(() => process.exit(0), 500);
}).catch((error) => {
console.error("Unhandled error in main process:", error); console.error("Unhandled error in main process:", error);
process.exit(1); // Force exit with error code after a small delay
setTimeout(() => process.exit(1), 500);
}); });
} }

View File

@@ -9,170 +9,183 @@ async function importCategories(prodConnection, localConnection) {
const startTime = Date.now(); const startTime = Date.now();
const typeOrder = [10, 20, 11, 21, 12, 13]; const typeOrder = [10, 20, 11, 21, 12, 13];
let totalInserted = 0; let totalInserted = 0;
let totalUpdated = 0;
let skippedCategories = []; let skippedCategories = [];
try { try {
// Process each type in order with its own query // Start a single transaction for the entire import
await localConnection.query('BEGIN');
// Process each type in order with its own savepoint
for (const type of typeOrder) { for (const type of typeOrder) {
const [categories] = await prodConnection.query( try {
` // Create a savepoint for this type
SELECT await localConnection.query(`SAVEPOINT category_type_${type}`);
pc.cat_id,
pc.name,
pc.type,
CASE
WHEN pc.type IN (10, 20) THEN NULL -- Top level categories should have no parent
WHEN pc.master_cat_id IS NULL THEN NULL
ELSE pc.master_cat_id
END as parent_id,
pc.combined_name as description
FROM product_categories pc
WHERE pc.type = ?
ORDER BY pc.cat_id
`,
[type]
);
if (categories.length === 0) continue; // Production query remains MySQL compatible
const [categories] = await prodConnection.query(
console.log(`\nProcessing ${categories.length} type ${type} categories`); `
if (type === 10) { SELECT
console.log("Type 10 categories:", JSON.stringify(categories, null, 2)); pc.cat_id,
} pc.name,
pc.type,
// For types that can have parents (11, 21, 12, 13), verify parent existence CASE
let categoriesToInsert = categories; WHEN pc.type IN (10, 20) THEN NULL -- Top level categories should have no parent
if (![10, 20].includes(type)) { WHEN pc.master_cat_id IS NULL THEN NULL
// Get all parent IDs ELSE pc.master_cat_id
const parentIds = [ END as parent_id,
...new Set( pc.combined_name as description
categories.map((c) => c.parent_id).filter((id) => id !== null) FROM product_categories pc
), WHERE pc.type = ?
]; ORDER BY pc.cat_id
`,
// Check which parents exist [type]
const [existingParents] = await localConnection.query(
"SELECT cat_id FROM categories WHERE cat_id IN (?)",
[parentIds]
);
const existingParentIds = new Set(existingParents.map((p) => p.cat_id));
// Filter categories and track skipped ones
categoriesToInsert = categories.filter(
(cat) =>
cat.parent_id === null || existingParentIds.has(cat.parent_id)
);
const invalidCategories = categories.filter(
(cat) =>
cat.parent_id !== null && !existingParentIds.has(cat.parent_id)
); );
if (invalidCategories.length > 0) { if (categories.length === 0) {
const skippedInfo = invalidCategories.map((c) => ({ await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`);
id: c.cat_id,
name: c.name,
type: c.type,
missing_parent: c.parent_id,
}));
skippedCategories.push(...skippedInfo);
console.log(
"\nSkipping categories with missing parents:",
invalidCategories
.map(
(c) =>
`${c.cat_id} - ${c.name} (missing parent: ${c.parent_id})`
)
.join("\n")
);
}
if (categoriesToInsert.length === 0) {
console.log(
`No valid categories of type ${type} to insert - all had missing parents`
);
continue; continue;
} }
console.log(`Processing ${categories.length} type ${type} categories`);
// For types that can have parents (11, 21, 12, 13), we'll proceed directly
// No need to check for parent existence since we process in hierarchical order
let categoriesToInsert = categories;
if (categoriesToInsert.length === 0) {
console.log(`No valid categories of type ${type} to insert`);
await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`);
continue;
}
// PostgreSQL upsert query with parameterized values
const values = categoriesToInsert.flatMap((cat) => [
cat.cat_id,
cat.name,
cat.type,
cat.parent_id,
cat.description,
'active',
new Date(),
new Date()
]);
const placeholders = categoriesToInsert
.map((_, i) => `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})`)
.join(',');
// Insert categories with ON CONFLICT clause for PostgreSQL
const query = `
WITH inserted_categories AS (
INSERT INTO categories (
cat_id, name, type, parent_id, description, status, created_at, updated_at
)
VALUES ${placeholders}
ON CONFLICT (cat_id) DO UPDATE SET
name = EXCLUDED.name,
type = EXCLUDED.type,
parent_id = EXCLUDED.parent_id,
description = EXCLUDED.description,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
RETURNING
cat_id,
CASE
WHEN xmax = 0 THEN true
ELSE false
END as is_insert
)
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_insert) as inserted,
COUNT(*) FILTER (WHERE NOT is_insert) as updated
FROM inserted_categories`;
const result = await localConnection.query(query, values);
// Get the first result since query returns an array
const queryResult = Array.isArray(result) ? result[0] : result;
if (!queryResult || !queryResult.rows || !queryResult.rows[0]) {
console.error('Query failed to return results');
throw new Error('Query did not return expected results');
}
const total = parseInt(queryResult.rows[0].total) || 0;
const inserted = parseInt(queryResult.rows[0].inserted) || 0;
const updated = parseInt(queryResult.rows[0].updated) || 0;
console.log(`Total: ${total}, Inserted: ${inserted}, Updated: ${updated}`);
totalInserted += inserted;
totalUpdated += updated;
// Release the savepoint for this type
await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`);
outputProgress({
status: "running",
operation: "Categories import",
message: `Imported ${inserted} (updated ${updated}) categories of type ${type}`,
current: totalInserted + totalUpdated,
total: categories.length,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
});
} catch (error) {
// Rollback to the savepoint for this type
await localConnection.query(`ROLLBACK TO SAVEPOINT category_type_${type}`);
throw error;
} }
console.log(
`Inserting ${categoriesToInsert.length} type ${type} categories`
);
const placeholders = categoriesToInsert
.map(() => "(?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
.join(",");
const values = categoriesToInsert.flatMap((cat) => [
cat.cat_id,
cat.name,
cat.type,
cat.parent_id,
cat.description,
"active",
]);
// Insert categories and create relationships in one query to avoid race conditions
await localConnection.query(
`
INSERT INTO categories (cat_id, name, type, parent_id, description, status, created_at, updated_at)
VALUES ${placeholders}
ON DUPLICATE KEY UPDATE
name = VALUES(name),
type = VALUES(type),
parent_id = VALUES(parent_id),
description = VALUES(description),
status = VALUES(status),
updated_at = CURRENT_TIMESTAMP
`,
values
);
totalInserted += categoriesToInsert.length;
outputProgress({
status: "running",
operation: "Categories import",
current: totalInserted,
total: totalInserted,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
});
} }
// After all imports, if we skipped any categories, throw an error // Commit the entire transaction - we'll do this even if we have skipped categories
if (skippedCategories.length > 0) { await localConnection.query('COMMIT');
const error = new Error(
"Categories import completed with errors - some categories were skipped due to missing parents" // Update sync status
); await localConnection.query(`
error.skippedCategories = skippedCategories; INSERT INTO sync_status (table_name, last_sync_timestamp)
throw error; VALUES ('categories', NOW())
} ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`);
outputProgress({ outputProgress({
status: "complete", status: "complete",
operation: "Categories import completed", operation: "Categories import completed",
current: totalInserted, current: totalInserted + totalUpdated,
total: totalInserted, total: totalInserted + totalUpdated,
duration: formatElapsedTime((Date.now() - startTime) / 1000), duration: formatElapsedTime((Date.now() - startTime) / 1000),
warnings: skippedCategories.length > 0 ? {
message: "Some categories were skipped due to missing parents",
skippedCategories
} : undefined
}); });
return { return {
status: "complete", status: "complete",
totalImported: totalInserted recordsAdded: totalInserted,
recordsUpdated: totalUpdated,
totalRecords: totalInserted + totalUpdated,
warnings: skippedCategories.length > 0 ? {
message: "Some categories were skipped due to missing parents",
skippedCategories
} : undefined
}; };
} catch (error) { } catch (error) {
console.error("Error importing categories:", error); console.error("Error importing categories:", error);
if (error.skippedCategories) {
console.error( // Only rollback if we haven't committed yet
"Skipped categories:", try {
JSON.stringify(error.skippedCategories, null, 2) await localConnection.query('ROLLBACK');
); } catch (rollbackError) {
console.error("Error during rollback:", rollbackError);
} }
outputProgress({ outputProgress({
status: "error", status: "error",
operation: "Categories import failed", operation: "Categories import failed",
error: error.message, error: error.message
skippedCategories: error.skippedCategories
}); });
throw error; throw error;

View File

@@ -2,7 +2,7 @@ const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } =
const { importMissingProducts, setupTemporaryTables, cleanupTemporaryTables, materializeCalculations } = require('./products'); const { importMissingProducts, setupTemporaryTables, cleanupTemporaryTables, materializeCalculations } = require('./products');
/** /**
* Imports orders from a production MySQL database to a local MySQL database. * Imports orders from a production MySQL database to a local PostgreSQL database.
* It can run in two modes: * It can run in two modes:
* 1. Incremental update mode (default): Only fetch orders that have changed since the last sync time. * 1. Incremental update mode (default): Only fetch orders that have changed since the last sync time.
* 2. Full update mode: Fetch all eligible orders within the last 5 years regardless of timestamp. * 2. Full update mode: Fetch all eligible orders within the last 5 years regardless of timestamp.
@@ -23,97 +23,24 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
let importedCount = 0; let importedCount = 0;
let totalOrderItems = 0; let totalOrderItems = 0;
let totalUniqueOrders = 0; let totalUniqueOrders = 0;
// Add a cumulative counter for processed orders before the loop
let cumulativeProcessedOrders = 0; let cumulativeProcessedOrders = 0;
try { try {
// Clean up any existing temp tables first // Begin transaction
await localConnection.query(` await localConnection.beginTransaction();
DROP TEMPORARY TABLE IF EXISTS temp_order_items;
DROP TEMPORARY TABLE IF EXISTS temp_order_meta;
DROP TEMPORARY TABLE IF EXISTS temp_order_discounts;
DROP TEMPORARY TABLE IF EXISTS temp_order_taxes;
DROP TEMPORARY TABLE IF EXISTS temp_order_costs;
`);
// Create all temp tables with correct schema
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_items (
order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL,
SKU VARCHAR(50) NOT NULL,
price DECIMAL(10,2) NOT NULL,
quantity INT NOT NULL,
base_discount DECIMAL(10,2) DEFAULT 0,
PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_meta (
order_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
customer VARCHAR(100) NOT NULL,
customer_name VARCHAR(150) NOT NULL,
status INT,
canceled TINYINT(1),
summary_discount DECIMAL(10,2) DEFAULT 0.00,
summary_subtotal DECIMAL(10,2) DEFAULT 0.00,
PRIMARY KEY (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_discounts (
order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL,
discount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_taxes (
order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL,
tax DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_costs (
order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL,
costeach DECIMAL(10,3) DEFAULT 0.000,
PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
// Get column names from the local table
const [columns] = await localConnection.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'orders'
AND COLUMN_NAME != 'updated' -- Exclude the updated column
ORDER BY ORDINAL_POSITION
`);
const columnNames = columns.map(col => col.COLUMN_NAME);
// Get last sync info // Get last sync info
const [syncInfo] = await localConnection.query( const [syncInfo] = await localConnection.query(
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'" "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
); );
const lastSyncTime = syncInfo?.[0]?.last_sync_timestamp || '1970-01-01'; const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
console.log('Orders: Using last sync time:', lastSyncTime); console.log('Orders: Using last sync time:', lastSyncTime);
// First get count of order items // First get count of order items - Keep MySQL compatible for production
const [[{ total }]] = await prodConnection.query(` const [[{ total }]] = await prodConnection.query(`
SELECT COUNT(*) as total SELECT COUNT(*) as total
FROM order_items oi FROM order_items oi
USE INDEX (PRIMARY)
JOIN _order o ON oi.order_id = o.order_id JOIN _order o ON oi.order_id = o.order_id
WHERE o.order_status >= 15 WHERE o.order_status >= 15
AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR) AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
@@ -141,18 +68,18 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
totalOrderItems = total; totalOrderItems = total;
console.log('Orders: Found changes:', totalOrderItems); console.log('Orders: Found changes:', totalOrderItems);
// Get order items in batches // Get order items - Keep MySQL compatible for production
console.log('Orders: Starting MySQL query...');
const [orderItems] = await prodConnection.query(` const [orderItems] = await prodConnection.query(`
SELECT SELECT
oi.order_id, oi.order_id,
oi.prod_pid as pid, oi.prod_pid,
oi.prod_itemnumber as SKU, COALESCE(NULLIF(TRIM(oi.prod_itemnumber), ''), 'NO-SKU') as SKU,
oi.prod_price as price, oi.prod_price as price,
oi.qty_ordered as quantity, oi.qty_ordered as quantity,
COALESCE(oi.prod_price_reg - oi.prod_price, 0) as base_discount, COALESCE(oi.prod_price_reg - oi.prod_price, 0) as base_discount,
oi.stamp as last_modified oi.stamp as last_modified
FROM order_items oi FROM order_items oi
USE INDEX (PRIMARY)
JOIN _order o ON oi.order_id = o.order_id JOIN _order o ON oi.order_id = o.order_id
WHERE o.order_status >= 15 WHERE o.order_status >= 15
AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR) AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
@@ -177,24 +104,81 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
` : ''} ` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
console.log('Orders: Processing', orderItems.length, 'order items'); console.log('Orders: Found', orderItems.length, 'order items to process');
// Create tables in PostgreSQL for data processing
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
CREATE TEMP TABLE temp_order_items (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
SKU VARCHAR(50) NOT NULL,
price DECIMAL(10,2) NOT NULL,
quantity INTEGER NOT NULL,
base_discount DECIMAL(10,2) DEFAULT 0,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_meta (
order_id INTEGER NOT NULL,
date DATE NOT NULL,
customer VARCHAR(100) NOT NULL,
customer_name VARCHAR(150) NOT NULL,
status INTEGER,
canceled BOOLEAN,
summary_discount DECIMAL(10,2) DEFAULT 0.00,
summary_subtotal DECIMAL(10,2) DEFAULT 0.00,
PRIMARY KEY (order_id)
);
CREATE TEMP TABLE temp_order_discounts (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
discount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_taxes (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
tax DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_costs (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
costeach DECIMAL(10,3) DEFAULT 0.000,
PRIMARY KEY (order_id, pid)
);
CREATE INDEX idx_temp_order_items_pid ON temp_order_items(pid);
CREATE INDEX idx_temp_order_meta_order_id ON temp_order_meta(order_id);
`);
// Insert order items in batches // Insert order items in batches
for (let i = 0; i < orderItems.length; i += 5000) { for (let i = 0; i < orderItems.length; i += 5000) {
const batch = orderItems.slice(i, Math.min(i + 5000, orderItems.length)); const batch = orderItems.slice(i, Math.min(i + 5000, orderItems.length));
const placeholders = batch.map(() => "(?, ?, ?, ?, ?, ?)").join(","); const placeholders = batch.map((_, idx) =>
`($${idx * 6 + 1}, $${idx * 6 + 2}, $${idx * 6 + 3}, $${idx * 6 + 4}, $${idx * 6 + 5}, $${idx * 6 + 6})`
).join(",");
const values = batch.flatMap(item => [ const values = batch.flatMap(item => [
item.order_id, item.pid, item.SKU, item.price, item.quantity, item.base_discount item.order_id, item.prod_pid, item.SKU, item.price, item.quantity, item.base_discount
]); ]);
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_order_items (order_id, pid, SKU, price, quantity, base_discount) INSERT INTO temp_order_items (order_id, pid, SKU, price, quantity, base_discount)
VALUES ${placeholders} VALUES ${placeholders}
ON DUPLICATE KEY UPDATE ON CONFLICT (order_id, pid) DO UPDATE SET
SKU = VALUES(SKU), SKU = EXCLUDED.SKU,
price = VALUES(price), price = EXCLUDED.price,
quantity = VALUES(quantity), quantity = EXCLUDED.quantity,
base_discount = VALUES(base_discount) base_discount = EXCLUDED.base_discount
`, values); `, values);
processedCount = i + batch.length; processedCount = i + batch.length;
@@ -203,24 +187,34 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
operation: "Orders import", operation: "Orders import",
message: `Loading order items: ${processedCount} of ${totalOrderItems}`, message: `Loading order items: ${processedCount} of ${totalOrderItems}`,
current: processedCount, current: processedCount,
total: totalOrderItems total: totalOrderItems,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, processedCount, totalOrderItems),
rate: calculateRate(startTime, processedCount)
}); });
} }
// Get unique order IDs // Get unique order IDs
const orderIds = [...new Set(orderItems.map(item => item.order_id))]; const orderIds = [...new Set(orderItems.map(item => item.order_id))];
totalUniqueOrders = orderIds.length; totalUniqueOrders = orderIds.length;
console.log('Total unique order IDs:', totalUniqueOrders); console.log('Orders: Processing', totalUniqueOrders, 'unique orders');
// Reset processed count for order processing phase // Reset processed count for order processing phase
processedCount = 0; processedCount = 0;
// Get order metadata in batches // Process metadata, discounts, taxes, and costs in parallel
for (let i = 0; i < orderIds.length; i += 5000) { const METADATA_BATCH_SIZE = 2000;
const batchIds = orderIds.slice(i, i + 5000); const PG_BATCH_SIZE = 200;
console.log(`Processing batch ${i/5000 + 1}, size: ${batchIds.length}`);
console.log('Sample of batch IDs:', batchIds.slice(0, 5)); // Add a helper function for title case conversion
function toTitleCase(str) {
if (!str) return '';
return str.toLowerCase().split(' ').map(word => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).join(' ');
}
const processMetadataBatch = async (batchIds) => {
const [orders] = await prodConnection.query(` const [orders] = await prodConnection.query(`
SELECT SELECT
o.order_id, o.order_id,
@@ -235,64 +229,46 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
LEFT JOIN users u ON o.order_cid = u.cid LEFT JOIN users u ON o.order_cid = u.cid
WHERE o.order_id IN (?) WHERE o.order_id IN (?)
`, [batchIds]); `, [batchIds]);
console.log(`Retrieved ${orders.length} orders for ${batchIds.length} IDs`); // Process in sub-batches for PostgreSQL
const duplicates = orders.filter((order, index, self) => for (let j = 0; j < orders.length; j += PG_BATCH_SIZE) {
self.findIndex(o => o.order_id === order.order_id) !== index const subBatch = orders.slice(j, j + PG_BATCH_SIZE);
); if (subBatch.length === 0) continue;
if (duplicates.length > 0) {
console.log('Found duplicates:', duplicates); const placeholders = subBatch.map((_, idx) =>
`($${idx * 8 + 1}, $${idx * 8 + 2}, $${idx * 8 + 3}, $${idx * 8 + 4}, $${idx * 8 + 5}, $${idx * 8 + 6}, $${idx * 8 + 7}, $${idx * 8 + 8})`
).join(",");
const values = subBatch.flatMap(order => [
order.order_id,
order.date,
order.customer,
toTitleCase(order.customer_name) || '',
order.status,
order.canceled,
order.summary_discount || 0,
order.summary_subtotal || 0
]);
await localConnection.query(`
INSERT INTO temp_order_meta (
order_id, date, customer, customer_name, status, canceled,
summary_discount, summary_subtotal
)
VALUES ${placeholders}
ON CONFLICT (order_id) DO UPDATE SET
date = EXCLUDED.date,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
summary_discount = EXCLUDED.summary_discount,
summary_subtotal = EXCLUDED.summary_subtotal
`, values);
} }
};
const placeholders = orders.map(() => "(?, ?, ?, ?, ?, ?, ?, ?)").join(","); const processDiscountsBatch = async (batchIds) => {
const values = orders.flatMap(order => [
order.order_id,
order.date,
order.customer,
order.customer_name,
order.status,
order.canceled,
order.summary_discount,
order.summary_subtotal
]);
await localConnection.query(`
INSERT INTO temp_order_meta (
order_id,
date,
customer,
customer_name,
status,
canceled,
summary_discount,
summary_subtotal
) VALUES ${placeholders}
ON DUPLICATE KEY UPDATE
date = VALUES(date),
customer = VALUES(customer),
customer_name = VALUES(customer_name),
status = VALUES(status),
canceled = VALUES(canceled),
summary_discount = VALUES(summary_discount),
summary_subtotal = VALUES(summary_subtotal)
`, values);
processedCount = i + orders.length;
outputProgress({
status: "running",
operation: "Orders import",
message: `Loading order metadata: ${processedCount} of ${totalUniqueOrders}`,
current: processedCount,
total: totalUniqueOrders
});
}
// Reset processed count for final phase
processedCount = 0;
// Get promotional discounts in batches
for (let i = 0; i < orderIds.length; i += 5000) {
const batchIds = orderIds.slice(i, i + 5000);
const [discounts] = await prodConnection.query(` const [discounts] = await prodConnection.query(`
SELECT order_id, pid, SUM(amount) as discount SELECT order_id, pid, SUM(amount) as discount
FROM order_discount_items FROM order_discount_items
@@ -300,327 +276,342 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
GROUP BY order_id, pid GROUP BY order_id, pid
`, [batchIds]); `, [batchIds]);
if (discounts.length > 0) { if (discounts.length === 0) return;
const placeholders = discounts.map(() => "(?, ?, ?)").join(",");
const values = discounts.flatMap(d => [d.order_id, d.pid, d.discount]); for (let j = 0; j < discounts.length; j += PG_BATCH_SIZE) {
const subBatch = discounts.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(d => [
d.order_id,
d.pid,
d.discount || 0
]);
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_order_discounts VALUES ${placeholders} INSERT INTO temp_order_discounts (order_id, pid, discount)
ON DUPLICATE KEY UPDATE VALUES ${placeholders}
discount = VALUES(discount) ON CONFLICT (order_id, pid) DO UPDATE SET
discount = EXCLUDED.discount
`, values); `, values);
} }
} };
// Get tax information in batches const processTaxesBatch = async (batchIds) => {
for (let i = 0; i < orderIds.length; i += 5000) { // Optimized tax query to avoid subquery
const batchIds = orderIds.slice(i, i + 5000);
const [taxes] = await prodConnection.query(` const [taxes] = await prodConnection.query(`
SELECT DISTINCT SELECT oti.order_id, otip.pid, otip.item_taxes_to_collect as tax
oti.order_id, FROM (
otip.pid, SELECT order_id, MAX(taxinfo_id) as latest_taxinfo_id
otip.item_taxes_to_collect as tax
FROM order_tax_info oti
JOIN (
SELECT order_id, MAX(stamp) as max_stamp
FROM order_tax_info FROM order_tax_info
WHERE order_id IN (?) WHERE order_id IN (?)
GROUP BY order_id GROUP BY order_id
) latest ON oti.order_id = latest.order_id AND oti.stamp = latest.max_stamp ) latest_info
JOIN order_tax_info oti ON oti.order_id = latest_info.order_id
AND oti.taxinfo_id = latest_info.latest_taxinfo_id
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
`, [batchIds]); `, [batchIds]);
if (taxes.length > 0) { if (taxes.length === 0) return;
// Remove any duplicates before inserting
const uniqueTaxes = new Map();
taxes.forEach(t => {
const key = `${t.order_id}-${t.pid}`;
uniqueTaxes.set(key, t);
});
const values = Array.from(uniqueTaxes.values()).flatMap(t => [t.order_id, t.pid, t.tax]); for (let j = 0; j < taxes.length; j += PG_BATCH_SIZE) {
if (values.length > 0) { const subBatch = taxes.slice(j, j + PG_BATCH_SIZE);
const placeholders = Array(uniqueTaxes.size).fill("(?, ?, ?)").join(","); if (subBatch.length === 0) continue;
await localConnection.query(`
INSERT INTO temp_order_taxes VALUES ${placeholders} const placeholders = subBatch.map((_, idx) =>
ON DUPLICATE KEY UPDATE tax = VALUES(tax) `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
`, values); ).join(",");
}
const values = subBatch.flatMap(t => [
t.order_id,
t.pid,
t.tax || 0
]);
await localConnection.query(`
INSERT INTO temp_order_taxes (order_id, pid, tax)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
tax = EXCLUDED.tax
`, values);
} }
} };
// Get costeach values in batches const processCostsBatch = async (batchIds) => {
for (let i = 0; i < orderIds.length; i += 5000) { // Modified query to ensure one row per order_id/pid by using a subquery
const batchIds = orderIds.slice(i, i + 5000);
const [costs] = await prodConnection.query(` const [costs] = await prodConnection.query(`
SELECT SELECT
oc.orderid as order_id, oc.orderid as order_id,
oc.pid, oc.pid,
COALESCE( oc.costeach
oc.costeach,
(SELECT pi.costeach
FROM product_inventory pi
WHERE pi.pid = oc.pid
AND pi.daterec <= o.date_placed
ORDER BY pi.daterec DESC LIMIT 1)
) as costeach
FROM order_costs oc FROM order_costs oc
JOIN _order o ON oc.orderid = o.order_id INNER JOIN (
WHERE oc.orderid IN (?) SELECT
orderid,
pid,
MAX(id) as max_id
FROM order_costs
WHERE orderid IN (?)
AND pending = 0
GROUP BY orderid, pid
) latest ON oc.orderid = latest.orderid AND oc.pid = latest.pid AND oc.id = latest.max_id
`, [batchIds]); `, [batchIds]);
if (costs.length > 0) { if (costs.length === 0) return;
const placeholders = costs.map(() => '(?, ?, ?)').join(",");
const values = costs.flatMap(c => [c.order_id, c.pid, c.costeach || 0]); for (let j = 0; j < costs.length; j += PG_BATCH_SIZE) {
const subBatch = costs.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(c => [
c.order_id,
c.pid,
c.costeach || 0
]);
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_order_costs (order_id, pid, costeach) INSERT INTO temp_order_costs (order_id, pid, costeach)
VALUES ${placeholders} VALUES ${placeholders}
ON DUPLICATE KEY UPDATE costeach = VALUES(costeach) ON CONFLICT (order_id, pid) DO UPDATE SET
costeach = EXCLUDED.costeach
`, values); `, values);
} }
} };
// Now combine all the data and insert into orders table // Process all data types in parallel for each batch
// Pre-check all products at once instead of per batch for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
const allOrderPids = [...new Set(orderItems.map(item => item.pid))]; const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
"SELECT pid FROM products WHERE pid IN (?)",
[allOrderPids]
) : [[]];
const existingPids = new Set(existingProducts.map(p => p.pid));
// Process in larger batches
for (let i = 0; i < orderIds.length; i += 5000) {
const batchIds = orderIds.slice(i, i + 5000);
// Get combined data for this batch
const [orders] = await localConnection.query(`
SELECT
oi.order_id as order_number,
oi.pid,
oi.SKU,
om.date,
oi.price,
oi.quantity,
oi.base_discount + COALESCE(od.discount, 0) +
CASE
WHEN om.summary_discount > 0 THEN
ROUND((om.summary_discount * (oi.price * oi.quantity)) /
NULLIF(om.summary_subtotal, 0), 2)
ELSE 0
END as discount,
COALESCE(ot.tax, 0) as tax,
0 as tax_included,
0 as shipping,
om.customer,
om.customer_name,
om.status,
om.canceled,
COALESCE(tc.costeach, 0) as costeach
FROM temp_order_items oi
JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs tc ON oi.order_id = tc.order_id AND oi.pid = tc.pid
WHERE oi.order_id IN (?)
`, [batchIds]);
// Filter orders and track missing products - do this in a single pass
const validOrders = [];
const values = [];
const processedOrderItems = new Set(); // Track unique order items
const processedOrders = new Set(); // Track unique orders
for (const order of orders) { await Promise.all([
if (!existingPids.has(order.pid)) { processMetadataBatch(batchIds),
missingProducts.add(order.pid); processDiscountsBatch(batchIds),
skippedOrders.add(order.order_number); processTaxesBatch(batchIds),
continue; processCostsBatch(batchIds)
} ]);
validOrders.push(order);
values.push(...columnNames.map(col => order[col] ?? null));
processedOrderItems.add(`${order.order_number}-${order.pid}`);
processedOrders.add(order.order_number);
}
if (validOrders.length > 0) { processedCount = i + batchIds.length;
// Pre-compute the placeholders string once
const singlePlaceholder = `(${columnNames.map(() => "?").join(",")})`;
const placeholders = Array(validOrders.length).fill(singlePlaceholder).join(",");
const result = await localConnection.query(`
INSERT INTO orders (${columnNames.join(",")})
VALUES ${placeholders}
ON DUPLICATE KEY UPDATE
SKU = VALUES(SKU),
date = VALUES(date),
price = VALUES(price),
quantity = VALUES(quantity),
discount = VALUES(discount),
tax = VALUES(tax),
tax_included = VALUES(tax_included),
shipping = VALUES(shipping),
customer = VALUES(customer),
customer_name = VALUES(customer_name),
status = VALUES(status),
canceled = VALUES(canceled),
costeach = VALUES(costeach)
`, validOrders.map(o => columnNames.map(col => o[col] ?? null)).flat());
const affectedRows = result[0].affectedRows;
const updates = Math.floor(affectedRows / 2);
const inserts = affectedRows - (updates * 2);
recordsAdded += inserts;
recordsUpdated += updates;
importedCount += processedOrderItems.size; // Count unique order items processed
}
// Update progress based on unique orders processed
cumulativeProcessedOrders += processedOrders.size;
outputProgress({ outputProgress({
status: "running", status: "running",
operation: "Orders import", operation: "Orders import",
message: `Imported ${importedCount} order items (${cumulativeProcessedOrders} of ${totalUniqueOrders} orders processed)`, message: `Loading order data: ${processedCount} of ${totalUniqueOrders}`,
current: cumulativeProcessedOrders, current: processedCount,
total: totalUniqueOrders, total: totalUniqueOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000), elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders), remaining: estimateRemaining(startTime, processedCount, totalUniqueOrders),
rate: calculateRate(startTime, cumulativeProcessedOrders) rate: calculateRate(startTime, processedCount)
}); });
} }
// Now try to import any orders that were skipped due to missing products // Pre-check all products at once
if (skippedOrders.size > 0) { const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
try { console.log('Orders: Checking', allOrderPids.length, 'unique products');
outputProgress({
status: "running", const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
operation: "Orders import", "SELECT pid FROM products WHERE pid = ANY($1::bigint[])",
message: `Retrying import of ${skippedOrders.size} orders with previously missing products`, [allOrderPids]
}); ) : [[]];
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
// Process in smaller batches
for (let i = 0; i < orderIds.length; i += 1000) {
const batchIds = orderIds.slice(i, i + 1000);
// Get the orders that were skipped // Get combined data for this batch in sub-batches
const [skippedProdOrders] = await localConnection.query(` const PG_BATCH_SIZE = 100; // Process 100 records at a time
SELECT DISTINCT for (let j = 0; j < batchIds.length; j += PG_BATCH_SIZE) {
const subBatchIds = batchIds.slice(j, j + PG_BATCH_SIZE);
const [orders] = await localConnection.query(`
WITH order_totals AS (
SELECT
oi.order_id,
oi.pid,
SUM(COALESCE(od.discount, 0)) as promo_discount,
COALESCE(ot.tax, 0) as total_tax,
COALESCE(oc.costeach, oi.price * 0.5) as costeach
FROM temp_order_items oi
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
)
SELECT
oi.order_id as order_number, oi.order_id as order_number,
oi.pid, oi.pid::bigint as pid,
oi.SKU, oi.SKU as sku,
om.date, om.date,
oi.price, oi.price,
oi.quantity, oi.quantity,
oi.base_discount + COALESCE(od.discount, 0) + (oi.base_discount +
CASE COALESCE(ot.promo_discount, 0) +
WHEN o.summary_discount > 0 THEN CASE
ROUND((o.summary_discount * (oi.price * oi.quantity)) / WHEN om.summary_discount > 0 AND om.summary_subtotal > 0 THEN
NULLIF(o.summary_subtotal, 0), 2) ROUND((om.summary_discount * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 2)
ELSE 0 ELSE 0
END as discount, END)::DECIMAL(10,2) as discount,
COALESCE(ot.tax, 0) as tax, COALESCE(ot.total_tax, 0)::DECIMAL(10,2) as tax,
0 as tax_included, false as tax_included,
0 as shipping, 0 as shipping,
om.customer, om.customer,
om.customer_name, om.customer_name,
om.status, om.status,
om.canceled, om.canceled,
COALESCE(tc.costeach, 0) as costeach COALESCE(ot.costeach, oi.price * 0.5)::DECIMAL(10,3) as costeach
FROM temp_order_items oi FROM (
SELECT DISTINCT ON (order_id, pid)
order_id, pid, SKU, price, quantity, base_discount
FROM temp_order_items
WHERE order_id = ANY($1)
ORDER BY order_id, pid
) oi
JOIN temp_order_meta om ON oi.order_id = om.order_id JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN _order o ON oi.order_id = o.order_id LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid ORDER BY oi.order_id, oi.pid
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid `, [subBatchIds]);
LEFT JOIN temp_order_costs tc ON oi.order_id = tc.order_id AND oi.pid = tc.pid
WHERE oi.order_id IN (?)
`, [Array.from(skippedOrders)]);
// Check which products exist now // Filter orders and track missing products
const skippedPids = [...new Set(skippedProdOrders.map(o => o.pid))]; const validOrders = [];
const [existingProducts] = skippedPids.length > 0 ? await localConnection.query( const processedOrderItems = new Set();
"SELECT pid FROM products WHERE pid IN (?)", const processedOrders = new Set();
[skippedPids]
) : [[]]; for (const order of orders.rows) {
const existingPids = new Set(existingProducts.map(p => p.pid)); if (!existingPids.has(order.pid)) {
missingProducts.add(order.pid);
// Filter orders that can now be imported skippedOrders.add(order.order_number);
const validOrders = skippedProdOrders.filter(order => existingPids.has(order.pid)); continue;
const retryOrderItems = new Set(); // Track unique order items in retry }
validOrders.push(order);
if (validOrders.length > 0) { processedOrderItems.add(`${order.order_number}-${order.pid}`);
const placeholders = validOrders.map(() => `(${columnNames.map(() => "?").join(", ")})`).join(","); processedOrders.add(order.order_number);
const values = validOrders.map(o => columnNames.map(col => o[col] ?? null)).flat();
const result = await localConnection.query(`
INSERT INTO orders (${columnNames.join(", ")})
VALUES ${placeholders}
ON DUPLICATE KEY UPDATE
SKU = VALUES(SKU),
date = VALUES(date),
price = VALUES(price),
quantity = VALUES(quantity),
discount = VALUES(discount),
tax = VALUES(tax),
tax_included = VALUES(tax_included),
shipping = VALUES(shipping),
customer = VALUES(customer),
customer_name = VALUES(customer_name),
status = VALUES(status),
canceled = VALUES(canceled),
costeach = VALUES(costeach)
`, values);
const affectedRows = result[0].affectedRows;
const updates = Math.floor(affectedRows / 2);
const inserts = affectedRows - (updates * 2);
// Track unique order items
validOrders.forEach(order => {
retryOrderItems.add(`${order.order_number}-${order.pid}`);
});
outputProgress({
status: "running",
operation: "Orders import",
message: `Successfully imported ${retryOrderItems.size} previously skipped order items`,
});
// Update the main counters
recordsAdded += inserts;
recordsUpdated += updates;
importedCount += retryOrderItems.size;
} }
} catch (error) {
console.warn('Warning: Failed to retry skipped orders:', error.message); // Process valid orders in smaller sub-batches
console.warn(`Skipped ${skippedOrders.size} orders due to ${missingProducts.size} missing products`); const FINAL_BATCH_SIZE = 50;
for (let k = 0; k < validOrders.length; k += FINAL_BATCH_SIZE) {
const subBatch = validOrders.slice(k, k + FINAL_BATCH_SIZE);
const placeholders = subBatch.map((_, idx) => {
const base = idx * 15; // 15 columns including costeach
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
}).join(',');
const batchValues = subBatch.flatMap(o => [
o.order_number,
o.pid,
o.sku || 'NO-SKU',
o.date,
o.price,
o.quantity,
o.discount,
o.tax,
o.tax_included,
o.shipping,
o.customer,
o.customer_name,
o.status,
o.canceled,
o.costeach
]);
const [result] = await localConnection.query(`
WITH inserted_orders AS (
INSERT INTO orders (
order_number, pid, sku, date, price, quantity, discount,
tax, tax_included, shipping, customer, customer_name,
status, canceled, costeach
)
VALUES ${placeholders}
ON CONFLICT (order_number, pid) DO UPDATE SET
sku = EXCLUDED.sku,
date = EXCLUDED.date,
price = EXCLUDED.price,
quantity = EXCLUDED.quantity,
discount = EXCLUDED.discount,
tax = EXCLUDED.tax,
tax_included = EXCLUDED.tax_included,
shipping = EXCLUDED.shipping,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
costeach = EXCLUDED.costeach
RETURNING xmax = 0 as inserted
)
SELECT
COUNT(*) FILTER (WHERE inserted) as inserted,
COUNT(*) FILTER (WHERE NOT inserted) as updated
FROM inserted_orders
`, batchValues);
const { inserted, updated } = result.rows[0];
recordsAdded += parseInt(inserted) || 0;
recordsUpdated += parseInt(updated) || 0;
importedCount += subBatch.length;
}
cumulativeProcessedOrders += processedOrders.size;
outputProgress({
status: "running",
operation: "Orders import",
message: `Importing orders: ${cumulativeProcessedOrders} of ${totalUniqueOrders}`,
current: cumulativeProcessedOrders,
total: totalUniqueOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders),
rate: calculateRate(startTime, cumulativeProcessedOrders)
});
} }
} }
// Clean up temporary tables after ALL processing is complete // Update sync status
await localConnection.query(`
DROP TEMPORARY TABLE IF EXISTS temp_order_items;
DROP TEMPORARY TABLE IF EXISTS temp_order_meta;
DROP TEMPORARY TABLE IF EXISTS temp_order_discounts;
DROP TEMPORARY TABLE IF EXISTS temp_order_taxes;
DROP TEMPORARY TABLE IF EXISTS temp_order_costs;
`);
// Only update sync status if we get here (no errors thrown)
await localConnection.query(` await localConnection.query(`
INSERT INTO sync_status (table_name, last_sync_timestamp) INSERT INTO sync_status (table_name, last_sync_timestamp)
VALUES ('orders', NOW()) VALUES ('orders', NOW())
ON DUPLICATE KEY UPDATE last_sync_timestamp = NOW() ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`); `);
// Cleanup temporary tables
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
`);
// Commit transaction
await localConnection.commit();
return { return {
status: "complete", status: "complete",
totalImported: Math.floor(importedCount), totalImported: Math.floor(importedCount) || 0,
recordsAdded: recordsAdded || 0, recordsAdded: parseInt(recordsAdded) || 0,
recordsUpdated: Math.floor(recordsUpdated), recordsUpdated: parseInt(recordsUpdated) || 0,
totalSkipped: skippedOrders.size, totalSkipped: skippedOrders.size || 0,
missingProducts: missingProducts.size, missingProducts: missingProducts.size || 0,
incrementalUpdate, incrementalUpdate,
lastSyncTime lastSyncTime
}; };
} catch (error) { } catch (error) {
console.error("Error during orders import:", error); console.error("Error during orders import:", error);
// Rollback transaction
try {
await localConnection.rollback();
} catch (rollbackError) {
console.error("Error during rollback:", rollbackError);
}
throw error; throw error;
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
const mysql = require("mysql2/promise"); const mysql = require("mysql2/promise");
const { Client } = require("ssh2"); const { Client } = require("ssh2");
const { Pool } = require('pg');
const dotenv = require("dotenv"); const dotenv = require("dotenv");
const path = require("path"); const path = require("path");
@@ -41,23 +42,90 @@ async function setupSshTunnel(sshConfig) {
async function setupConnections(sshConfig) { async function setupConnections(sshConfig) {
const tunnel = await setupSshTunnel(sshConfig); const tunnel = await setupSshTunnel(sshConfig);
// Setup MySQL connection for production
const prodConnection = await mysql.createConnection({ const prodConnection = await mysql.createConnection({
...sshConfig.prodDbConfig, ...sshConfig.prodDbConfig,
stream: tunnel.stream, stream: tunnel.stream,
}); });
const localConnection = await mysql.createPool({ // Setup PostgreSQL connection pool for local
...sshConfig.localDbConfig, const localPool = new Pool(sshConfig.localDbConfig);
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
return { // Test the PostgreSQL connection
ssh: tunnel.ssh, try {
prodConnection, const client = await localPool.connect();
localConnection await client.query('SELECT NOW()');
client.release();
console.log('PostgreSQL connection successful');
} catch (err) {
console.error('PostgreSQL connection error:', err);
throw err;
}
// Create a wrapper for the PostgreSQL pool to match MySQL interface
const localConnection = {
_client: null,
_transactionActive: false,
query: async (text, params) => {
// If we're not in a transaction, use the pool directly
if (!localConnection._transactionActive) {
const client = await localPool.connect();
try {
const result = await client.query(text, params);
return [result];
} finally {
client.release();
}
}
// If we're in a transaction, use the dedicated client
if (!localConnection._client) {
throw new Error('No active transaction client');
}
const result = await localConnection._client.query(text, params);
return [result];
},
beginTransaction: async () => {
if (localConnection._transactionActive) {
throw new Error('Transaction already active');
}
localConnection._client = await localPool.connect();
await localConnection._client.query('BEGIN');
localConnection._transactionActive = true;
},
commit: async () => {
if (!localConnection._transactionActive) {
throw new Error('No active transaction to commit');
}
await localConnection._client.query('COMMIT');
localConnection._client.release();
localConnection._client = null;
localConnection._transactionActive = false;
},
rollback: async () => {
if (!localConnection._transactionActive) {
throw new Error('No active transaction to rollback');
}
await localConnection._client.query('ROLLBACK');
localConnection._client.release();
localConnection._client = null;
localConnection._transactionActive = false;
},
end: async () => {
if (localConnection._client) {
localConnection._client.release();
localConnection._client = null;
}
await localPool.end();
}
}; };
return { prodConnection, localConnection, tunnel };
} }
// Helper function to close connections // Helper function to close connections

View File

@@ -1,4 +1,4 @@
const mysql = require('mysql2/promise'); const { Client } = require('pg');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const fs = require('fs'); const fs = require('fs');
@@ -10,7 +10,7 @@ const dbConfig = {
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
multipleStatements: true port: process.env.DB_PORT || 5432
}; };
// Helper function to output progress in JSON format // Helper function to output progress in JSON format
@@ -54,14 +54,44 @@ function splitSQLStatements(sql) {
let currentStatement = ''; let currentStatement = '';
let inString = false; let inString = false;
let stringChar = ''; let stringChar = '';
let inDollarQuote = false;
let dollarQuoteTag = '';
// Process character by character // Process character by character
for (let i = 0; i < sql.length; i++) { for (let i = 0; i < sql.length; i++) {
const char = sql[i]; const char = sql[i];
const nextChar = sql[i + 1] || ''; const nextChar = sql[i + 1] || '';
// Handle string literals // Handle dollar quotes
if ((char === "'" || char === '"') && sql[i - 1] !== '\\') { if (char === '$' && !inString) {
// Look ahead to find the dollar quote tag
let tag = '$';
let j = i + 1;
while (j < sql.length && sql[j] !== '$') {
tag += sql[j];
j++;
}
tag += '$';
if (j < sql.length) { // Found closing $
if (!inDollarQuote) {
inDollarQuote = true;
dollarQuoteTag = tag;
currentStatement += tag;
i = j;
continue;
} else if (sql.substring(i, j + 1) === dollarQuoteTag) {
inDollarQuote = false;
dollarQuoteTag = '';
currentStatement += tag;
i = j;
continue;
}
}
}
// Handle string literals (only if not in dollar quote)
if (!inDollarQuote && (char === "'" || char === '"') && sql[i - 1] !== '\\') {
if (!inString) { if (!inString) {
inString = true; inString = true;
stringChar = char; stringChar = char;
@@ -70,23 +100,25 @@ function splitSQLStatements(sql) {
} }
} }
// Handle comments // Handle comments (only if not in string or dollar quote)
if (!inString && char === '-' && nextChar === '-') { if (!inString && !inDollarQuote) {
// Skip to end of line if (char === '-' && nextChar === '-') {
while (i < sql.length && sql[i] !== '\n') i++; // Skip to end of line
continue; while (i < sql.length && sql[i] !== '\n') i++;
continue;
}
if (char === '/' && nextChar === '*') {
// Skip until closing */
i += 2;
while (i < sql.length && (sql[i] !== '*' || sql[i + 1] !== '/')) i++;
i++; // Skip the closing /
continue;
}
} }
if (!inString && char === '/' && nextChar === '*') { // Handle statement boundaries (only if not in string or dollar quote)
// Skip until closing */ if (!inString && !inDollarQuote && char === ';') {
i += 2;
while (i < sql.length && (sql[i] !== '*' || sql[i + 1] !== '/')) i++;
i++; // Skip the closing /
continue;
}
// Handle statement boundaries
if (!inString && char === ';') {
if (currentStatement.trim()) { if (currentStatement.trim()) {
statements.push(currentStatement.trim()); statements.push(currentStatement.trim());
} }
@@ -120,30 +152,26 @@ async function resetDatabase() {
} }
}); });
const connection = await mysql.createConnection(dbConfig); const client = new Client(dbConfig);
await client.connect();
try { try {
// Check MySQL privileges // Check PostgreSQL version and user
outputProgress({ outputProgress({
operation: 'Checking privileges', operation: 'Checking database',
message: 'Verifying MySQL user privileges...' message: 'Verifying PostgreSQL version and user privileges...'
}); });
const [grants] = await connection.query('SHOW GRANTS'); const versionResult = await client.query('SELECT version()');
outputProgress({ const userResult = await client.query('SELECT current_user, current_database()');
operation: 'User privileges',
message: {
grants: grants.map(g => Object.values(g)[0])
}
});
// Enable warnings as errors
await connection.query('SET SESSION sql_notes = 1');
// Log database config (without sensitive info)
outputProgress({ outputProgress({
operation: 'Database config', operation: 'Database info',
message: `Using database: ${dbConfig.database} on host: ${dbConfig.host}` message: {
version: versionResult.rows[0].version,
user: userResult.rows[0].current_user,
database: userResult.rows[0].current_database
}
}); });
// Get list of all tables in the current database // Get list of all tables in the current database
@@ -152,14 +180,14 @@ async function resetDatabase() {
message: 'Retrieving all table names...' message: 'Retrieving all table names...'
}); });
const [tables] = await connection.query(` const tablesResult = await client.query(`
SELECT GROUP_CONCAT(table_name) as tables SELECT string_agg(tablename, ', ') as tables
FROM information_schema.tables FROM pg_tables
WHERE table_schema = DATABASE() WHERE schemaname = 'public'
AND table_name NOT IN ('users', 'import_history', 'calculate_history') AND tablename NOT IN ('users', 'permissions', 'user_permissions', 'calculate_history', 'import_history', 'ai_prompts', 'ai_validation_performance', 'templates');
`); `);
if (!tables[0].tables) { if (!tablesResult.rows[0].tables) {
outputProgress({ outputProgress({
operation: 'No tables found', operation: 'No tables found',
message: 'Database is already empty' message: 'Database is already empty'
@@ -170,20 +198,73 @@ async function resetDatabase() {
message: 'Dropping all existing tables...' message: 'Dropping all existing tables...'
}); });
await connection.query('SET FOREIGN_KEY_CHECKS = 0'); // Disable triggers/foreign key checks
const dropQuery = ` await client.query('SET session_replication_role = \'replica\';');
DROP TABLE IF EXISTS
${tables[0].tables // Drop all tables except users
.split(',') const tables = tablesResult.rows[0].tables.split(', ');
.filter(table => !['users', 'calculate_history'].includes(table)) for (const table of tables) {
.map(table => '`' + table + '`') if (!['users'].includes(table)) {
.join(', ')} await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
`; }
await connection.query(dropQuery); }
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
// Only drop types if we're not preserving history tables
const historyTablesExist = await client.query(`
SELECT EXISTS (
SELECT FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('calculate_history', 'import_history')
);
`);
if (!historyTablesExist.rows[0].exists) {
await client.query('DROP TYPE IF EXISTS calculation_status CASCADE;');
await client.query('DROP TYPE IF EXISTS module_name CASCADE;');
}
// Re-enable triggers/foreign key checks
await client.query('SET session_replication_role = \'origin\';');
} }
// Read and execute main schema (core tables) // Create enum types if they don't exist
outputProgress({
operation: 'Creating enum types',
message: 'Setting up required enum types...'
});
// Check if types exist before creating
const typesExist = await client.query(`
SELECT EXISTS (
SELECT 1 FROM pg_type
WHERE typname = 'calculation_status'
) as calc_status_exists,
EXISTS (
SELECT 1 FROM pg_type
WHERE typname = 'module_name'
) as module_name_exists;
`);
if (!typesExist.rows[0].calc_status_exists) {
await client.query(`CREATE TYPE calculation_status AS ENUM ('running', 'completed', 'failed', 'cancelled')`);
}
if (!typesExist.rows[0].module_name_exists) {
await client.query(`
CREATE TYPE module_name AS ENUM (
'product_metrics',
'time_aggregates',
'financial_metrics',
'vendor_metrics',
'category_metrics',
'brand_metrics',
'sales_forecasts',
'abc_classification'
)
`);
}
// Read and execute main schema first (core tables)
outputProgress({ outputProgress({
operation: 'Running database setup', operation: 'Running database setup',
message: 'Creating core tables...' message: 'Creating core tables...'
@@ -223,35 +304,24 @@ async function resetDatabase() {
for (let i = 0; i < statements.length; i++) { for (let i = 0; i < statements.length; i++) {
const stmt = statements[i]; const stmt = statements[i];
try { try {
const [result, fields] = await connection.query(stmt); const result = await client.query(stmt);
// Check for warnings
const [warnings] = await connection.query('SHOW WARNINGS');
if (warnings && warnings.length > 0) {
outputProgress({
status: 'warning',
operation: 'SQL Warning',
statement: i + 1,
warnings: warnings
});
}
// Verify if table was created (if this was a CREATE TABLE statement) // Verify if table was created (if this was a CREATE TABLE statement)
if (stmt.trim().toLowerCase().startsWith('create table')) { if (stmt.trim().toLowerCase().startsWith('create table')) {
const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?`?(\w+)`?/i)?.[1]; const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1];
if (tableName) { if (tableName) {
const [tableExists] = await connection.query(` const tableExists = await client.query(`
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = 'public'
AND table_name = ? AND table_name = $1
`, [tableName]); `, [tableName]);
outputProgress({ outputProgress({
operation: 'Table Creation Verification', operation: 'Table Creation Verification',
message: { message: {
table: tableName, table: tableName,
exists: tableExists[0].count > 0 exists: tableExists.rows[0].count > 0
} }
}); });
} }
@@ -263,7 +333,7 @@ async function resetDatabase() {
statement: i + 1, statement: i + 1,
total: statements.length, total: statements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
affectedRows: result.affectedRows rowCount: result.rowCount
} }
}); });
} catch (sqlError) { } catch (sqlError) {
@@ -271,8 +341,6 @@ async function resetDatabase() {
status: 'error', status: 'error',
operation: 'SQL Error', operation: 'SQL Error',
error: sqlError.message, error: sqlError.message,
sqlState: sqlError.sqlState,
errno: sqlError.errno,
statement: stmt, statement: stmt,
statementNumber: i + 1 statementNumber: i + 1
}); });
@@ -280,66 +348,12 @@ async function resetDatabase() {
} }
} }
// List all tables in the database after schema execution // Verify core tables were created
outputProgress({ const existingTables = (await client.query(`
operation: 'Debug database', SELECT table_name
message: {
currentDatabase: (await connection.query('SELECT DATABASE() as db'))[0][0].db
}
});
const [allTables] = await connection.query(`
SELECT
table_schema,
table_name,
engine,
create_time,
table_rows
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = 'public'
`); `)).rows.map(t => t.table_name);
if (allTables.length === 0) {
outputProgress({
operation: 'Warning',
message: 'No tables found in database after schema execution'
});
} else {
outputProgress({
operation: 'Tables after schema execution',
message: {
count: allTables.length,
tables: allTables.map(t => ({
schema: t.table_schema,
name: t.table_name,
engine: t.engine,
created: t.create_time,
rows: t.table_rows
}))
}
});
}
// Also check table status
const [tableStatus] = await connection.query('SHOW TABLE STATUS');
outputProgress({
operation: 'Table Status',
message: {
tables: tableStatus.map(t => ({
name: t.Name,
engine: t.Engine,
version: t.Version,
rowFormat: t.Row_format,
rows: t.Rows,
createTime: t.Create_time,
updateTime: t.Update_time
}))
}
});
// Verify core tables were created using SHOW TABLES
const [showTables] = await connection.query('SHOW TABLES');
const existingTables = showTables.map(t => Object.values(t)[0]);
outputProgress({ outputProgress({
operation: 'Core tables verification', operation: 'Core tables verification',
@@ -359,22 +373,12 @@ async function resetDatabase() {
); );
} }
// Verify all core tables use InnoDB
const [engineStatus] = await connection.query('SHOW TABLE STATUS WHERE Name IN (?)', [CORE_TABLES]);
const nonInnoDBTables = engineStatus.filter(t => t.Engine !== 'InnoDB');
if (nonInnoDBTables.length > 0) {
throw new Error(
`Tables using non-InnoDB engine: ${nonInnoDBTables.map(t => t.Name).join(', ')}`
);
}
outputProgress({ outputProgress({
operation: 'Core tables created', operation: 'Core tables created',
message: `Successfully created tables: ${CORE_TABLES.join(', ')}` message: `Successfully created tables: ${CORE_TABLES.join(', ')}`
}); });
// Read and execute config schema // Now read and execute config schema (since core tables exist)
outputProgress({ outputProgress({
operation: 'Running config setup', operation: 'Running config setup',
message: 'Creating configuration tables...' message: 'Creating configuration tables...'
@@ -400,18 +404,7 @@ async function resetDatabase() {
for (let i = 0; i < configStatements.length; i++) { for (let i = 0; i < configStatements.length; i++) {
const stmt = configStatements[i]; const stmt = configStatements[i];
try { try {
const [result, fields] = await connection.query(stmt); const result = await client.query(stmt);
// Check for warnings
const [warnings] = await connection.query('SHOW WARNINGS');
if (warnings && warnings.length > 0) {
outputProgress({
status: 'warning',
operation: 'Config SQL Warning',
statement: i + 1,
warnings: warnings
});
}
outputProgress({ outputProgress({
operation: 'Config SQL Progress', operation: 'Config SQL Progress',
@@ -419,7 +412,7 @@ async function resetDatabase() {
statement: i + 1, statement: i + 1,
total: configStatements.length, total: configStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
affectedRows: result.affectedRows rowCount: result.rowCount
} }
}); });
} catch (sqlError) { } catch (sqlError) {
@@ -427,8 +420,6 @@ async function resetDatabase() {
status: 'error', status: 'error',
operation: 'Config SQL Error', operation: 'Config SQL Error',
error: sqlError.message, error: sqlError.message,
sqlState: sqlError.sqlState,
errno: sqlError.errno,
statement: stmt, statement: stmt,
statementNumber: i + 1 statementNumber: i + 1
}); });
@@ -436,33 +427,6 @@ async function resetDatabase() {
} }
} }
// Verify config tables were created
const [showConfigTables] = await connection.query('SHOW TABLES');
const existingConfigTables = showConfigTables.map(t => Object.values(t)[0]);
outputProgress({
operation: 'Config tables verification',
message: {
found: existingConfigTables,
expected: CONFIG_TABLES
}
});
const missingConfigTables = CONFIG_TABLES.filter(
t => !existingConfigTables.includes(t)
);
if (missingConfigTables.length > 0) {
throw new Error(
`Failed to create config tables: ${missingConfigTables.join(', ')}`
);
}
outputProgress({
operation: 'Config tables created',
message: `Successfully created tables: ${CONFIG_TABLES.join(', ')}`
});
// Read and execute metrics schema (metrics tables) // Read and execute metrics schema (metrics tables)
outputProgress({ outputProgress({
operation: 'Running metrics setup', operation: 'Running metrics setup',
@@ -489,18 +453,7 @@ async function resetDatabase() {
for (let i = 0; i < metricsStatements.length; i++) { for (let i = 0; i < metricsStatements.length; i++) {
const stmt = metricsStatements[i]; const stmt = metricsStatements[i];
try { try {
const [result, fields] = await connection.query(stmt); const result = await client.query(stmt);
// Check for warnings
const [warnings] = await connection.query('SHOW WARNINGS');
if (warnings && warnings.length > 0) {
outputProgress({
status: 'warning',
operation: 'Metrics SQL Warning',
statement: i + 1,
warnings: warnings
});
}
outputProgress({ outputProgress({
operation: 'Metrics SQL Progress', operation: 'Metrics SQL Progress',
@@ -508,7 +461,7 @@ async function resetDatabase() {
statement: i + 1, statement: i + 1,
total: metricsStatements.length, total: metricsStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
affectedRows: result.affectedRows rowCount: result.rowCount
} }
}); });
} catch (sqlError) { } catch (sqlError) {
@@ -516,8 +469,6 @@ async function resetDatabase() {
status: 'error', status: 'error',
operation: 'Metrics SQL Error', operation: 'Metrics SQL Error',
error: sqlError.message, error: sqlError.message,
sqlState: sqlError.sqlState,
errno: sqlError.errno,
statement: stmt, statement: stmt,
statementNumber: i + 1 statementNumber: i + 1
}); });
@@ -539,7 +490,7 @@ async function resetDatabase() {
}); });
process.exit(1); process.exit(1);
} finally { } finally {
await connection.end(); await client.end();
} }
} }

View File

@@ -1,4 +1,4 @@
const mysql = require('mysql2/promise'); const { Client } = require('pg');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
@@ -8,7 +8,7 @@ const dbConfig = {
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
multipleStatements: true port: process.env.DB_PORT || 5432
}; };
function outputProgress(data) { function outputProgress(data) {
@@ -34,8 +34,8 @@ const METRICS_TABLES = [
'sales_forecasts', 'sales_forecasts',
'temp_purchase_metrics', 'temp_purchase_metrics',
'temp_sales_metrics', 'temp_sales_metrics',
'vendor_metrics', //before vendor_details for foreign key 'vendor_metrics',
'vendor_time_metrics', //before vendor_details for foreign key 'vendor_time_metrics',
'vendor_details' 'vendor_details'
]; ];
@@ -90,31 +90,31 @@ function splitSQLStatements(sql) {
} }
async function resetMetrics() { async function resetMetrics() {
let connection; let client;
try { try {
outputProgress({ outputProgress({
operation: 'Starting metrics reset', operation: 'Starting metrics reset',
message: 'Connecting to database...' message: 'Connecting to database...'
}); });
connection = await mysql.createConnection(dbConfig); client = new Client(dbConfig);
await connection.beginTransaction(); await client.connect();
// First verify current state // First verify current state
const [initialTables] = await connection.query(` const initialTables = await client.query(`
SELECT TABLE_NAME as name SELECT tablename as name
FROM information_schema.tables FROM pg_tables
WHERE TABLE_SCHEMA = DATABASE() WHERE schemaname = 'public'
AND TABLE_NAME IN (?) AND tablename = ANY($1)
`, [METRICS_TABLES]); `, [METRICS_TABLES]);
outputProgress({ outputProgress({
operation: 'Initial state', operation: 'Initial state',
message: `Found ${initialTables.length} existing metrics tables: ${initialTables.map(t => t.name).join(', ')}` message: `Found ${initialTables.rows.length} existing metrics tables: ${initialTables.rows.map(t => t.name).join(', ')}`
}); });
// Disable foreign key checks at the start // Disable foreign key checks at the start
await connection.query('SET FOREIGN_KEY_CHECKS = 0'); await client.query('SET session_replication_role = \'replica\'');
// Drop all metrics tables in reverse order to handle dependencies // Drop all metrics tables in reverse order to handle dependencies
outputProgress({ outputProgress({
@@ -124,17 +124,17 @@ async function resetMetrics() {
for (const table of [...METRICS_TABLES].reverse()) { for (const table of [...METRICS_TABLES].reverse()) {
try { try {
await connection.query(`DROP TABLE IF EXISTS ${table}`); await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
// Verify the table was actually dropped // Verify the table was actually dropped
const [checkDrop] = await connection.query(` const checkDrop = await client.query(`
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM information_schema.tables FROM pg_tables
WHERE TABLE_SCHEMA = DATABASE() WHERE schemaname = 'public'
AND TABLE_NAME = ? AND tablename = $1
`, [table]); `, [table]);
if (checkDrop[0].count > 0) { if (parseInt(checkDrop.rows[0].count) > 0) {
throw new Error(`Failed to drop table ${table} - table still exists`); throw new Error(`Failed to drop table ${table} - table still exists`);
} }
@@ -153,15 +153,15 @@ async function resetMetrics() {
} }
// Verify all tables were dropped // Verify all tables were dropped
const [afterDrop] = await connection.query(` const afterDrop = await client.query(`
SELECT TABLE_NAME as name SELECT tablename as name
FROM information_schema.tables FROM pg_tables
WHERE TABLE_SCHEMA = DATABASE() WHERE schemaname = 'public'
AND TABLE_NAME IN (?) AND tablename = ANY($1)
`, [METRICS_TABLES]); `, [METRICS_TABLES]);
if (afterDrop.length > 0) { if (afterDrop.rows.length > 0) {
throw new Error(`Failed to drop all tables. Remaining tables: ${afterDrop.map(t => t.name).join(', ')}`); throw new Error(`Failed to drop all tables. Remaining tables: ${afterDrop.rows.map(t => t.name).join(', ')}`);
} }
// Read metrics schema // Read metrics schema
@@ -187,39 +187,26 @@ async function resetMetrics() {
for (let i = 0; i < statements.length; i++) { for (let i = 0; i < statements.length; i++) {
const stmt = statements[i]; const stmt = statements[i];
try { try {
await connection.query(stmt); const result = await client.query(stmt);
// Check for warnings
const [warnings] = await connection.query('SHOW WARNINGS');
if (warnings && warnings.length > 0) {
outputProgress({
status: 'warning',
operation: 'SQL Warning',
message: {
statement: i + 1,
warnings: warnings
}
});
}
// If this is a CREATE TABLE statement, verify the table was created // If this is a CREATE TABLE statement, verify the table was created
if (stmt.trim().toLowerCase().startsWith('create table')) { if (stmt.trim().toLowerCase().startsWith('create table')) {
const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?`?(\w+)`?/i)?.[1]; const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1];
if (tableName) { if (tableName) {
const [checkCreate] = await connection.query(` const checkCreate = await client.query(`
SELECT TABLE_NAME as name, CREATE_TIME as created SELECT tablename as name
FROM information_schema.tables FROM pg_tables
WHERE TABLE_SCHEMA = DATABASE() WHERE schemaname = 'public'
AND TABLE_NAME = ? AND tablename = $1
`, [tableName]); `, [tableName]);
if (checkCreate.length === 0) { if (checkCreate.rows.length === 0) {
throw new Error(`Failed to create table ${tableName} - table does not exist after CREATE statement`); throw new Error(`Failed to create table ${tableName} - table does not exist after CREATE statement`);
} }
outputProgress({ outputProgress({
operation: 'Table created', operation: 'Table created',
message: `Successfully created table: ${tableName} at ${checkCreate[0].created}` message: `Successfully created table: ${tableName}`
}); });
} }
} }
@@ -229,7 +216,8 @@ async function resetMetrics() {
message: { message: {
statement: i + 1, statement: i + 1,
total: statements.length, total: statements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : '') preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
} }
}); });
} catch (sqlError) { } catch (sqlError) {
@@ -238,8 +226,6 @@ async function resetMetrics() {
operation: 'SQL Error', operation: 'SQL Error',
message: { message: {
error: sqlError.message, error: sqlError.message,
sqlState: sqlError.sqlState,
errno: sqlError.errno,
statement: stmt, statement: stmt,
statementNumber: i + 1 statementNumber: i + 1
} }
@@ -249,7 +235,7 @@ async function resetMetrics() {
} }
// Re-enable foreign key checks after all tables are created // Re-enable foreign key checks after all tables are created
await connection.query('SET FOREIGN_KEY_CHECKS = 1'); await client.query('SET session_replication_role = \'origin\'');
// Verify metrics tables were created // Verify metrics tables were created
outputProgress({ outputProgress({
@@ -257,37 +243,36 @@ async function resetMetrics() {
message: 'Checking all metrics tables were created...' message: 'Checking all metrics tables were created...'
}); });
const [metricsTablesResult] = await connection.query(` const metricsTablesResult = await client.query(`
SELECT SELECT tablename as name
TABLE_NAME as name, FROM pg_tables
TABLE_ROWS as \`rows\`, WHERE schemaname = 'public'
CREATE_TIME as created AND tablename = ANY($1)
FROM information_schema.tables
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (?)
`, [METRICS_TABLES]); `, [METRICS_TABLES]);
outputProgress({ outputProgress({
operation: 'Tables found', operation: 'Tables found',
message: `Found ${metricsTablesResult.length} tables: ${metricsTablesResult.map(t => message: `Found ${metricsTablesResult.rows.length} tables: ${metricsTablesResult.rows.map(t => t.name).join(', ')}`
`${t.name} (created: ${t.created})`
).join(', ')}`
}); });
const existingMetricsTables = metricsTablesResult.map(t => t.name); const existingMetricsTables = metricsTablesResult.rows.map(t => t.name);
const missingMetricsTables = METRICS_TABLES.filter(t => !existingMetricsTables.includes(t)); const missingMetricsTables = METRICS_TABLES.filter(t => !existingMetricsTables.includes(t));
if (missingMetricsTables.length > 0) { if (missingMetricsTables.length > 0) {
// Do one final check of the actual tables // Do one final check of the actual tables
const [finalCheck] = await connection.query('SHOW TABLES'); const finalCheck = await client.query(`
SELECT tablename as name
FROM pg_tables
WHERE schemaname = 'public'
`);
outputProgress({ outputProgress({
operation: 'Final table check', operation: 'Final table check',
message: `All database tables: ${finalCheck.map(t => Object.values(t)[0]).join(', ')}` message: `All database tables: ${finalCheck.rows.map(t => t.name).join(', ')}`
}); });
throw new Error(`Failed to create metrics tables: ${missingMetricsTables.join(', ')}`); throw new Error(`Failed to create metrics tables: ${missingMetricsTables.join(', ')}`);
} }
await connection.commit(); await client.query('COMMIT');
outputProgress({ outputProgress({
status: 'complete', status: 'complete',
@@ -302,17 +287,17 @@ async function resetMetrics() {
stack: error.stack stack: error.stack
}); });
if (connection) { if (client) {
await connection.rollback(); await client.query('ROLLBACK');
// Make sure to re-enable foreign key checks even if there's an error // Make sure to re-enable foreign key checks even if there's an error
await connection.query('SET FOREIGN_KEY_CHECKS = 1').catch(() => {}); await client.query('SET session_replication_role = \'origin\'').catch(() => {});
} }
throw error; throw error;
} finally { } finally {
if (connection) { if (client) {
// One final attempt to ensure foreign key checks are enabled // One final attempt to ensure foreign key checks are enabled
await connection.query('SET FOREIGN_KEY_CHECKS = 1').catch(() => {}); await client.query('SET session_replication_role = \'origin\'').catch(() => {});
await connection.end(); await client.end();
} }
} }
} }

View File

@@ -0,0 +1,337 @@
/**
* This script updates the costeach values for existing orders from the original MySQL database
* without needing to run the full import process.
*/
const dotenv = require("dotenv");
const path = require("path");
const fs = require("fs");
const { setupConnections, closeConnections } = require('./import/utils');
const { outputProgress, formatElapsedTime } = require('./metrics/utils/progress');
dotenv.config({ path: path.join(__dirname, "../.env") });
// SSH configuration
const sshConfig = {
ssh: {
host: process.env.PROD_SSH_HOST,
port: process.env.PROD_SSH_PORT || 22,
username: process.env.PROD_SSH_USER,
privateKey: process.env.PROD_SSH_KEY_PATH
? fs.readFileSync(process.env.PROD_SSH_KEY_PATH)
: undefined,
compress: true, // Enable SSH compression
},
prodDbConfig: {
// MySQL config for production
host: process.env.PROD_DB_HOST || "localhost",
user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z',
},
localDbConfig: {
// PostgreSQL config for local
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 || 5432,
ssl: process.env.DB_SSL === 'true',
connectionTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
max: 10 // connection pool max size
}
};
async function updateOrderCosts() {
const startTime = Date.now();
let connections;
let updatedCount = 0;
let errorCount = 0;
try {
outputProgress({
status: "running",
operation: "Order costs update",
message: "Initializing SSH tunnel..."
});
connections = await setupConnections(sshConfig);
const { prodConnection, localConnection } = connections;
// 1. Get all orders from local database that need cost updates
outputProgress({
status: "running",
operation: "Order costs update",
message: "Getting orders from local database..."
});
const [orders] = await localConnection.query(`
SELECT DISTINCT order_number, pid
FROM orders
WHERE costeach = 0 OR costeach IS NULL
ORDER BY order_number
`);
if (!orders || !orders.rows || orders.rows.length === 0) {
console.log("No orders found that need cost updates");
return { updatedCount: 0, errorCount: 0 };
}
const totalOrders = orders.rows.length;
console.log(`Found ${totalOrders} orders that need cost updates`);
// Process in batches of 1000 orders
const BATCH_SIZE = 500;
for (let i = 0; i < orders.rows.length; i += BATCH_SIZE) {
try {
// Start transaction for this batch
await localConnection.beginTransaction();
const batch = orders.rows.slice(i, i + BATCH_SIZE);
const orderNumbers = [...new Set(batch.map(o => o.order_number))];
// 2. Fetch costs from production database for these orders
outputProgress({
status: "running",
operation: "Order costs update",
message: `Fetching costs for orders ${i + 1} to ${Math.min(i + BATCH_SIZE, totalOrders)} of ${totalOrders}`,
current: i,
total: totalOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000)
});
const [costs] = await prodConnection.query(`
SELECT
oc.orderid as order_number,
oc.pid,
oc.costeach
FROM order_costs oc
INNER JOIN (
SELECT
orderid,
pid,
MAX(id) as max_id
FROM order_costs
WHERE orderid IN (?)
AND pending = 0
GROUP BY orderid, pid
) latest ON oc.orderid = latest.orderid AND oc.pid = latest.pid AND oc.id = latest.max_id
`, [orderNumbers]);
// Create a map of costs for easy lookup
const costMap = {};
if (costs && costs.length) {
costs.forEach(c => {
costMap[`${c.order_number}-${c.pid}`] = c.costeach || 0;
});
}
// 3. Update costs in local database by batches
// Using a more efficient update approach with a temporary table
// Create a temporary table for each batch
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_costs;
CREATE TEMP TABLE temp_order_costs (
order_number VARCHAR(50) NOT NULL,
pid BIGINT NOT NULL,
costeach DECIMAL(10,3) NOT NULL,
PRIMARY KEY (order_number, pid)
);
`);
// Insert cost data into the temporary table
const costEntries = [];
for (const order of batch) {
const key = `${order.order_number}-${order.pid}`;
if (key in costMap) {
costEntries.push({
order_number: order.order_number,
pid: order.pid,
costeach: costMap[key]
});
}
}
// Insert in sub-batches of 100
const DB_BATCH_SIZE = 50;
for (let j = 0; j < costEntries.length; j += DB_BATCH_SIZE) {
const subBatch = costEntries.slice(j, j + DB_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(',');
const values = subBatch.flatMap(item => [
item.order_number,
item.pid,
item.costeach
]);
await localConnection.query(`
INSERT INTO temp_order_costs (order_number, pid, costeach)
VALUES ${placeholders}
`, values);
}
// Perform bulk update from the temporary table
const [updateResult] = await localConnection.query(`
UPDATE orders o
SET costeach = t.costeach
FROM temp_order_costs t
WHERE o.order_number = t.order_number AND o.pid = t.pid
RETURNING o.id
`);
const batchUpdated = updateResult.rowCount || 0;
updatedCount += batchUpdated;
// Commit transaction for this batch
await localConnection.commit();
outputProgress({
status: "running",
operation: "Order costs update",
message: `Updated ${updatedCount} orders with costs from production (batch: ${batchUpdated})`,
current: i + batch.length,
total: totalOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000)
});
} catch (error) {
// If a batch fails, roll back that batch's transaction and continue
try {
await localConnection.rollback();
} catch (rollbackError) {
console.error("Error during batch rollback:", rollbackError);
}
console.error(`Error processing batch ${i}-${i + BATCH_SIZE}:`, error);
errorCount++;
}
}
// 4. For orders with no matching costs, set a default based on price
outputProgress({
status: "running",
operation: "Order costs update",
message: "Setting default costs for remaining orders..."
});
// Process remaining updates in smaller batches
const DEFAULT_BATCH_SIZE = 10000;
let totalDefaultUpdated = 0;
try {
// Start with a count query to determine how many records need the default update
const [countResult] = await localConnection.query(`
SELECT COUNT(*) as count FROM orders
WHERE (costeach = 0 OR costeach IS NULL)
`);
const totalToUpdate = parseInt(countResult.rows[0]?.count || 0);
if (totalToUpdate > 0) {
console.log(`Applying default cost to ${totalToUpdate} orders`);
// Apply the default in batches with separate transactions
for (let i = 0; i < totalToUpdate; i += DEFAULT_BATCH_SIZE) {
try {
await localConnection.beginTransaction();
const [defaultUpdates] = await localConnection.query(`
WITH orders_to_update AS (
SELECT id FROM orders
WHERE (costeach = 0 OR costeach IS NULL)
LIMIT ${DEFAULT_BATCH_SIZE}
)
UPDATE orders o
SET costeach = price * 0.5
FROM orders_to_update otu
WHERE o.id = otu.id
RETURNING o.id
`);
const batchDefaultUpdated = defaultUpdates.rowCount || 0;
totalDefaultUpdated += batchDefaultUpdated;
await localConnection.commit();
outputProgress({
status: "running",
operation: "Order costs update",
message: `Applied default costs to ${totalDefaultUpdated} of ${totalToUpdate} orders`,
current: totalDefaultUpdated,
total: totalToUpdate,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000)
});
} catch (error) {
try {
await localConnection.rollback();
} catch (rollbackError) {
console.error("Error during default update rollback:", rollbackError);
}
console.error(`Error applying default costs batch ${i}-${i + DEFAULT_BATCH_SIZE}:`, error);
errorCount++;
}
}
}
} catch (error) {
console.error("Error counting or updating remaining orders:", error);
errorCount++;
}
updatedCount += totalDefaultUpdated;
const endTime = Date.now();
const totalSeconds = (endTime - startTime) / 1000;
outputProgress({
status: "complete",
operation: "Order costs update",
message: `Updated ${updatedCount} orders (${totalDefaultUpdated} with default values) in ${formatElapsedTime(totalSeconds)}`,
elapsed: formatElapsedTime(totalSeconds)
});
return {
status: "complete",
updatedCount,
errorCount
};
} catch (error) {
console.error("Error during order costs update:", error);
return {
status: "error",
error: error.message,
updatedCount,
errorCount
};
} finally {
if (connections) {
await closeConnections(connections).catch(err => {
console.error("Error closing connections:", err);
});
}
}
}
// Run the script only if this is the main module
if (require.main === module) {
updateOrderCosts().then((results) => {
console.log('Cost update completed:', results);
// Force exit after a small delay to ensure all logs are written
setTimeout(() => process.exit(0), 500);
}).catch((error) => {
console.error("Unhandled error:", error);
// Force exit with error code after a small delay
setTimeout(() => process.exit(1), 500);
});
}
// Export the function for use in other scripts
module.exports = updateOrderCosts;

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,24 @@ router.get('/stats', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [results] = await pool.query(` const { rows: [results] } = await pool.query(`
SELECT SELECT
COALESCE( COALESCE(
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
), ),
0 0
) as profitMargin, ) as profitMargin,
COALESCE( COALESCE(
ROUND( ROUND(
(AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100), 1 (AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100)::numeric, 1
), ),
0 0
) as averageMarkup, ) as averageMarkup,
COALESCE( COALESCE(
ROUND( ROUND(
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 2 (SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 2
), ),
0 0
) as stockTurnoverRate, ) as stockTurnoverRate,
@@ -31,23 +31,23 @@ router.get('/stats', async (req, res) => {
COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount, COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount,
COALESCE( COALESCE(
ROUND( ROUND(
AVG(o.price * o.quantity), 2 AVG(o.price * o.quantity)::numeric, 2
), ),
0 0
) as averageOrderValue ) as averageOrderValue
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
`); `);
// Ensure all values are numbers // Ensure all values are numbers
const stats = { const stats = {
profitMargin: Number(results[0].profitMargin) || 0, profitMargin: Number(results.profitmargin) || 0,
averageMarkup: Number(results[0].averageMarkup) || 0, averageMarkup: Number(results.averagemarkup) || 0,
stockTurnoverRate: Number(results[0].stockTurnoverRate) || 0, stockTurnoverRate: Number(results.stockturnoverrate) || 0,
vendorCount: Number(results[0].vendorCount) || 0, vendorCount: Number(results.vendorcount) || 0,
categoryCount: Number(results[0].categoryCount) || 0, categoryCount: Number(results.categorycount) || 0,
averageOrderValue: Number(results[0].averageOrderValue) || 0 averageOrderValue: Number(results.averageordervalue) || 0
}; };
res.json(stats); res.json(stats);
@@ -63,13 +63,13 @@ router.get('/profit', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Get profit margins by category with full path // Get profit margins by category with full path
const [byCategory] = await pool.query(` const { rows: byCategory } = await pool.query(`
WITH RECURSIVE category_path AS ( WITH RECURSIVE category_path AS (
SELECT SELECT
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CAST(c.name AS CHAR(1000)) as path c.name::text as path
FROM categories c FROM categories c
WHERE c.parent_id IS NULL WHERE c.parent_id IS NULL
@@ -79,7 +79,7 @@ router.get('/profit', async (req, res) => {
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CONCAT(cp.path, ' > ', c.name) cp.path || ' > ' || c.name
FROM categories c FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id JOIN category_path cp ON c.parent_id = cp.cat_id
) )
@@ -88,53 +88,46 @@ router.get('/profit', async (req, res) => {
cp.path as categoryPath, cp.path as categoryPath,
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin, ) as profitMargin,
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue, ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY c.name, cp.path GROUP BY c.name, cp.path
ORDER BY profitMargin DESC ORDER BY profitMargin DESC
LIMIT 10 LIMIT 10
`); `);
// Get profit margin trend over time // Get profit margin trend over time
const [overTime] = await pool.query(` const { rows: overTime } = await pool.query(`
SELECT SELECT
formatted_date as date, to_char(o.date, 'YYYY-MM-DD') as date,
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin, ) as profitMargin,
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue, ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
CROSS JOIN ( WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date GROUP BY to_char(o.date, 'YYYY-MM-DD')
FROM orders o ORDER BY date
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
) dates
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND DATE_FORMAT(o.date, '%Y-%m-%d') = dates.formatted_date
GROUP BY formatted_date
ORDER BY formatted_date
`); `);
// Get top performing products with category paths // Get top performing products with category paths
const [topProducts] = await pool.query(` const { rows: topProducts } = await pool.query(`
WITH RECURSIVE category_path AS ( WITH RECURSIVE category_path AS (
SELECT SELECT
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CAST(c.name AS CHAR(1000)) as path c.name::text as path
FROM categories c FROM categories c
WHERE c.parent_id IS NULL WHERE c.parent_id IS NULL
@@ -144,7 +137,7 @@ router.get('/profit', async (req, res) => {
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CONCAT(cp.path, ' > ', c.name) cp.path || ' > ' || c.name
FROM categories c FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id JOIN category_path cp ON c.parent_id = cp.cat_id
) )
@@ -154,18 +147,18 @@ router.get('/profit', async (req, res) => {
cp.path as categoryPath, cp.path as categoryPath,
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin, ) as profitMargin,
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue, ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.pid, p.title, c.name, cp.path GROUP BY p.pid, p.title, c.name, cp.path
HAVING revenue > 0 HAVING SUM(o.price * o.quantity) > 0
ORDER BY profitMargin DESC ORDER BY profitMargin DESC
LIMIT 10 LIMIT 10
`); `);
@@ -185,7 +178,7 @@ router.get('/vendors', async (req, res) => {
console.log('Fetching vendor performance data...'); console.log('Fetching vendor performance data...');
// First check if we have any vendors with sales // First check if we have any vendors with sales
const [checkData] = await pool.query(` const { rows: [checkData] } = await pool.query(`
SELECT COUNT(DISTINCT p.vendor) as vendor_count, SELECT COUNT(DISTINCT p.vendor) as vendor_count,
COUNT(DISTINCT o.order_number) as order_count COUNT(DISTINCT o.order_number) as order_count
FROM products p FROM products p
@@ -193,39 +186,39 @@ router.get('/vendors', async (req, res) => {
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
`); `);
console.log('Vendor data check:', checkData[0]); console.log('Vendor data check:', checkData);
// Get vendor performance metrics // Get vendor performance metrics
const [performance] = await pool.query(` const { rows: performance } = await pool.query(`
WITH monthly_sales AS ( WITH monthly_sales AS (
SELECT SELECT
p.vendor, p.vendor,
CAST(SUM(CASE ROUND(SUM(CASE
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHEN o.date >= CURRENT_DATE - INTERVAL '30 days'
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) AS DECIMAL(15,3)) as current_month, END)::numeric, 3) as current_month,
CAST(SUM(CASE ROUND(SUM(CASE
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) WHEN o.date >= CURRENT_DATE - INTERVAL '60 days'
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.date < CURRENT_DATE - INTERVAL '30 days'
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) AS DECIMAL(15,3)) as previous_month END)::numeric, 3) as previous_month
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND o.date >= CURRENT_DATE - INTERVAL '60 days'
GROUP BY p.vendor GROUP BY p.vendor
) )
SELECT SELECT
p.vendor, p.vendor,
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as salesVolume, ROUND(SUM(o.price * o.quantity)::numeric, 3) as salesVolume,
COALESCE(ROUND( COALESCE(ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
), 0) as profitMargin, ), 0) as profitMargin,
COALESCE(ROUND( COALESCE(ROUND(
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1 (SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1
), 0) as stockTurnover, ), 0) as stockTurnover,
COUNT(DISTINCT p.pid) as productCount, COUNT(DISTINCT p.pid) as productCount,
ROUND( ROUND(
@@ -236,7 +229,7 @@ router.get('/vendors', async (req, res) => {
LEFT JOIN orders o ON p.pid = o.pid LEFT JOIN orders o ON p.pid = o.pid
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.vendor, ms.current_month, ms.previous_month GROUP BY p.vendor, ms.current_month, ms.previous_month
ORDER BY salesVolume DESC ORDER BY salesVolume DESC
LIMIT 10 LIMIT 10
@@ -244,45 +237,7 @@ router.get('/vendors', async (req, res) => {
console.log('Performance data:', performance); console.log('Performance data:', performance);
// Get vendor comparison data res.json({ performance });
const [comparison] = await pool.query(`
SELECT
p.vendor,
CAST(COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0), 2), 0) AS DECIMAL(15,3)) as salesPerProduct,
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
COUNT(DISTINCT p.pid) as size
FROM products p
LEFT JOIN orders o ON p.pid = o.pid AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE p.vendor IS NOT NULL
GROUP BY p.vendor
ORDER BY salesPerProduct DESC
LIMIT 20
`);
console.log('Comparison data:', comparison);
// Get vendor sales trends
const [trends] = await pool.query(`
SELECT
p.vendor,
DATE_FORMAT(o.date, '%b %Y') as month,
CAST(COALESCE(SUM(o.price * o.quantity), 0) AS DECIMAL(15,3)) as sales
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
GROUP BY
p.vendor,
DATE_FORMAT(o.date, '%b %Y'),
DATE_FORMAT(o.date, '%Y-%m')
ORDER BY
p.vendor,
DATE_FORMAT(o.date, '%Y-%m')
`);
console.log('Trends data:', trends);
res.json({ performance, comparison, trends });
} catch (error) { } catch (error) {
console.error('Error fetching vendor performance:', error); console.error('Error fetching vendor performance:', error);
res.status(500).json({ error: 'Failed to fetch vendor performance' }); res.status(500).json({ error: 'Failed to fetch vendor performance' });

View File

@@ -6,7 +6,7 @@ router.get('/', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// Get all categories with metrics and hierarchy info // Get all categories with metrics and hierarchy info
const [categories] = await pool.query(` const { rows: categories } = await pool.query(`
SELECT SELECT
c.cat_id, c.cat_id,
c.name, c.name,
@@ -18,7 +18,7 @@ router.get('/', async (req, res) => {
p.type as parent_type, p.type as parent_type,
COALESCE(cm.product_count, 0) as product_count, COALESCE(cm.product_count, 0) as product_count,
COALESCE(cm.active_products, 0) as active_products, COALESCE(cm.active_products, 0) as active_products,
CAST(COALESCE(cm.total_value, 0) AS DECIMAL(15,3)) as total_value, ROUND(COALESCE(cm.total_value, 0)::numeric, 3) as total_value,
COALESCE(cm.avg_margin, 0) as avg_margin, COALESCE(cm.avg_margin, 0) as avg_margin,
COALESCE(cm.turnover_rate, 0) as turnover_rate, COALESCE(cm.turnover_rate, 0) as turnover_rate,
COALESCE(cm.growth_rate, 0) as growth_rate COALESCE(cm.growth_rate, 0) as growth_rate
@@ -39,22 +39,22 @@ router.get('/', async (req, res) => {
`); `);
// Get overall stats // Get overall stats
const [stats] = await pool.query(` const { rows: [stats] } = await pool.query(`
SELECT SELECT
COUNT(DISTINCT c.cat_id) as totalCategories, COUNT(DISTINCT c.cat_id) as totalCategories,
COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories, COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
CAST(COALESCE(SUM(cm.total_value), 0) AS DECIMAL(15,3)) as totalValue, ROUND(COALESCE(SUM(cm.total_value), 0)::numeric, 3) as totalValue,
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin, COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0))::numeric, 1), 0) as avgMargin,
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0))::numeric, 1), 0) as avgGrowth
FROM categories c FROM categories c
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
`); `);
// Get type counts for filtering // Get type counts for filtering
const [typeCounts] = await pool.query(` const { rows: typeCounts } = await pool.query(`
SELECT SELECT
type, type,
COUNT(*) as count COUNT(*)::integer as count
FROM categories FROM categories
GROUP BY type GROUP BY type
ORDER BY type ORDER BY type
@@ -81,14 +81,14 @@ router.get('/', async (req, res) => {
})), })),
typeCounts: typeCounts.map(tc => ({ typeCounts: typeCounts.map(tc => ({
type: tc.type, type: tc.type,
count: parseInt(tc.count) count: tc.count // Already cast to integer in the query
})), })),
stats: { stats: {
totalCategories: parseInt(stats[0].totalCategories), totalCategories: parseInt(stats.totalcategories),
activeCategories: parseInt(stats[0].activeCategories), activeCategories: parseInt(stats.activecategories),
totalValue: parseFloat(stats[0].totalValue), totalValue: parseFloat(stats.totalvalue),
avgMargin: parseFloat(stats[0].avgMargin), avgMargin: parseFloat(stats.avgmargin),
avgGrowth: parseFloat(stats[0].avgGrowth) avgGrowth: parseFloat(stats.avggrowth)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -13,22 +13,22 @@ router.get('/', async (req, res) => {
try { try {
console.log('[Config Route] Fetching configuration values...'); console.log('[Config Route] Fetching configuration values...');
const [stockThresholds] = await pool.query('SELECT * FROM stock_thresholds WHERE id = 1'); const { rows: stockThresholds } = await pool.query('SELECT * FROM stock_thresholds WHERE id = 1');
console.log('[Config Route] Stock thresholds:', stockThresholds); console.log('[Config Route] Stock thresholds:', stockThresholds);
const [leadTimeThresholds] = await pool.query('SELECT * FROM lead_time_thresholds WHERE id = 1'); const { rows: leadTimeThresholds } = await pool.query('SELECT * FROM lead_time_thresholds WHERE id = 1');
console.log('[Config Route] Lead time thresholds:', leadTimeThresholds); console.log('[Config Route] Lead time thresholds:', leadTimeThresholds);
const [salesVelocityConfig] = await pool.query('SELECT * FROM sales_velocity_config WHERE id = 1'); const { rows: salesVelocityConfig } = await pool.query('SELECT * FROM sales_velocity_config WHERE id = 1');
console.log('[Config Route] Sales velocity config:', salesVelocityConfig); console.log('[Config Route] Sales velocity config:', salesVelocityConfig);
const [abcConfig] = await pool.query('SELECT * FROM abc_classification_config WHERE id = 1'); const { rows: abcConfig } = await pool.query('SELECT * FROM abc_classification_config WHERE id = 1');
console.log('[Config Route] ABC config:', abcConfig); console.log('[Config Route] ABC config:', abcConfig);
const [safetyStockConfig] = await pool.query('SELECT * FROM safety_stock_config WHERE id = 1'); const { rows: safetyStockConfig } = await pool.query('SELECT * FROM safety_stock_config WHERE id = 1');
console.log('[Config Route] Safety stock config:', safetyStockConfig); console.log('[Config Route] Safety stock config:', safetyStockConfig);
const [turnoverConfig] = await pool.query('SELECT * FROM turnover_config WHERE id = 1'); const { rows: turnoverConfig } = await pool.query('SELECT * FROM turnover_config WHERE id = 1');
console.log('[Config Route] Turnover config:', turnoverConfig); console.log('[Config Route] Turnover config:', turnoverConfig);
const response = { const response = {
@@ -53,14 +53,14 @@ router.put('/stock-thresholds/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity } = req.body; const { critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE stock_thresholds `UPDATE stock_thresholds
SET critical_days = ?, SET critical_days = $1,
reorder_days = ?, reorder_days = $2,
overstock_days = ?, overstock_days = $3,
low_stock_threshold = ?, low_stock_threshold = $4,
min_reorder_quantity = ? min_reorder_quantity = $5
WHERE id = ?`, WHERE id = $6`,
[critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity, req.params.id] [critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });
@@ -75,12 +75,12 @@ router.put('/lead-time-thresholds/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { target_days, warning_days, critical_days } = req.body; const { target_days, warning_days, critical_days } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE lead_time_thresholds `UPDATE lead_time_thresholds
SET target_days = ?, SET target_days = $1,
warning_days = ?, warning_days = $2,
critical_days = ? critical_days = $3
WHERE id = ?`, WHERE id = $4`,
[target_days, warning_days, critical_days, req.params.id] [target_days, warning_days, critical_days, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });
@@ -95,12 +95,12 @@ router.put('/sales-velocity/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { daily_window_days, weekly_window_days, monthly_window_days } = req.body; const { daily_window_days, weekly_window_days, monthly_window_days } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE sales_velocity_config `UPDATE sales_velocity_config
SET daily_window_days = ?, SET daily_window_days = $1,
weekly_window_days = ?, weekly_window_days = $2,
monthly_window_days = ? monthly_window_days = $3
WHERE id = ?`, WHERE id = $4`,
[daily_window_days, weekly_window_days, monthly_window_days, req.params.id] [daily_window_days, weekly_window_days, monthly_window_days, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });
@@ -115,12 +115,12 @@ router.put('/abc-classification/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { a_threshold, b_threshold, classification_period_days } = req.body; const { a_threshold, b_threshold, classification_period_days } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE abc_classification_config `UPDATE abc_classification_config
SET a_threshold = ?, SET a_threshold = $1,
b_threshold = ?, b_threshold = $2,
classification_period_days = ? classification_period_days = $3
WHERE id = ?`, WHERE id = $4`,
[a_threshold, b_threshold, classification_period_days, req.params.id] [a_threshold, b_threshold, classification_period_days, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });
@@ -135,11 +135,11 @@ router.put('/safety-stock/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { coverage_days, service_level } = req.body; const { coverage_days, service_level } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE safety_stock_config `UPDATE safety_stock_config
SET coverage_days = ?, SET coverage_days = $1,
service_level = ? service_level = $2
WHERE id = ?`, WHERE id = $3`,
[coverage_days, service_level, req.params.id] [coverage_days, service_level, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });
@@ -154,11 +154,11 @@ router.put('/turnover/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { calculation_period_days, target_rate } = req.body; const { calculation_period_days, target_rate } = req.body;
const [result] = await pool.query( const { rows } = await pool.query(
`UPDATE turnover_config `UPDATE turnover_config
SET calculation_period_days = ?, SET calculation_period_days = $1,
target_rate = ? target_rate = $2
WHERE id = ?`, WHERE id = $3`,
[calculation_period_days, target_rate, req.params.id] [calculation_period_days, target_rate, req.params.id]
); );
res.json({ success: true }); res.json({ success: true });

View File

@@ -750,8 +750,16 @@ router.post('/full-reset', async (req, res) => {
router.get('/history/import', async (req, res) => { router.get('/history/import', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [rows] = await pool.query(` const { rows } = await pool.query(`
SELECT * FROM import_history SELECT
id,
start_time,
end_time,
status,
error_message,
records_added::integer,
records_updated::integer
FROM import_history
ORDER BY start_time DESC ORDER BY start_time DESC
LIMIT 20 LIMIT 20
`); `);
@@ -766,8 +774,16 @@ router.get('/history/import', async (req, res) => {
router.get('/history/calculate', async (req, res) => { router.get('/history/calculate', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [rows] = await pool.query(` const { rows } = await pool.query(`
SELECT * FROM calculate_history SELECT
id,
start_time,
end_time,
status,
error_message,
modules_processed::integer,
total_modules::integer
FROM calculate_history
ORDER BY start_time DESC ORDER BY start_time DESC
LIMIT 20 LIMIT 20
`); `);
@@ -782,8 +798,10 @@ router.get('/history/calculate', async (req, res) => {
router.get('/status/modules', async (req, res) => { router.get('/status/modules', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [rows] = await pool.query(` const { rows } = await pool.query(`
SELECT module_name, last_calculation_timestamp SELECT
module_name,
last_calculation_timestamp::timestamp
FROM calculate_status FROM calculate_status
ORDER BY module_name ORDER BY module_name
`); `);
@@ -798,8 +816,10 @@ router.get('/status/modules', async (req, res) => {
router.get('/status/tables', async (req, res) => { router.get('/status/tables', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [rows] = await pool.query(` const { rows } = await pool.query(`
SELECT table_name, last_sync_timestamp SELECT
table_name,
last_sync_timestamp::timestamp
FROM sync_status FROM sync_status
ORDER BY table_name ORDER BY table_name
`); `);

View File

@@ -19,16 +19,15 @@ async function executeQuery(sql, params = []) {
router.get('/stock/metrics', async (req, res) => { router.get('/stock/metrics', async (req, res) => {
try { try {
// Get stock metrics // Get stock metrics
const [rows] = await executeQuery(` const { rows: [stockMetrics] } = await executeQuery(`
SELECT SELECT
COALESCE(COUNT(*), 0) as total_products, COALESCE(COUNT(*), 0)::integer as total_products,
COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0) as products_in_stock, COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0)::integer as products_in_stock,
COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0) as total_units, COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0)::integer as total_units,
COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0) as total_cost, ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0)::numeric, 3) as total_cost,
COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0) as total_retail ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0)::numeric, 3) as total_retail
FROM products FROM products
`); `);
const stockMetrics = rows[0];
console.log('Raw stockMetrics from database:', stockMetrics); console.log('Raw stockMetrics from database:', stockMetrics);
console.log('stockMetrics.total_products:', stockMetrics.total_products); console.log('stockMetrics.total_products:', stockMetrics.total_products);
@@ -38,26 +37,26 @@ router.get('/stock/metrics', async (req, res) => {
console.log('stockMetrics.total_retail:', stockMetrics.total_retail); console.log('stockMetrics.total_retail:', stockMetrics.total_retail);
// Get brand stock values with Other category // Get brand stock values with Other category
const [brandValues] = await executeQuery(` const { rows: brandValues } = await executeQuery(`
WITH brand_totals AS ( WITH brand_totals AS (
SELECT SELECT
COALESCE(brand, 'Unbranded') as brand, COALESCE(brand, 'Unbranded') as brand,
COUNT(DISTINCT pid) as variant_count, COUNT(DISTINCT pid)::integer as variant_count,
COALESCE(SUM(stock_quantity), 0) as stock_units, COALESCE(SUM(stock_quantity), 0)::integer as stock_units,
CAST(COALESCE(SUM(stock_quantity * cost_price), 0) AS DECIMAL(15,3)) as stock_cost, ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) as stock_cost,
CAST(COALESCE(SUM(stock_quantity * price), 0) AS DECIMAL(15,3)) as stock_retail ROUND(COALESCE(SUM(stock_quantity * price), 0)::numeric, 3) as stock_retail
FROM products FROM products
WHERE stock_quantity > 0 WHERE stock_quantity > 0
GROUP BY COALESCE(brand, 'Unbranded') GROUP BY COALESCE(brand, 'Unbranded')
HAVING stock_cost > 0 HAVING ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) > 0
), ),
other_brands AS ( other_brands AS (
SELECT SELECT
'Other' as brand, 'Other' as brand,
SUM(variant_count) as variant_count, SUM(variant_count)::integer as variant_count,
SUM(stock_units) as stock_units, SUM(stock_units)::integer as stock_units,
CAST(SUM(stock_cost) AS DECIMAL(15,3)) as stock_cost, ROUND(SUM(stock_cost)::numeric, 3) as stock_cost,
CAST(SUM(stock_retail) AS DECIMAL(15,3)) as stock_retail ROUND(SUM(stock_retail)::numeric, 3) as stock_retail
FROM brand_totals FROM brand_totals
WHERE stock_cost <= 5000 WHERE stock_cost <= 5000
), ),
@@ -101,51 +100,50 @@ router.get('/stock/metrics', async (req, res) => {
// Returns purchase order metrics by vendor // Returns purchase order metrics by vendor
router.get('/purchase/metrics', async (req, res) => { router.get('/purchase/metrics', async (req, res) => {
try { try {
const [rows] = await executeQuery(` const { rows: [poMetrics] } = await executeQuery(`
SELECT SELECT
COALESCE(COUNT(DISTINCT CASE COALESCE(COUNT(DISTINCT CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived} WHEN po.receiving_status < $1
THEN po.po_id THEN po.po_id
END), 0) as active_pos, END), 0)::integer as active_pos,
COALESCE(COUNT(DISTINCT CASE COALESCE(COUNT(DISTINCT CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived} WHEN po.receiving_status < $1
AND po.expected_date < CURDATE() AND po.expected_date < CURRENT_DATE
THEN po.po_id THEN po.po_id
END), 0) as overdue_pos, END), 0)::integer as overdue_pos,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived} WHEN po.receiving_status < $1
THEN po.ordered THEN po.ordered
ELSE 0 ELSE 0
END), 0) as total_units, END), 0)::integer as total_units,
CAST(COALESCE(SUM(CASE ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived} WHEN po.receiving_status < $1
THEN po.ordered * po.cost_price THEN po.ordered * po.cost_price
ELSE 0 ELSE 0
END), 0) AS DECIMAL(15,3)) as total_cost, END), 0)::numeric, 3) as total_cost,
CAST(COALESCE(SUM(CASE ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived} WHEN po.receiving_status < $1
THEN po.ordered * p.price THEN po.ordered * p.price
ELSE 0 ELSE 0
END), 0) AS DECIMAL(15,3)) as total_retail END), 0)::numeric, 3) as total_retail
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.pid = p.pid JOIN products p ON po.pid = p.pid
`); `, [ReceivingStatus.PartialReceived]);
const poMetrics = rows[0];
const [vendorOrders] = await executeQuery(` const { rows: vendorOrders } = await executeQuery(`
SELECT SELECT
po.vendor, po.vendor,
COUNT(DISTINCT po.po_id) as orders, COUNT(DISTINCT po.po_id)::integer as orders,
COALESCE(SUM(po.ordered), 0) as units, COALESCE(SUM(po.ordered), 0)::integer as units,
CAST(COALESCE(SUM(po.ordered * po.cost_price), 0) AS DECIMAL(15,3)) as cost, ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost,
CAST(COALESCE(SUM(po.ordered * p.price), 0) AS DECIMAL(15,3)) as retail ROUND(COALESCE(SUM(po.ordered * p.price), 0)::numeric, 3) as retail
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.pid = p.pid JOIN products p ON po.pid = p.pid
WHERE po.receiving_status < ${ReceivingStatus.PartialReceived} WHERE po.receiving_status < $1
GROUP BY po.vendor GROUP BY po.vendor
HAVING cost > 0 HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
ORDER BY cost DESC ORDER BY cost DESC
`); `, [ReceivingStatus.PartialReceived]);
// Format response to match PurchaseMetricsData interface // Format response to match PurchaseMetricsData interface
const response = { const response = {
@@ -175,21 +173,21 @@ router.get('/purchase/metrics', async (req, res) => {
router.get('/replenishment/metrics', async (req, res) => { router.get('/replenishment/metrics', async (req, res) => {
try { try {
// Get summary metrics // Get summary metrics
const [metrics] = await executeQuery(` const { rows: [metrics] } = await executeQuery(`
SELECT SELECT
COUNT(DISTINCT p.pid) as products_to_replenish, COUNT(DISTINCT p.pid)::integer as products_to_replenish,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty ELSE pm.reorder_qty
END), 0) as total_units_needed, END), 0)::integer as total_units_needed,
CAST(COALESCE(SUM(CASE ROUND(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price ELSE pm.reorder_qty * p.cost_price
END), 0) AS DECIMAL(15,3)) as total_cost, END), 0)::numeric, 3) as total_cost,
CAST(COALESCE(SUM(CASE ROUND(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price ELSE pm.reorder_qty * p.price
END), 0) AS DECIMAL(15,3)) as total_retail END), 0)::numeric, 3) as total_retail
FROM products p FROM products p
JOIN product_metrics pm ON p.pid = pm.pid JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
@@ -199,23 +197,23 @@ router.get('/replenishment/metrics', async (req, res) => {
`); `);
// Get top variants to replenish // Get top variants to replenish
const [variants] = await executeQuery(` const { rows: variants } = await executeQuery(`
SELECT SELECT
p.pid, p.pid,
p.title, p.title,
p.stock_quantity as current_stock, p.stock_quantity::integer as current_stock,
CASE CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty ELSE pm.reorder_qty
END as replenish_qty, END::integer as replenish_qty,
CAST(CASE ROUND(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price ELSE pm.reorder_qty * p.cost_price
END AS DECIMAL(15,3)) as replenish_cost, END::numeric, 3) as replenish_cost,
CAST(CASE ROUND(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price ELSE pm.reorder_qty * p.price
END AS DECIMAL(15,3)) as replenish_retail, END::numeric, 3) as replenish_retail,
pm.stock_status pm.stock_status
FROM products p FROM products p
JOIN product_metrics pm ON p.pid = pm.pid JOIN product_metrics pm ON p.pid = pm.pid
@@ -234,10 +232,10 @@ router.get('/replenishment/metrics', async (req, res) => {
// Format response // Format response
const response = { const response = {
productsToReplenish: parseInt(metrics[0].products_to_replenish) || 0, productsToReplenish: parseInt(metrics.products_to_replenish) || 0,
unitsToReplenish: parseInt(metrics[0].total_units_needed) || 0, unitsToReplenish: parseInt(metrics.total_units_needed) || 0,
replenishmentCost: parseFloat(metrics[0].total_cost) || 0, replenishmentCost: parseFloat(metrics.total_cost) || 0,
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0, replenishmentRetail: parseFloat(metrics.total_retail) || 0,
topVariants: variants.map(v => ({ topVariants: variants.map(v => ({
id: v.pid, id: v.pid,
title: v.title, title: v.title,

File diff suppressed because it is too large Load Diff

View File

@@ -5,26 +5,28 @@ const router = express.Router();
router.get('/trends', async (req, res) => { router.get('/trends', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const [rows] = await pool.query(` const { rows } = await pool.query(`
WITH MonthlyMetrics AS ( WITH MonthlyMetrics AS (
SELECT SELECT
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date, make_date(pta.year, pta.month, 1) as date,
CAST(COALESCE(SUM(pta.total_revenue), 0) AS DECIMAL(15,3)) as revenue, ROUND(COALESCE(SUM(pta.total_revenue), 0)::numeric, 3) as revenue,
CAST(COALESCE(SUM(pta.total_cost), 0) AS DECIMAL(15,3)) as cost, ROUND(COALESCE(SUM(pta.total_cost), 0)::numeric, 3) as cost,
CAST(COALESCE(SUM(pm.inventory_value), 0) AS DECIMAL(15,3)) as inventory_value, ROUND(COALESCE(SUM(pm.inventory_value), 0)::numeric, 3) as inventory_value,
CASE CASE
WHEN SUM(pm.inventory_value) > 0 WHEN SUM(pm.inventory_value) > 0
THEN CAST((SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100 AS DECIMAL(15,3)) THEN ROUND((SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value) * 100)::numeric, 3)
ELSE 0 ELSE 0
END as gmroi END as gmroi
FROM product_time_aggregates pta FROM product_time_aggregates pta
JOIN product_metrics pm ON pta.pid = pm.pid JOIN product_metrics pm ON pta.pid = pm.pid
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m') WHERE (pta.year * 100 + pta.month) >=
EXTRACT(YEAR FROM CURRENT_DATE - INTERVAL '12 months')::integer * 100 +
EXTRACT(MONTH FROM CURRENT_DATE - INTERVAL '12 months')::integer
GROUP BY pta.year, pta.month GROUP BY pta.year, pta.month
ORDER BY date ASC ORDER BY date ASC
) )
SELECT SELECT
DATE_FORMAT(date, '%b %y') as date, to_char(date, 'Mon YY') as date,
revenue, revenue,
inventory_value, inventory_value,
gmroi gmroi

View File

@@ -20,39 +20,46 @@ router.get('/', async (req, res) => {
// Build the WHERE clause // Build the WHERE clause
const conditions = ['o1.canceled = false']; const conditions = ['o1.canceled = false'];
const params = []; const params = [];
let paramCounter = 1;
if (search) { if (search) {
conditions.push('(o1.order_number LIKE ? OR o1.customer LIKE ?)'); conditions.push(`(o1.order_number ILIKE $${paramCounter} OR o1.customer ILIKE $${paramCounter})`);
params.push(`%${search}%`, `%${search}%`); params.push(`%${search}%`);
paramCounter++;
} }
if (status !== 'all') { if (status !== 'all') {
conditions.push('o1.status = ?'); conditions.push(`o1.status = $${paramCounter}`);
params.push(status); params.push(status);
paramCounter++;
} }
if (fromDate) { if (fromDate) {
conditions.push('DATE(o1.date) >= DATE(?)'); conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`);
params.push(fromDate.toISOString()); params.push(fromDate.toISOString());
paramCounter++;
} }
if (toDate) { if (toDate) {
conditions.push('DATE(o1.date) <= DATE(?)'); conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`);
params.push(toDate.toISOString()); params.push(toDate.toISOString());
paramCounter++;
} }
if (minAmount > 0) { if (minAmount > 0) {
conditions.push('total_amount >= ?'); conditions.push(`total_amount >= $${paramCounter}`);
params.push(minAmount); params.push(minAmount);
paramCounter++;
} }
if (maxAmount) { if (maxAmount) {
conditions.push('total_amount <= ?'); conditions.push(`total_amount <= $${paramCounter}`);
params.push(maxAmount); params.push(maxAmount);
paramCounter++;
} }
// Get total count for pagination // Get total count for pagination
const [countResult] = await pool.query(` const { rows: [countResult] } = await pool.query(`
SELECT COUNT(DISTINCT o1.order_number) as total SELECT COUNT(DISTINCT o1.order_number) as total
FROM orders o1 FROM orders o1
LEFT JOIN ( LEFT JOIN (
@@ -63,7 +70,7 @@ router.get('/', async (req, res) => {
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
`, params); `, params);
const total = countResult[0].total; const total = countResult.total;
// Get paginated results // Get paginated results
const query = ` const query = `
@@ -75,7 +82,7 @@ router.get('/', async (req, res) => {
o1.payment_method, o1.payment_method,
o1.shipping_method, o1.shipping_method,
COUNT(o2.pid) as items_count, COUNT(o2.pid) as items_count,
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount ROUND(SUM(o2.price * o2.quantity)::numeric, 3) as total_amount
FROM orders o1 FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number JOIN orders o2 ON o1.order_number = o2.order_number
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
@@ -91,36 +98,37 @@ router.get('/', async (req, res) => {
? `${sortColumn} ${sortDirection}` ? `${sortColumn} ${sortDirection}`
: `o1.${sortColumn} ${sortDirection}` : `o1.${sortColumn} ${sortDirection}`
} }
LIMIT ? OFFSET ? LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`; `;
const [rows] = await pool.query(query, [...params, limit, offset]); params.push(limit, offset);
const { rows } = await pool.query(query, params);
// Get order statistics // Get order statistics
const [stats] = await pool.query(` const { rows: [orderStats] } = await pool.query(`
WITH CurrentStats AS ( WITH CurrentStats AS (
SELECT SELECT
COUNT(DISTINCT order_number) as total_orders, COUNT(DISTINCT order_number) as total_orders,
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as total_revenue ROUND(SUM(price * quantity)::numeric, 3) as total_revenue
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) >= CURRENT_DATE - INTERVAL '30 days'
), ),
PreviousStats AS ( PreviousStats AS (
SELECT SELECT
COUNT(DISTINCT order_number) as prev_orders, COUNT(DISTINCT order_number) as prev_orders,
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as prev_revenue ROUND(SUM(price * quantity)::numeric, 3) as prev_revenue
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) BETWEEN CURRENT_DATE - INTERVAL '60 days' AND CURRENT_DATE - INTERVAL '30 days'
), ),
OrderValues AS ( OrderValues AS (
SELECT SELECT
order_number, order_number,
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as order_value ROUND(SUM(price * quantity)::numeric, 3) as order_value
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY order_number GROUP BY order_number
) )
SELECT SELECT
@@ -128,29 +136,27 @@ router.get('/', async (req, res) => {
cs.total_revenue, cs.total_revenue,
CASE CASE
WHEN ps.prev_orders > 0 WHEN ps.prev_orders > 0
THEN ((cs.total_orders - ps.prev_orders) / ps.prev_orders * 100) THEN ROUND(((cs.total_orders - ps.prev_orders)::numeric / ps.prev_orders * 100), 1)
ELSE 0 ELSE 0
END as order_growth, END as order_growth,
CASE CASE
WHEN ps.prev_revenue > 0 WHEN ps.prev_revenue > 0
THEN ((cs.total_revenue - ps.prev_revenue) / ps.prev_revenue * 100) THEN ROUND(((cs.total_revenue - ps.prev_revenue)::numeric / ps.prev_revenue * 100), 1)
ELSE 0 ELSE 0
END as revenue_growth, END as revenue_growth,
CASE CASE
WHEN cs.total_orders > 0 WHEN cs.total_orders > 0
THEN CAST((cs.total_revenue / cs.total_orders) AS DECIMAL(15,3)) THEN ROUND((cs.total_revenue::numeric / cs.total_orders), 3)
ELSE 0 ELSE 0
END as average_order_value, END as average_order_value,
CASE CASE
WHEN ps.prev_orders > 0 WHEN ps.prev_orders > 0
THEN CAST((ps.prev_revenue / ps.prev_orders) AS DECIMAL(15,3)) THEN ROUND((ps.prev_revenue::numeric / ps.prev_orders), 3)
ELSE 0 ELSE 0
END as prev_average_order_value END as prev_average_order_value
FROM CurrentStats cs FROM CurrentStats cs
CROSS JOIN PreviousStats ps CROSS JOIN PreviousStats ps
`); `);
const orderStats = stats[0];
res.json({ res.json({
orders: rows.map(row => ({ orders: rows.map(row => ({
@@ -189,7 +195,7 @@ router.get('/:orderNumber', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// Get order details // Get order details
const [orderRows] = await pool.query(` const { rows: orderRows } = await pool.query(`
SELECT DISTINCT SELECT DISTINCT
o1.order_number, o1.order_number,
o1.customer, o1.customer,
@@ -200,10 +206,10 @@ router.get('/:orderNumber', async (req, res) => {
o1.shipping_address, o1.shipping_address,
o1.billing_address, o1.billing_address,
COUNT(o2.pid) as items_count, COUNT(o2.pid) as items_count,
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount ROUND(SUM(o2.price * o2.quantity)::numeric, 3) as total_amount
FROM orders o1 FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number JOIN orders o2 ON o1.order_number = o2.order_number
WHERE o1.order_number = ? AND o1.canceled = false WHERE o1.order_number = $1 AND o1.canceled = false
GROUP BY GROUP BY
o1.order_number, o1.order_number,
o1.customer, o1.customer,
@@ -220,17 +226,17 @@ router.get('/:orderNumber', async (req, res) => {
} }
// Get order items // Get order items
const [itemRows] = await pool.query(` const { rows: itemRows } = await pool.query(`
SELECT SELECT
o.pid, o.pid,
p.title, p.title,
p.SKU, p.SKU,
o.quantity, o.quantity,
o.price, o.price,
CAST((o.price * o.quantity) AS DECIMAL(15,3)) as total ROUND((o.price * o.quantity)::numeric, 3) as total
FROM orders o FROM orders o
JOIN products p ON o.pid = p.pid JOIN products p ON o.pid = p.pid
WHERE o.order_number = ? AND o.canceled = false WHERE o.order_number = $1 AND o.canceled = false
`, [req.params.orderNumber]); `, [req.params.orderNumber]);
const order = { const order = {

View File

@@ -20,7 +20,7 @@ router.get('/brands', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
console.log('Fetching brands from database...'); console.log('Fetching brands from database...');
const [results] = await pool.query(` const { rows } = await pool.query(`
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
FROM products p FROM products p
JOIN purchase_orders po ON p.pid = po.pid JOIN purchase_orders po ON p.pid = po.pid
@@ -30,8 +30,8 @@ router.get('/brands', async (req, res) => {
ORDER BY COALESCE(p.brand, 'Unbranded') ORDER BY COALESCE(p.brand, 'Unbranded')
`); `);
console.log(`Found ${results.length} brands:`, results.slice(0, 3)); console.log(`Found ${rows.length} brands:`, rows.slice(0, 3));
res.json(results.map(r => r.brand)); res.json(rows.map(r => r.brand));
} catch (error) { } catch (error) {
console.error('Error fetching brands:', error); console.error('Error fetching brands:', error);
res.status(500).json({ error: 'Failed to fetch brands' }); res.status(500).json({ error: 'Failed to fetch brands' });
@@ -50,6 +50,7 @@ router.get('/', async (req, res) => {
const conditions = ['p.visible = true']; const conditions = ['p.visible = true'];
const params = []; const params = [];
let paramCounter = 1;
// Add default replenishable filter unless explicitly showing non-replenishable // Add default replenishable filter unless explicitly showing non-replenishable
if (req.query.showNonReplenishable !== 'true') { if (req.query.showNonReplenishable !== 'true') {
@@ -58,9 +59,10 @@ router.get('/', async (req, res) => {
// Handle search filter // Handle search filter
if (req.query.search) { if (req.query.search) {
conditions.push('(p.title LIKE ? OR p.SKU LIKE ? OR p.barcode LIKE ?)'); conditions.push(`(p.title ILIKE $${paramCounter} OR p.SKU ILIKE $${paramCounter} OR p.barcode ILIKE $${paramCounter})`);
const searchTerm = `%${req.query.search}%`; const searchTerm = `%${req.query.search}%`;
params.push(searchTerm, searchTerm, searchTerm); params.push(searchTerm);
paramCounter++;
} }
// Handle numeric filters with operators // Handle numeric filters with operators
@@ -84,61 +86,69 @@ router.get('/', async (req, res) => {
if (field) { if (field) {
const operator = req.query[`${key}_operator`] || '='; const operator = req.query[`${key}_operator`] || '=';
if (operator === 'between') { if (operator === 'between') {
// Handle between operator
try { try {
const [min, max] = JSON.parse(value); const [min, max] = JSON.parse(value);
conditions.push(`${field} BETWEEN ? AND ?`); conditions.push(`${field} BETWEEN $${paramCounter} AND $${paramCounter + 1}`);
params.push(min, max); params.push(min, max);
paramCounter += 2;
} catch (e) { } catch (e) {
console.error(`Invalid between value for ${key}:`, value); console.error(`Invalid between value for ${key}:`, value);
} }
} else { } else {
// Handle other operators conditions.push(`${field} ${operator} $${paramCounter}`);
conditions.push(`${field} ${operator} ?`);
params.push(parseFloat(value)); params.push(parseFloat(value));
paramCounter++;
} }
} }
}); });
// Handle select filters // Handle select filters
if (req.query.vendor) { if (req.query.vendor) {
conditions.push('p.vendor = ?'); conditions.push(`p.vendor = $${paramCounter}`);
params.push(req.query.vendor); params.push(req.query.vendor);
paramCounter++;
} }
if (req.query.brand) { if (req.query.brand) {
conditions.push('p.brand = ?'); conditions.push(`p.brand = $${paramCounter}`);
params.push(req.query.brand); params.push(req.query.brand);
paramCounter++;
} }
if (req.query.category) { if (req.query.category) {
conditions.push('p.categories LIKE ?'); conditions.push(`p.categories ILIKE $${paramCounter}`);
params.push(`%${req.query.category}%`); params.push(`%${req.query.category}%`);
paramCounter++;
} }
if (req.query.stockStatus && req.query.stockStatus !== 'all') { if (req.query.stockStatus && req.query.stockStatus !== 'all') {
conditions.push('pm.stock_status = ?'); conditions.push(`pm.stock_status = $${paramCounter}`);
params.push(req.query.stockStatus); params.push(req.query.stockStatus);
paramCounter++;
} }
if (req.query.abcClass) { if (req.query.abcClass) {
conditions.push('pm.abc_class = ?'); conditions.push(`pm.abc_class = $${paramCounter}`);
params.push(req.query.abcClass); params.push(req.query.abcClass);
paramCounter++;
} }
if (req.query.leadTimeStatus) { if (req.query.leadTimeStatus) {
conditions.push('pm.lead_time_status = ?'); conditions.push(`pm.lead_time_status = $${paramCounter}`);
params.push(req.query.leadTimeStatus); params.push(req.query.leadTimeStatus);
paramCounter++;
} }
if (req.query.replenishable !== undefined) { if (req.query.replenishable !== undefined) {
conditions.push('p.replenishable = ?'); conditions.push(`p.replenishable = $${paramCounter}`);
params.push(req.query.replenishable === 'true' ? 1 : 0); params.push(req.query.replenishable === 'true');
paramCounter++;
} }
if (req.query.managingStock !== undefined) { if (req.query.managingStock !== undefined) {
conditions.push('p.managing_stock = ?'); conditions.push(`p.managing_stock = $${paramCounter}`);
params.push(req.query.managingStock === 'true' ? 1 : 0); params.push(req.query.managingStock === 'true');
paramCounter++;
} }
// Combine all conditions with AND // Combine all conditions with AND
@@ -151,17 +161,17 @@ router.get('/', async (req, res) => {
LEFT JOIN product_metrics pm ON p.pid = pm.pid LEFT JOIN product_metrics pm ON p.pid = pm.pid
${whereClause} ${whereClause}
`; `;
const [countResult] = await pool.query(countQuery, params); const { rows: [countResult] } = await pool.query(countQuery, params);
const total = countResult[0].total; const total = countResult.total;
// Get available filters // Get available filters
const [categories] = await pool.query( const { rows: categories } = await pool.query(
'SELECT name FROM categories ORDER BY name' 'SELECT name FROM categories ORDER BY name'
); );
const [vendors] = await pool.query( const { rows: vendors } = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor' 'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != \'\' ORDER BY vendor'
); );
const [brands] = await pool.query( const { rows: brands } = await pool.query(
'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand' 'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
); );
@@ -173,7 +183,7 @@ router.get('/', async (req, res) => {
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CAST(c.name AS CHAR(1000)) as path CAST(c.name AS text) as path
FROM categories c FROM categories c
WHERE c.parent_id IS NULL WHERE c.parent_id IS NULL
@@ -183,7 +193,7 @@ router.get('/', async (req, res) => {
c.cat_id, c.cat_id,
c.name, c.name,
c.parent_id, c.parent_id,
CONCAT(cp.path, ' > ', c.name) cp.path || ' > ' || c.name
FROM categories c FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id JOIN category_path cp ON c.parent_id = cp.cat_id
), ),
@@ -210,7 +220,6 @@ router.get('/', async (req, res) => {
FROM products p FROM products p
), ),
product_leaf_categories AS ( product_leaf_categories AS (
-- Find categories that aren't parents to other categories for this product
SELECT DISTINCT pc.cat_id SELECT DISTINCT pc.cat_id
FROM product_categories pc FROM product_categories pc
WHERE NOT EXISTS ( WHERE NOT EXISTS (
@@ -224,7 +233,7 @@ router.get('/', async (req, res) => {
SELECT SELECT
p.*, p.*,
COALESCE(p.brand, 'Unbranded') as brand, COALESCE(p.brand, 'Unbranded') as brand,
GROUP_CONCAT(DISTINCT CONCAT(c.cat_id, ':', c.name)) as categories, string_agg(DISTINCT (c.cat_id || ':' || c.name), ',') as categories,
pm.daily_sales_avg, pm.daily_sales_avg,
pm.weekly_sales_avg, pm.weekly_sales_avg,
pm.monthly_sales_avg, pm.monthly_sales_avg,
@@ -247,83 +256,32 @@ router.get('/', async (req, res) => {
pm.last_received_date, pm.last_received_date,
pm.abc_class, pm.abc_class,
pm.stock_status, pm.stock_status,
pm.turnover_rate, pm.turnover_rate
pm.current_lead_time,
pm.target_lead_time,
pm.lead_time_status,
pm.reorder_qty,
pm.overstocked_amt,
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.pid = pm.pid LEFT JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.pid = pc.pid LEFT JOIN product_categories pc ON p.pid = pc.pid
LEFT JOIN categories c ON pc.cat_id = c.cat_id LEFT JOIN categories c ON pc.cat_id = c.cat_id
LEFT JOIN product_thresholds pt ON p.pid = pt.pid ${whereClause}
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id GROUP BY p.pid, pm.pid
${whereClause ? 'WHERE ' + whereClause.substring(6) : ''}
GROUP BY p.pid
ORDER BY ${sortColumn} ${sortDirection} ORDER BY ${sortColumn} ${sortDirection}
LIMIT ? OFFSET ? LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`; `;
// Add pagination params to the main query params params.push(limit, offset);
const queryParams = [...params, limit, offset]; const { rows: products } = await pool.query(query, params);
console.log('Query:', query.replace(/\s+/g, ' '));
console.log('Params:', queryParams);
const [rows] = await pool.query(query, queryParams);
// Transform the results
const products = rows.map(row => ({
...row,
categories: row.categories ? row.categories.split(',') : [],
price: parseFloat(row.price),
cost_price: parseFloat(row.cost_price),
landing_cost_price: row.landing_cost_price ? parseFloat(row.landing_cost_price) : null,
stock_quantity: parseInt(row.stock_quantity),
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
avg_quantity_per_order: parseFloat(row.avg_quantity_per_order) || 0,
number_of_orders: parseInt(row.number_of_orders) || 0,
first_sale_date: row.first_sale_date || null,
last_sale_date: row.last_sale_date || null,
days_of_inventory: parseFloat(row.days_of_inventory) || 0,
weeks_of_inventory: parseFloat(row.weeks_of_inventory) || 0,
reorder_point: parseFloat(row.reorder_point) || 0,
safety_stock: parseFloat(row.safety_stock) || 0,
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
total_revenue: parseFloat(row.total_revenue) || 0,
inventory_value: parseFloat(row.inventory_value) || 0,
cost_of_goods_sold: parseFloat(row.cost_of_goods_sold) || 0,
gross_profit: parseFloat(row.gross_profit) || 0,
gmroi: parseFloat(row.gmroi) || 0,
avg_lead_time_days: parseFloat(row.avg_lead_time_days) || 0,
last_purchase_date: row.last_purchase_date || null,
last_received_date: row.last_received_date || null,
abc_class: row.abc_class || null,
stock_status: row.stock_status || null,
turnover_rate: parseFloat(row.turnover_rate) || 0,
current_lead_time: parseFloat(row.current_lead_time) || 0,
target_lead_time: parseFloat(row.target_lead_time) || 0,
lead_time_status: row.lead_time_status || null,
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0,
reorder_qty: parseInt(row.reorder_qty) || 0,
overstocked_amt: parseInt(row.overstocked_amt) || 0
}));
res.json({ res.json({
products, products,
pagination: { pagination: {
total, total,
currentPage: page,
pages: Math.ceil(total / limit), pages: Math.ceil(total / limit),
currentPage: page,
limit limit
}, },
filters: { filters: {
categories: categories.map(category => category.name), categories: categories.map(c => c.name),
vendors: vendors.map(vendor => vendor.vendor), vendors: vendors.map(v => v.vendor),
brands: brands.map(brand => brand.brand) brands: brands.map(b => b.brand)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -29,40 +29,46 @@ router.get('/', async (req, res) => {
let whereClause = '1=1'; let whereClause = '1=1';
const params = []; const params = [];
let paramCounter = 1;
if (search) { if (search) {
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)'; whereClause += ` AND (po.po_id ILIKE $${paramCounter} OR po.vendor ILIKE $${paramCounter})`;
params.push(`%${search}%`, `%${search}%`); params.push(`%${search}%`);
paramCounter++;
} }
if (status && status !== 'all') { if (status && status !== 'all') {
whereClause += ' AND po.status = ?'; whereClause += ` AND po.status = $${paramCounter}`;
params.push(Number(status)); params.push(Number(status));
paramCounter++;
} }
if (vendor && vendor !== 'all') { if (vendor && vendor !== 'all') {
whereClause += ' AND po.vendor = ?'; whereClause += ` AND po.vendor = $${paramCounter}`;
params.push(vendor); params.push(vendor);
paramCounter++;
} }
if (startDate) { if (startDate) {
whereClause += ' AND po.date >= ?'; whereClause += ` AND po.date >= $${paramCounter}`;
params.push(startDate); params.push(startDate);
paramCounter++;
} }
if (endDate) { if (endDate) {
whereClause += ' AND po.date <= ?'; whereClause += ` AND po.date <= $${paramCounter}`;
params.push(endDate); params.push(endDate);
paramCounter++;
} }
// Get filtered summary metrics // Get filtered summary metrics
const [summary] = await pool.query(` const { rows: [summary] } = await pool.query(`
WITH po_totals AS ( WITH po_totals AS (
SELECT SELECT
po_id, po_id,
SUM(ordered) as total_ordered, SUM(ordered) as total_ordered,
SUM(received) as total_received, SUM(received) as total_received,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY po_id GROUP BY po_id
@@ -72,26 +78,26 @@ router.get('/', async (req, res) => {
SUM(total_ordered) as total_ordered, SUM(total_ordered) as total_ordered,
SUM(total_received) as total_received, SUM(total_received) as total_received,
ROUND( ROUND(
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 (SUM(total_received)::numeric / NULLIF(SUM(total_ordered), 0)), 3
) as fulfillment_rate, ) as fulfillment_rate,
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value, ROUND(SUM(total_cost)::numeric, 3) as total_value,
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost ROUND(AVG(total_cost)::numeric, 3) as avg_cost
FROM po_totals FROM po_totals
`, params); `, params);
// Get total count for pagination // Get total count for pagination
const [countResult] = await pool.query(` const { rows: [countResult] } = await pool.query(`
SELECT COUNT(DISTINCT po_id) as total SELECT COUNT(DISTINCT po_id) as total
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${whereClause}
`, params); `, params);
const total = countResult[0].total; const total = countResult.total;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const pages = Math.ceil(total / limit); const pages = Math.ceil(total / limit);
// Get recent purchase orders // Get recent purchase orders
const [orders] = await pool.query(` const { rows: orders } = await pool.query(`
WITH po_totals AS ( WITH po_totals AS (
SELECT SELECT
po_id, po_id,
@@ -101,10 +107,10 @@ router.get('/', async (req, res) => {
receiving_status, receiving_status,
COUNT(DISTINCT pid) as total_items, COUNT(DISTINCT pid) as total_items,
SUM(ordered) as total_quantity, SUM(ordered) as total_quantity,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost, ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost,
SUM(received) as total_received, SUM(received) as total_received,
ROUND( ROUND(
SUM(received) / NULLIF(SUM(ordered), 0), 3 (SUM(received)::numeric / NULLIF(SUM(ordered), 0)), 3
) as fulfillment_rate ) as fulfillment_rate
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${whereClause}
@@ -113,7 +119,7 @@ router.get('/', async (req, res) => {
SELECT SELECT
po_id as id, po_id as id,
vendor as vendor_name, vendor as vendor_name,
DATE_FORMAT(date, '%Y-%m-%d') as order_date, to_char(date, 'YYYY-MM-DD') as order_date,
status, status,
receiving_status, receiving_status,
total_items, total_items,
@@ -124,21 +130,21 @@ router.get('/', async (req, res) => {
FROM po_totals FROM po_totals
ORDER BY ORDER BY
CASE CASE
WHEN ? = 'order_date' THEN date WHEN $${paramCounter} = 'order_date' THEN date
WHEN ? = 'vendor_name' THEN vendor WHEN $${paramCounter} = 'vendor_name' THEN vendor
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,3)) WHEN $${paramCounter} = 'total_cost' THEN total_cost
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,3)) WHEN $${paramCounter} = 'total_received' THEN total_received
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED) WHEN $${paramCounter} = 'total_items' THEN total_items
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED) WHEN $${paramCounter} = 'total_quantity' THEN total_quantity
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3)) WHEN $${paramCounter} = 'fulfillment_rate' THEN fulfillment_rate
WHEN ? = 'status' THEN status WHEN $${paramCounter} = 'status' THEN status
ELSE date ELSE date
END ${sortDirection === 'desc' ? 'DESC' : 'ASC'} END ${sortDirection === 'desc' ? 'DESC' : 'ASC'}
LIMIT ? OFFSET ? LIMIT $${paramCounter + 1} OFFSET $${paramCounter + 2}
`, [...params, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, Number(limit), offset]); `, [...params, sortColumn, Number(limit), offset]);
// Get unique vendors for filter options // Get unique vendors for filter options
const [vendors] = await pool.query(` const { rows: vendors } = await pool.query(`
SELECT DISTINCT vendor SELECT DISTINCT vendor
FROM purchase_orders FROM purchase_orders
WHERE vendor IS NOT NULL AND vendor != '' WHERE vendor IS NOT NULL AND vendor != ''
@@ -146,7 +152,7 @@ router.get('/', async (req, res) => {
`); `);
// Get unique statuses for filter options // Get unique statuses for filter options
const [statuses] = await pool.query(` const { rows: statuses } = await pool.query(`
SELECT DISTINCT status SELECT DISTINCT status
FROM purchase_orders FROM purchase_orders
WHERE status IS NOT NULL WHERE status IS NOT NULL
@@ -169,12 +175,12 @@ router.get('/', async (req, res) => {
// Parse summary metrics // Parse summary metrics
const parsedSummary = { const parsedSummary = {
order_count: Number(summary[0].order_count) || 0, order_count: Number(summary.order_count) || 0,
total_ordered: Number(summary[0].total_ordered) || 0, total_ordered: Number(summary.total_ordered) || 0,
total_received: Number(summary[0].total_received) || 0, total_received: Number(summary.total_received) || 0,
fulfillment_rate: Number(summary[0].fulfillment_rate) || 0, fulfillment_rate: Number(summary.fulfillment_rate) || 0,
total_value: Number(summary[0].total_value) || 0, total_value: Number(summary.total_value) || 0,
avg_cost: Number(summary[0].avg_cost) || 0 avg_cost: Number(summary.avg_cost) || 0
}; };
res.json({ res.json({
@@ -202,7 +208,7 @@ router.get('/vendor-metrics', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [metrics] = await pool.query(` const { rows: metrics } = await pool.query(`
WITH delivery_metrics AS ( WITH delivery_metrics AS (
SELECT SELECT
vendor, vendor,
@@ -213,7 +219,7 @@ router.get('/vendor-metrics', async (req, res) => {
CASE CASE
WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED} WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED}
AND received_date IS NOT NULL AND date IS NOT NULL AND received_date IS NOT NULL AND date IS NOT NULL
THEN DATEDIFF(received_date, date) THEN (received_date - date)::integer
ELSE NULL ELSE NULL
END as delivery_days END as delivery_days
FROM purchase_orders FROM purchase_orders
@@ -226,18 +232,18 @@ router.get('/vendor-metrics', async (req, res) => {
SUM(ordered) as total_ordered, SUM(ordered) as total_ordered,
SUM(received) as total_received, SUM(received) as total_received,
ROUND( ROUND(
SUM(received) / NULLIF(SUM(ordered), 0), 3 (SUM(received)::numeric / NULLIF(SUM(ordered), 0)), 3
) as fulfillment_rate, ) as fulfillment_rate,
CAST(ROUND(
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
) AS DECIMAL(15,3)) as avg_unit_cost,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
ROUND( ROUND(
AVG(NULLIF(delivery_days, 0)), 1 (SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2
) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend,
ROUND(
AVG(NULLIF(delivery_days, 0))::numeric, 1
) as avg_delivery_days ) as avg_delivery_days
FROM delivery_metrics FROM delivery_metrics
GROUP BY vendor GROUP BY vendor
HAVING total_orders > 0 HAVING COUNT(DISTINCT po_id) > 0
ORDER BY total_spend DESC ORDER BY total_spend DESC
`); `);
@@ -251,7 +257,7 @@ router.get('/vendor-metrics', async (req, res) => {
fulfillment_rate: Number(vendor.fulfillment_rate) || 0, fulfillment_rate: Number(vendor.fulfillment_rate) || 0,
avg_unit_cost: Number(vendor.avg_unit_cost) || 0, avg_unit_cost: Number(vendor.avg_unit_cost) || 0,
total_spend: Number(vendor.total_spend) || 0, total_spend: Number(vendor.total_spend) || 0,
avg_delivery_days: vendor.avg_delivery_days === null ? null : Number(vendor.avg_delivery_days) avg_delivery_days: Number(vendor.avg_delivery_days) || 0
})); }));
res.json(parsedMetrics); res.json(parsedMetrics);

View File

@@ -0,0 +1,396 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Create reusable uploads directory if it doesn't exist
const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable');
fs.mkdirSync(uploadsDir, { recursive: true });
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
console.log(`Saving reusable image to: ${uploadsDir}`);
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
// Create unique filename with original extension
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// Make sure we preserve the original file extension
let fileExt = path.extname(file.originalname).toLowerCase();
// Ensure there is a proper extension based on mimetype if none exists
if (!fileExt) {
switch (file.mimetype) {
case 'image/jpeg': fileExt = '.jpg'; break;
case 'image/png': fileExt = '.png'; break;
case 'image/gif': fileExt = '.gif'; break;
case 'image/webp': fileExt = '.webp'; break;
default: fileExt = '.jpg'; // Default to jpg
}
}
const fileName = `reusable-${uniqueSuffix}${fileExt}`;
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
cb(null, fileName);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: function (req, file, cb) {
// Accept only image files
const filetypes = /jpeg|jpg|png|gif|webp/;
const mimetype = filetypes.test(file.mimetype);
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
if (mimetype && extname) {
return cb(null, true);
}
cb(new Error('Only image files are allowed'));
}
});
// Get all reusable images
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 reusable_images
ORDER BY created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching reusable images:', error);
res.status(500).json({
error: 'Failed to fetch reusable images',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get images by company or global images
router.get('/by-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');
}
// Get images that are either global or belong to this company
const result = await pool.query(`
SELECT * FROM reusable_images
WHERE is_global = true OR company = $1
ORDER BY created_at DESC
`, [companyId]);
res.json(result.rows);
} catch (error) {
console.error('Error fetching reusable images by company:', error);
res.status(500).json({
error: 'Failed to fetch reusable images by company',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get global images only
router.get('/global', 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 reusable_images
WHERE is_global = true
ORDER BY created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching global reusable images:', error);
res.status(500).json({
error: 'Failed to fetch global reusable images',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get a single image 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 reusable_images
WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Reusable image not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching reusable image:', error);
res.status(500).json({
error: 'Failed to fetch reusable image',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Upload a new reusable image
router.post('/upload', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
}
const { name, is_global, company } = req.body;
// Validate required fields
if (!name) {
return res.status(400).json({ error: 'Image name is required' });
}
// Convert is_global from string to boolean
const isGlobal = is_global === 'true' || is_global === true;
// Validate company is provided for non-global images
if (!isGlobal && !company) {
return res.status(400).json({ error: 'Company is required for non-global images' });
}
// Log file information
console.log('Reusable image uploaded:', {
filename: req.file.filename,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path
});
// Ensure the file exists
const filePath = path.join(uploadsDir, req.file.filename);
if (!fs.existsSync(filePath)) {
return res.status(500).json({ error: 'File was not saved correctly' });
}
// Create URL for the uploaded file
const baseUrl = 'https://inventory.acot.site';
const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Insert record into database
const result = await pool.query(`
INSERT INTO reusable_images (
name,
filename,
file_path,
image_url,
is_global,
company,
mime_type,
file_size
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`, [
name,
req.file.filename,
filePath,
imageUrl,
isGlobal,
isGlobal ? null : company,
req.file.mimetype,
req.file.size
]);
// Return success response with image data
res.status(201).json({
success: true,
image: result.rows[0],
message: 'Image uploaded successfully'
});
} catch (error) {
console.error('Error uploading reusable image:', error);
res.status(500).json({ error: error.message || 'Failed to upload image' });
}
});
// Update image details (name, is_global, company)
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, is_global, company } = req.body;
// Validate required fields
if (!name) {
return res.status(400).json({ error: 'Image name is required' });
}
// Convert is_global from string to boolean if necessary
const isGlobal = typeof is_global === 'string' ? is_global === 'true' : !!is_global;
// Validate company is provided for non-global images
if (!isGlobal && !company) {
return res.status(400).json({ error: 'Company is required for non-global images' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Check if the image exists
const checkResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Reusable image not found' });
}
const result = await pool.query(`
UPDATE reusable_images
SET
name = $1,
is_global = $2,
company = $3
WHERE id = $4
RETURNING *
`, [
name,
isGlobal,
isGlobal ? null : company,
id
]);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating reusable image:', error);
res.status(500).json({
error: 'Failed to update reusable image',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete a reusable image
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');
}
// Get the image data first to get the filename
const imageResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]);
if (imageResult.rows.length === 0) {
return res.status(404).json({ error: 'Reusable image not found' });
}
const image = imageResult.rows[0];
// Delete from database
await pool.query('DELETE FROM reusable_images WHERE id = $1', [id]);
// Delete the file from filesystem
const filePath = path.join(uploadsDir, image.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
res.json({
message: 'Reusable image deleted successfully',
image
});
} catch (error) {
console.error('Error deleting reusable image:', error);
res.status(500).json({
error: 'Failed to delete reusable image',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Check if file exists and permissions
router.get('/check-file/:filename', (req, res) => {
const { filename } = req.params;
// Prevent directory traversal
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(uploadsDir, filename);
try {
// Check if file exists
if (!fs.existsSync(filePath)) {
return res.status(404).json({
error: 'File not found',
path: filePath,
exists: false,
readable: false
});
}
// Check if file is readable
fs.accessSync(filePath, fs.constants.R_OK);
// Get file stats
const stats = fs.statSync(filePath);
return res.json({
filename,
path: filePath,
exists: true,
readable: true,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
permissions: stats.mode.toString(8)
});
} catch (error) {
return res.status(500).json({
error: error.message,
path: filePath,
exists: fs.existsSync(filePath),
readable: false
});
}
});
// Error handling middleware
router.use((err, req, res, next) => {
console.error('Reusable images route error:', err);
res.status(500).json({
error: 'Internal server error',
details: err.message
});
});
module.exports = router;

View File

@@ -0,0 +1,283 @@
const express = require('express');
const { getPool } = require('../utils/db');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, "../../.env") });
const router = express.Router();
// Get all templates
router.get('/', async (req, res) => {
try {
const pool = getPool();
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
SELECT * FROM templates
ORDER BY company ASC, product_type ASC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching templates:', error);
res.status(500).json({
error: 'Failed to fetch templates',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get template by company and product type
router.get('/:company/:productType', async (req, res) => {
try {
const { company, productType } = req.params;
const pool = getPool();
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
SELECT * FROM templates
WHERE company = $1 AND product_type = $2
`, [company, productType]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Template not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching template:', error);
res.status(500).json({
error: 'Failed to fetch template',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Create new template
router.post('/', async (req, res) => {
try {
const {
company,
product_type,
supplier,
msrp,
cost_each,
qty_per_unit,
case_qty,
hts_code,
description,
weight,
length,
width,
height,
tax_cat,
size_cat,
categories,
ship_restrictions
} = req.body;
// Validate required fields
if (!company || !product_type) {
return res.status(400).json({ error: 'Company and Product Type are required' });
}
const pool = getPool();
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO templates (
company,
product_type,
supplier,
msrp,
cost_each,
qty_per_unit,
case_qty,
hts_code,
description,
weight,
length,
width,
height,
tax_cat,
size_cat,
categories,
ship_restrictions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`, [
company,
product_type,
supplier,
msrp,
cost_each,
qty_per_unit,
case_qty,
hts_code,
description,
weight,
length,
width,
height,
tax_cat,
size_cat,
categories,
ship_restrictions
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
});
}
res.status(500).json({
error: 'Failed to create template',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Update template
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
company,
product_type,
supplier,
msrp,
cost_each,
qty_per_unit,
case_qty,
hts_code,
description,
weight,
length,
width,
height,
tax_cat,
size_cat,
categories,
ship_restrictions
} = req.body;
// Validate required fields
if (!company || !product_type) {
return res.status(400).json({ error: 'Company and Product Type are required' });
}
const pool = getPool();
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
UPDATE templates
SET
company = $1,
product_type = $2,
supplier = $3,
msrp = $4,
cost_each = $5,
qty_per_unit = $6,
case_qty = $7,
hts_code = $8,
description = $9,
weight = $10,
length = $11,
width = $12,
height = $13,
tax_cat = $14,
size_cat = $15,
categories = $16,
ship_restrictions = $17
WHERE id = $18
RETURNING *
`, [
company,
product_type,
supplier,
msrp,
cost_each,
qty_per_unit,
case_qty,
hts_code,
description,
weight,
length,
width,
height,
tax_cat,
size_cat,
categories,
ship_restrictions,
id
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Template not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating template:', error);
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('unique constraint')) {
return res.status(409).json({
error: 'Template already exists for this company and product type',
details: error.message
});
}
res.status(500).json({
error: 'Failed to update template',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete template
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const pool = getPool();
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query('DELETE FROM templates WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Template not found' });
}
res.json({ message: 'Template deleted successfully' });
} catch (error) {
console.error('Error deleting template:', error);
res.status(500).json({
error: 'Failed to delete template',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Error handling middleware
router.use((err, req, res, next) => {
console.error('Template route error:', err);
res.status(500).json({
error: 'Internal server error',
details: err.message
});
});
module.exports = router;

View File

@@ -6,7 +6,7 @@ router.get('/', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// Get all vendors with metrics // Get all vendors with metrics
const [vendors] = await pool.query(` const { rows: vendors } = await pool.query(`
SELECT DISTINCT SELECT DISTINCT
p.vendor as name, p.vendor as name,
COALESCE(vm.active_products, 0) as active_products, COALESCE(vm.active_products, 0) as active_products,
@@ -26,16 +26,16 @@ router.get('/', async (req, res) => {
// Get cost metrics for all vendors // Get cost metrics for all vendors
const vendorNames = vendors.map(v => v.name); const vendorNames = vendors.map(v => v.name);
const [costMetrics] = await pool.query(` const { rows: costMetrics } = await pool.query(`
SELECT SELECT
vendor, vendor,
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost, ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders FROM purchase_orders
WHERE status = 'closed' WHERE status = 'closed'
AND cost_price IS NOT NULL AND cost_price IS NOT NULL
AND ordered > 0 AND ordered > 0
AND vendor IN (?) AND vendor = ANY($1)
GROUP BY vendor GROUP BY vendor
`, [vendorNames]); `, [vendorNames]);
@@ -49,26 +49,26 @@ router.get('/', async (req, res) => {
}, {}); }, {});
// Get overall stats // Get overall stats
const [stats] = await pool.query(` const { rows: [stats] } = await pool.query(`
SELECT SELECT
COUNT(DISTINCT p.vendor) as totalVendors, COUNT(DISTINCT p.vendor) as totalVendors,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
THEN p.vendor THEN p.vendor
END) as activeVendors, END) as activeVendors,
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime, COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0))::numeric, 1), 0) as avgLeadTime,
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate, COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0))::numeric, 1), 0) as avgFillRate,
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0))::numeric, 1), 0) as avgOnTimeDelivery
FROM products p FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != '' WHERE p.vendor IS NOT NULL AND p.vendor != ''
`); `);
// Get overall cost metrics // Get overall cost metrics
const [overallCostMetrics] = await pool.query(` const { rows: [overallCostMetrics] } = await pool.query(`
SELECT SELECT
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost, ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders FROM purchase_orders
WHERE status = 'closed' WHERE status = 'closed'
AND cost_price IS NOT NULL AND cost_price IS NOT NULL
@@ -90,13 +90,13 @@ router.get('/', async (req, res) => {
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0) total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
})), })),
stats: { stats: {
totalVendors: parseInt(stats[0].totalVendors), totalVendors: parseInt(stats.totalvendors),
activeVendors: parseInt(stats[0].activeVendors), activeVendors: parseInt(stats.activevendors),
avgLeadTime: parseFloat(stats[0].avgLeadTime), avgLeadTime: parseFloat(stats.avgleadtime),
avgFillRate: parseFloat(stats[0].avgFillRate), avgFillRate: parseFloat(stats.avgfillrate),
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery), avgOnTimeDelivery: parseFloat(stats.avgontimedelivery),
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost), avgUnitCost: parseFloat(overallCostMetrics.avg_unit_cost),
totalSpend: parseFloat(overallCostMetrics[0].total_spend) totalSpend: parseFloat(overallCostMetrics.total_spend)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -3,7 +3,6 @@ const cors = require('cors');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const mysql = require('mysql2/promise');
const { corsMiddleware, corsErrorHandler } = require('./middleware/cors'); const { corsMiddleware, corsErrorHandler } = require('./middleware/cors');
const { initPool } = require('./utils/db'); const { initPool } = require('./utils/db');
const productsRouter = require('./routes/products'); const productsRouter = require('./routes/products');
@@ -16,11 +15,14 @@ const configRouter = require('./routes/config');
const metricsRouter = require('./routes/metrics'); const metricsRouter = require('./routes/metrics');
const vendorsRouter = require('./routes/vendors'); const vendorsRouter = require('./routes/vendors');
const categoriesRouter = require('./routes/categories'); const categoriesRouter = require('./routes/categories');
const testConnectionRouter = require('./routes/test-connection'); const importRouter = require('./routes/import');
const aiValidationRouter = require('./routes/ai-validation');
const templatesRouter = require('./routes/templates');
const aiPromptsRouter = require('./routes/ai-prompts');
const reusableImagesRouter = require('./routes/reusable-images');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = path.resolve(process.cwd(), '.env'); const envPath = '/var/www/html/inventory/.env';
console.log('Current working directory:', process.cwd());
console.log('Looking for .env file at:', envPath); console.log('Looking for .env file at:', envPath);
console.log('.env file exists:', fs.existsSync(envPath)); console.log('.env file exists:', fs.existsSync(envPath));
@@ -33,6 +35,9 @@ try {
DB_HOST: process.env.DB_HOST || 'not set', DB_HOST: process.env.DB_HOST || 'not set',
DB_USER: process.env.DB_USER || 'not set', DB_USER: process.env.DB_USER || 'not set',
DB_NAME: process.env.DB_NAME || 'not set', DB_NAME: process.env.DB_NAME || 'not set',
DB_PASSWORD: process.env.DB_PASSWORD ? '[password set]' : 'not set',
DB_PORT: process.env.DB_PORT || 'not set',
DB_SSL: process.env.DB_SSL || 'not set'
}); });
} catch (error) { } catch (error) {
console.error('Error loading .env file:', error); console.error('Error loading .env file:', error);
@@ -62,61 +67,80 @@ app.use((req, res, next) => {
app.use(corsMiddleware); app.use(corsMiddleware);
// Body parser middleware // Body parser middleware
app.use(express.json()); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Initialize database pool // Initialize database pool and start server
const pool = initPool({ async function startServer() {
host: process.env.DB_HOST, try {
user: process.env.DB_USER, // Initialize database pool
password: process.env.DB_PASSWORD, const pool = await initPool({
database: process.env.DB_NAME, host: process.env.DB_HOST,
waitForConnections: true, user: process.env.DB_USER,
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10, password: process.env.DB_PASSWORD,
queueLimit: 0, database: process.env.DB_NAME,
enableKeepAlive: true, port: process.env.DB_PORT || 5432,
keepAliveInitialDelay: 0 max: process.env.NODE_ENV === 'production' ? 20 : 10,
}); idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
ssl: process.env.DB_SSL === 'true' ? {
rejectUnauthorized: false
} : false
});
// Make pool available to routes // Make pool available to routes
app.locals.pool = pool; app.locals.pool = pool;
// Routes // Set up routes after pool is initialized
app.use('/api/products', productsRouter); app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter); app.use('/api/orders', ordersRouter);
app.use('/api/csv', csvRouter); app.use('/api/csv', csvRouter);
app.use('/api/analytics', analyticsRouter); app.use('/api/analytics', analyticsRouter);
app.use('/api/purchase-orders', purchaseOrdersRouter); app.use('/api/purchase-orders', purchaseOrdersRouter);
app.use('/api/config', configRouter); app.use('/api/config', configRouter);
app.use('/api/metrics', metricsRouter); app.use('/api/metrics', metricsRouter);
app.use('/api/vendors', vendorsRouter); app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);
app.use('/api', testConnectionRouter); app.use('/api/import', importRouter);
app.use('/api/ai-validation', aiValidationRouter);
app.use('/api/templates', templatesRouter);
app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV environment: process.env.NODE_ENV
}); });
}); });
// CORS error handler - must be before other error handlers // CORS error handler - must be before other error handlers
app.use(corsErrorHandler); app.use(corsErrorHandler);
// Error handling middleware - MUST be after routes and CORS error handler // Error handling middleware - MUST be after routes and CORS error handler
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] Error:`, err); console.error(`[${new Date().toISOString()}] Error:`, err);
// Send detailed error in development, generic in production // Send detailed error in development, generic in production
const error = process.env.NODE_ENV === 'production' const error = process.env.NODE_ENV === 'production'
? 'An internal server error occurred' ? 'An internal server error occurred'
: err.message || err; : err.message || err;
res.status(err.status || 500).json({ error }); res.status(err.status || 500).json({ error });
}); });
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Handle uncaught exceptions // Handle uncaught exceptions
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@@ -128,17 +152,6 @@ process.on('unhandledRejection', (reason, promise) => {
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason); console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
}); });
// Test database connection
pool.getConnection()
.then(connection => {
console.log('[Database] Connected successfully');
connection.release();
})
.catch(err => {
console.error('[Database] Error connecting:', err);
process.exit(1);
});
// Initialize client sets for SSE // Initialize client sets for SSE
const importClients = new Set(); const importClients = new Set();
const updateClients = new Set(); const updateClients = new Set();
@@ -189,62 +202,5 @@ const setupSSE = (req, res) => {
} }
}; };
// Update the status endpoint to include reset-metrics // Start the server
app.get('/csv/status', (req, res) => { startServer();
res.json({
active: !!currentOperation,
type: currentOperation?.type || null,
progress: currentOperation ? {
status: currentOperation.status,
operation: currentOperation.operation,
current: currentOperation.current,
total: currentOperation.total,
percentage: currentOperation.percentage
} : null
});
});
// Update progress endpoint mapping
app.get('/csv/:type/progress', (req, res) => {
const { type } = req.params;
if (!['import', 'update', 'reset', 'reset-metrics'].includes(type)) {
res.status(400).json({ error: 'Invalid operation type' });
return;
}
setupSSE(req, res);
});
// Update the cancel endpoint to handle reset-metrics
app.post('/csv/cancel', (req, res) => {
const { operation } = req.query;
if (!currentOperation) {
res.status(400).json({ error: 'No operation in progress' });
return;
}
if (operation && operation.toLowerCase() !== currentOperation.type) {
res.status(400).json({ error: 'Operation type mismatch' });
return;
}
try {
// Handle cancellation based on operation type
if (currentOperation.type === 'reset-metrics') {
// Reset metrics doesn't need special cleanup
currentOperation = null;
res.json({ message: 'Reset metrics cancelled' });
} else {
// ... existing cancellation logic for other operations ...
}
} catch (error) {
console.error('Error during cancellation:', error);
res.status(500).json({ error: 'Failed to cancel operation' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});

View File

@@ -1,9 +1,9 @@
const mysql = require('mysql2/promise'); const { Pool } = require('pg');
let pool; let pool;
function initPool(config) { function initPool(config) {
pool = mysql.createPool(config); pool = new Pool(config);
return pool; return pool;
} }
@@ -11,7 +11,7 @@ async function getConnection() {
if (!pool) { if (!pool) {
throw new Error('Database pool not initialized'); throw new Error('Database pool not initialized');
} }
return pool.getConnection(); return pool.connect();
} }
module.exports = { module.exports = {

File diff suppressed because it is too large Load Diff

View File

@@ -13,55 +13,75 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@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-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4", "@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1", "@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0", "@tanstack/react-query": "^5.66.7",
"@tanstack/react-table": "^8.20.6", "@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2", "@tanstack/virtual-core": "^3.11.2",
"@types/js-levenshtein": "^1.1.3",
"@types/uuid": "^10.0.0",
"axios": "^1.8.1",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0",
"framer-motion": "^12.4.4",
"js-levenshtein": "^1.1.6",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"motion": "^11.18.0", "motion": "^11.18.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.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", "react-router-dom": "^7.1.1",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tanstack": "^1.0.0", "tanstack": "^1.0.0",
"vaul": "^1.1.2" "uuid": "^11.0.5",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14", "@types/lodash": "^4.17.15",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",

View File

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

View File

@@ -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 { MainLayout } from './components/layout/MainLayout';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Products } from './pages/Products'; import { Products } from './pages/Products';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Orders } from './pages/Orders';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { Analytics } from './pages/Analytics'; import { Analytics } from './pages/Analytics';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
@@ -15,61 +14,124 @@ import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting"; import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors'; import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories'; import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/Import';
import { AuthProvider } from './contexts/AuthContext';
import { Protected } from './components/auth/Protected';
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
function App() { function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const token = sessionStorage.getItem('token'); const token = localStorage.getItem('token');
if (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 { try {
const response = await fetch(`${config.authUrl}/protected`, { const response = await fetch(`${config.authUrl}/me`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (!response.ok) { if (!response.ok) {
sessionStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); 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) { } catch (error) {
console.error('Token verification failed:', error); console.error('Token verification failed:', error);
sessionStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); 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(); checkAuth();
}, [navigate]); }, [navigate, location.pathname, location.search]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster richColors position="top-center" /> <AuthProvider>
<Routes> <Toaster richColors position="top-center" />
<Route path="/login" element={<Login />} /> <Routes>
<Route element={ <Route path="/login" element={<Login />} />
<RequireAuth> <Route element={
<MainLayout /> <RequireAuth>
</RequireAuth> <MainLayout />
}> </RequireAuth>
<Route path="/" element={<Dashboard />} /> }>
<Route path="/products" element={<Products />} /> <Route index element={
<Route path="/categories" element={<Categories />} /> <Protected page="dashboard" fallback={<FirstAccessiblePage />}>
<Route path="/vendors" element={<Vendors />} /> <Dashboard />
<Route path="/orders" element={<Orders />} /> </Protected>
<Route path="/purchase-orders" element={<PurchaseOrders />} /> } />
<Route path="/analytics" element={<Analytics />} /> <Route path="/" element={
<Route path="/settings" element={<Settings />} /> <Protected page="dashboard">
<Route path="/forecasting" element={<Forecasting />} /> <Dashboard />
<Route path="*" element={<Navigate to="/" replace />} /> </Protected>
</Route> } />
</Routes> <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>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

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

View File

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

View 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 />;
}

View 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>
```

View 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}</>;
}

View File

@@ -1,8 +1,45 @@
import { Navigate, useLocation } from "react-router-dom" 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 }) { export function RequireAuth({ children }: { children: React.ReactNode }) {
const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true" const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true"
const { token, user, fetchCurrentUser } = useContext(AuthContext)
const location = useLocation() 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) { if (!isLoggedIn) {
// Redirect to login with the current path in the redirect parameter // Redirect to login with the current path in the redirect parameter

View File

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

View File

@@ -9,8 +9,8 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { AlertCircle, AlertTriangle } from "lucide-react"
import config from "@/config" import config from "@/config"
import { format } from "date-fns"
interface Product { interface Product {
pid: number; pid: number;
@@ -24,6 +24,24 @@ interface Product {
lead_time_status: string; 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() { export function LowStockAlerts() {
const { data: products } = useQuery<Product[]>({ const { data: products } = useQuery<Product[]>({
queryKey: ["low-stock"], queryKey: ["low-stock"],

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
LogOut, LogOut,
Users, Users,
Tags, Tags,
FileSpreadsheet,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react"; import { IconCrystalBall } from "@tabler/icons-react";
import { import {
@@ -23,42 +24,56 @@ import {
SidebarSeparator, SidebarSeparator,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useLocation, useNavigate, Link } from "react-router-dom"; import { useLocation, useNavigate, Link } from "react-router-dom";
import { Protected } from "@/components/auth/Protected";
const items = [ const items = [
{ {
title: "Overview", title: "Overview",
icon: Home, icon: Home,
url: "/", url: "/",
permission: "access:dashboard"
}, },
{ {
title: "Products", title: "Products",
icon: Package, icon: Package,
url: "/products", url: "/products",
permission: "access:products"
},
{
title: "Import",
icon: FileSpreadsheet,
url: "/import",
permission: "access:import"
}, },
{ {
title: "Forecasting", title: "Forecasting",
icon: IconCrystalBall, icon: IconCrystalBall,
url: "/forecasting", url: "/forecasting",
permission: "access:forecasting"
}, },
{ {
title: "Categories", title: "Categories",
icon: Tags, icon: Tags,
url: "/categories", url: "/categories",
permission: "access:categories"
}, },
{ {
title: "Vendors", title: "Vendors",
icon: Users, icon: Users,
url: "/vendors", url: "/vendors",
permission: "access:vendors"
}, },
{ {
title: "Purchase Orders", title: "Purchase Orders",
icon: ClipboardList, icon: ClipboardList,
url: "/purchase-orders", url: "/purchase-orders",
permission: "access:purchase_orders"
}, },
{ {
title: "Analytics", title: "Analytics",
icon: BarChart2, icon: BarChart2,
url: "/analytics", url: "/analytics",
permission: "access:analytics"
}, },
]; ];
@@ -67,8 +82,8 @@ export function AppSidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); sessionStorage.removeItem('isLoggedIn');
sessionStorage.removeItem('token');
navigate('/login'); navigate('/login');
}; };
@@ -92,20 +107,26 @@ export function AppSidebar() {
location.pathname === item.url || location.pathname === item.url ||
(item.url !== "/" && location.pathname.startsWith(item.url)); (item.url !== "/" && location.pathname.startsWith(item.url));
return ( return (
<SidebarMenuItem key={item.title}> <Protected
<SidebarMenuButton key={item.title}
asChild permission={item.permission}
tooltip={item.title} fallback={null}
isActive={isActive} >
> <SidebarMenuItem>
<Link to={item.url}> <SidebarMenuButton
<item.icon className="h-4 w-4" /> asChild
<span className="group-data-[collapsible=icon]:hidden"> tooltip={item.title}
{item.title} isActive={isActive}
</span> >
</Link> <Link to={item.url}>
</SidebarMenuButton> <item.icon className="h-4 w-4" />
</SidebarMenuItem> <span className="group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
); );
})} })}
</SidebarMenu> </SidebarMenu>
@@ -116,24 +137,30 @@ export function AppSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <Protected
<SidebarMenuButton permission="access:settings"
asChild fallback={null}
tooltip="Settings" >
isActive={location.pathname === "/settings"} <SidebarMenuItem>
> <SidebarMenuButton
<Link to="/settings"> asChild
<Settings className="h-4 w-4" /> tooltip="Settings"
<span className="group-data-[collapsible=icon]:hidden"> isActive={location.pathname === "/settings"}
Settings >
</span> <Link to="/settings">
</Link> <Settings className="h-4 w-4" />
</SidebarMenuButton> <span className="group-data-[collapsible=icon]:hidden">
</SidebarMenuItem> Settings
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarSeparator />
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>

View File

@@ -1,7 +1,7 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "./AppSidebar"; import { AppSidebar } from "./AppSidebar";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { motion } from "motion/react"; import { motion } from "framer-motion";
export function MainLayout() { export function MainLayout() {
return ( return (

View File

@@ -0,0 +1,38 @@
import merge from "lodash/merge"
import { Steps } from "./steps/Steps"
import { Providers } from "./components/Providers"
import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps"
// Simple empty theme placeholder
export const defaultTheme = {}
export const defaultRSIProps: Partial<RsiProps<any>> = {
autoMapHeaders: true,
autoMapSelectValues: false,
allowInvalidSubmit: true,
autoMapDistance: 2,
isNavigationEnabled: false,
translations: translations,
uploadStepHook: async (value) => value,
selectHeaderStepHook: async (headerValues, data) => ({ headerValues, data }),
matchColumnsStepHook: async (table) => table,
dateFormat: "yyyy-mm-dd", // ISO 8601,
parseRaw: true,
} as const
export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: RsiProps<T>) => {
const props = merge({}, defaultRSIProps, propsWithoutDefaults)
const mergedTranslations =
props.translations !== translations ? merge(translations, props.translations) : translations
return (
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
</ModalWrapper>
</Providers>
)
}

View File

@@ -0,0 +1,104 @@
import type React from "react"
import {
Dialog,
DialogContent,
DialogOverlay,
DialogPortal,
DialogClose,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi"
import { useState, useCallback } from "react"
type Props = {
children: React.ReactNode
isOpen: boolean
onClose: () => void
}
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl, translations } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false)
// Create a handler that resets scroll positions before closing
const handleClose = useCallback(() => {
// Reset all scroll positions in the dialog
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
scrollContainers.forEach(container => {
if (container instanceof HTMLElement) {
// Reset scroll position to top-left
container.scrollTop = 0;
container.scrollLeft = 0;
}
});
// Call the original onClose handler
onClose();
}, [onClose]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
<DialogPortal>
<DialogOverlay className="bg-background/80 backdrop-blur-sm" />
<DialogContent
onEscapeKeyDown={(e) => {
e.preventDefault()
setShowCloseAlert(true)
}}
onPointerDownOutside={(e) => e.preventDefault()}
className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
>
<AlertDialog>
<AlertDialogTrigger asChild>
<DialogClose className="absolute right-4 top-4" onClick={(e) => {
e.preventDefault()
setShowCloseAlert(true)
}} />
</AlertDialogTrigger>
</AlertDialog>
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
{children}
</div>
</DialogContent>
</DialogPortal>
</Dialog>
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>
{translations.alerts.confirmClose.headerTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{translations.alerts.confirmClose.bodyText}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
{translations.alerts.confirmClose.cancelButtonTitle}
</AlertDialogCancel>
<AlertDialogAction onClick={handleClose}>
{translations.alerts.confirmClose.exitButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
</>
)
}

View File

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

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