Compare commits

..

77 Commits

Author SHA1 Message Date
a896cdf223 Implement search functionality for models by label in SpotlightContext and update list data handling to include model actions. Enhance model retrieval logic for improved user interaction in the dashboard.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-25 01:00:33 +01:00
2df25364a0 Add URLs to various database models for improved navigation in the dashboard 2026-06-25 00:50:59 +01:00
75eeed16a0 Add plural labels to various database models 2026-06-25 00:47:12 +01:00
tom
02b0d245ff Adjust margin properties in DashboardNavigation and DashboardWindowButtons components for improved layout consistency and spacing.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-24 00:32:58 +01:00
tom
86d656720c Update dev:electron script in package.json for improved environment variable handling and add msiWrapped configuration for Windows installer.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-24 00:06:00 +01:00
tom
1474b23b1e Fixed windows update installation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-23 23:20:19 +01:00
657c1dd17e Add expandHeight prop to various Dashboard components for improved layout flexibility
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-22 01:48:12 +01:00
2c3cf9d02b Update various components in the Dashboard to include labelWidth adjustments for improved layout consistency. Components modified include NewAppPassword, NewCourierService, NewCourier, NewDocumentJob, NewDocumentSize, NewDocumentTemplate, NewMaterial, NewNoteType, NewProductCategory, NewProductSku, NewTaxRate, and NewVendor. These changes enhance user experience by ensuring better alignment and spacing in forms.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-22 00:41:04 +01:00
2177870fc9 Enhance Filament and FilamentSku models by enforcing required fields for cost-related properties. Update NewFilamentSku and NewFilament components to reflect changes in default values and layout adjustments for better user experience. Modify FilamentInfo to utilize updated object data structure for filament selection.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 23:59:21 +01:00
0e8f4506cc Update padding in DashboardNavigation component to enhance layout consistency and improve user interaction. 2026-06-21 23:45:03 +01:00
8568a8a8ce Refactor AppUpdateProgress to conditionally display progress for the restart stage. Remove unnecessary detail display for the restart status, enhancing clarity in update management.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 23:42:11 +01:00
991c10d48a Refactor DashboardNavigation and WebAppSwitcher components to improve layout and user interaction. Adjust padding in DashboardNavigation and replace Tooltip with KeyboardShortcut in WebAppSwitcher for enhanced accessibility and streamlined code. 2026-06-21 23:40:45 +01:00
8fbcc67230 Refactor Part and Product models to change 'disabled' property to 'readOnly' for margin fields. Implement value calculation for margin based on price and cost when priceMode is 'amount', enhancing data handling and user experience.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 23:30:36 +01:00
156d2a8baf Update Part model to enforce required fields for cost, costWithTax, priceMode, price, margin, priceWithTax, and priceTaxRate. Remove fileName property and improve filter and sorter organization for better clarity and maintainability.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 23:13:25 +01:00
8677691da3 Enhance isInstallComplete function in AppUpdateProgress to recognize additional completion messages, including 'successful' and 'restarting', improving accuracy of installation status detection during updates.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 22:54:58 +01:00
e0b409434d Remove useMessageContext import from NewGCodeFile, NewJob, and NewPrinter components to streamline code and improve maintainability.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 22:36:52 +01:00
77611646aa Remove success message display for GCode file, job, and printer creation in NewGCodeFile, NewJob, and NewPrinter components to streamline user feedback during submission.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
2026-06-21 22:35:17 +01:00
457c427928 Add payment management features to Finance dashboard, including AuthorisePayment, DeclinePayment, and CancelPayment components for handling payment actions. Update PaymentInfo to integrate new modals for these actions and enhance Payment model to support new states and attributes, improving overall payment processing functionality.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
2026-06-21 22:32:51 +01:00
0406c0d0e0 Add Tax Records feature to Finance dashboard, including NewTaxRecord component for creating records and TaxRecordInfo for detailed views. Update routing and sidebar navigation to reflect new structure, ensuring a seamless user experience in managing tax records. 2026-06-21 22:22:46 +01:00
6d1c7cf6ca Remove success message display for job and printer creation in NewJob and NewPrinter components to streamline user feedback during submission.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
2026-06-21 22:18:51 +01:00
00cde6e8c5 Add restart stage to AppUpdateProgress for enhanced update management. Implemented new status handling and UI updates to reflect restart progress, improving user feedback during application updates.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 21:57:42 +01:00
65cc2cd8b5 Implement update dismissal functionality in AppUpdateContext to enhance user experience during updates. Added methods to save and check dismissed updates, and modified update prompt logic to respect user preferences. Update modal behavior adjusted to incorporate dismissal actions. 2026-06-21 21:48:05 +01:00
4501f9936f Refactor ProductSku and PartSku models to enforce required fields for overrideCost, overridePrice, and related properties. Update visibility logic for cost and price fields based on overrides, ensuring consistent data handling across components. Adjust NewProductSku and ProductInfo components to utilize updated default values and improve user experience.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 21:19:49 +01:00
6cd0dca365 Enhance NewPartSku and NewProductSku components to include overrideCost and overridePrice properties, improving visibility options. Update PartInfo to utilize objectFormState for default values, ensuring consistency in data handling. 2026-06-21 21:17:03 +01:00
c7189a21c5 Add install detail to AppUpdateProgress for active installation status, improving user feedback during updates.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 20:25:32 +01:00
5201555cf8 Update AppUpdateProgress to add ellipsis to active download and installation labels for improved user feedback during update processes. 2026-06-21 20:23:06 +01:00
77ef061c15 Refactor UpdateStage component in AppUpdateProgress to streamline progress display and detail rendering, enhancing clarity and organization of update status information. 2026-06-21 20:19:37 +01:00
90204e4f10 Numerious fixes. 2026-06-21 20:19:22 +01:00
619df83ddd Update AppUpdateContext to include availableUpdate in context provider, enhancing update management capabilities.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 20:04:51 +01:00
b93b53fd33 Add macOS and Windows installer modules for improved update handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced macappupdate.js for macOS installer management, including progress tracking and error handling.
- Added winappupdate.js for Windows installer execution with process monitoring.
- Refactored appupdate.js to utilize new installer modules, enhancing code organization and maintainability.
- Improved error messaging and progress reporting during installation processes.
2026-06-21 20:03:18 +01:00
ea7ceea202 Refactor UpdateStage in AppUpdateProgress to improve status resolution and progress display logic, ensuring accurate representation of update stages. 2026-06-21 19:56:45 +01:00
1b8f0b1445 Update DashboardNavigation to include notificationCenterVisible state, enhancing conditional rendering for navigation layout.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 19:46:18 +01:00
6d839ebe9c Enhance NotificationProvider to fetch notifications based on authentication and token presence, improving notification handling logic. 2026-06-21 19:42:01 +01:00
128cf603e7 Enhance Mac installer progress logging by adding console output for better debugging and clarity during installation phases.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 19:38:19 +01:00
6d85d98a50 Update NewDocumentSize component to set default 'infiniteHeight' property to false, enhancing form initialization. 2026-06-21 19:23:28 +01:00
01be1ab29a Update ObjectChildTable to use dynamic 'bordered' property based on 'minimal' prop for improved layout flexibility.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 19:19:54 +01:00
6e11c92862 Introduce dynamic modal width in AppUpdateProvider for improved UI flexibility during software updates. 2026-06-21 19:18:28 +01:00
842ada9f33 Refactor Invoice model by removing duplicate 'paidAt' field definition and ensuring consistent property structure. 2026-06-21 19:15:43 +01:00
8109b8dce2 Added software update menu tag. 2026-06-21 19:14:10 +01:00
8a0bc22124 Enhance AppUpdateProgress component with detailed download and installation stages; introduce modal for error handling and improve progress status management.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 19:08:18 +01:00
71fe6e3462 Refactor Mac installer progress messages for clarity; simplify progress percentage calculation and update installation prompt message.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 18:38:23 +01:00
08890bd5bf Add 'fromType' and 'toType' fields to Invoice model; update NewInvoice component to handle new properties and enhance data handling. 2026-06-21 18:36:19 +01:00
6fd375b4c9 Update columnWidth property to 180 for multiple models to ensure consistent UI layout across the application.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 18:14:54 +01:00
a205f1d1ad Implemented view changes. 2026-06-21 18:11:51 +01:00
478010ea5b Refactor macOS and Windows installer handling to improve progress tracking and error reporting. Introduce functions for parsing installer output and update UI to conditionally display progress based on installation phase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 17:37:35 +01:00
6fe30f680f Update visibleProperties in multiple components to hide _reference field for improved data presentation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 17:14:32 +01:00
95cbff68a7 Add logging functionality for installer process, capturing stdout, stderr, and error details to a timestamped log file in the downloads directory.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 17:08:09 +01:00
0a5563b2a7 Add WebAppSwitcher component and related icons; update config for web app URLs
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 16:57:33 +01:00
3b60543deb Minor fixes.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 16:32:50 +01:00
bc5eea25bd Added a function to sanitize the application name for the macOS installer prompt, improving consistency and security in the installation process.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 16:23:15 +01:00
b6c72bb902 Refactor logo usage in About and NewAppUpdate components to use FarmControlAppIcon component for consistency and maintainability.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 15:39:21 +01:00
78dc567a8f Implemented software update installation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 15:18:04 +01:00
ea57ba65f3 Added 'b' prefix to build numbers.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 13:22:10 +01:00
4363f08f50 Implemented about page.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 13:19:09 +01:00
8901cdbc98 Update build name format in Jenkinsfile to prefix version with 'v' for consistency
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 04:10:58 +01:00
242f67f47e Enhance build name format in Jenkinsfile to include build number for better traceability
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
2026-06-21 04:10:12 +01:00
c613bdeff7 Refactor build name retrieval in Jenkinsfile to use Node.js for parsing package.json version
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
2026-06-21 04:08:17 +01:00
3c5ad2a230 Implement build name setting from package.json version in Jenkinsfile
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
2026-06-21 04:06:54 +01:00
9c69a45ba4 Added MSI support.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 03:42:59 +01:00
7a90c63cb4 Update artifact archiving in Jenkinsfile to target specific Farm Control files
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 03:31:06 +01:00
39111d81c8 Switch to recharts.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
2026-06-21 02:14:22 +01:00
afbab60ab9 Started app update implementation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 01:59:03 +01:00
c581705cdd Added json-schema-traverse dependency to package.json and updated pnpm-lock.yaml accordingly.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-21 00:37:41 +01:00
bb0249976e Fixed margin top. 2026-06-20 23:28:04 +01:00
bef3e47d29 Update RSS Feed label in ExportListButton for clarity
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 23:10:34 +01:00
6cc3fdb0ce Added minimal object child table with modal popup. 2026-06-20 23:10:10 +01:00
27f5989eb8 Added splitter to filter sidebar. 2026-06-20 22:56:07 +01:00
476a01eafb Fixed warnings and bugs. 2026-06-20 22:36:48 +01:00
8f369d777d Fixed product SKUs.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 22:27:17 +01:00
133adece5f Implemented RSS support. 2026-06-20 22:06:02 +01:00
cb24c6ff48 Refactor authentication session management in Electron by replacing keytar with electron-store for session persistence. Update package dependencies and remove keytar references from the workspace configuration. 2026-06-20 03:53:52 +01:00
8adea62218 Include .pkg artifacts in the archive process and modified package.json to add support for pkg targets for both arm64 and x64 architectures, enhancing the build configuration.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 02:43:41 +01:00
349431c310 Added DMG background. 2026-06-20 02:42:37 +01:00
15ce7123a2 Implemented electron view menu. 2026-06-20 02:32:52 +01:00
247bcc0ee5 Refactor cost and price calculations across multiple models to return 0 instead of undefined when no cost or price is provided. This change ensures consistent handling of missing values in Filament, FilamentSku, Invoice, OrderItem, Part, PartSku, and ProductSku models. 2026-06-20 01:41:00 +01:00
f50949a192 Implemented dev support for session status.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 01:34:06 +01:00
49dca65470 Implemented session status.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 00:33:46 +01:00
196 changed files with 5766 additions and 2607 deletions

28
Jenkinsfile vendored
View File

@ -20,7 +20,7 @@ def deploy() {
stage('Build (Ubuntu)') {
nodejs(nodeJSInstallationName: 'Node23') {
sh 'NODE_ENV=production pnpm build:cloudflare'
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production pnpm build:cloudflare"
}
}
@ -70,21 +70,41 @@ def buildOnLabel(label, buildCommand) {
stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh "NODE_ENV=production ${buildCommand}"
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
} else {
bat "set NODE_ENV=production && ${buildCommand}"
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
}
}
}
stage("Archive Artifacts (${label})") {
archiveArtifacts artifacts: 'app_dist/**/*.dmg, app_dist/**/*.exe', fingerprint: true
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
}
}
}
}
def setBuildNameFromPackageVersion() {
node('ubuntu') {
stage('Set Build Name') {
checkout scm
def version
nodejs(nodeJSInstallationName: 'Node23') {
version = sh(
script: "node -p \"require('./package.json').version\"",
returnStdout: true
).trim()
}
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
}
}
}
try {
setBuildNameFromPackageVersion()
parallel(
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),

BIN
assets/dmg/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -2,6 +2,9 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.069972,0,0,0.069972,-2.986098,-11.802594)">
<path d="M242,226C206,232 174.833,248.333 148.5,275C122.167,301.667 106,333 100,369L100,884C106,919.333 122,950 148,976C174,1002 204.333,1018.333 239,1025L243,1026L758,1026L761,1025C796.333,1018.333 826.833,1001.833 852.5,975.5C878.167,949.167 894,918.333 900,883L900,368C894,332.667 877.667,301.667 851,275C824.333,248.333 793,232 757,226L242,226ZM802,325L802,401C731.333,401.667 636.667,401.667 518,401L519,326L802,325ZM481,326C481.667,342.667 482,367.667 482,401L200,401L200,326L481,326ZM518,438L802,438L802,514L518,514L518,438ZM482,438L482,514L200,513L200,439L482,438ZM554,550C590.667,550 645.667,550.333 719,551L801,551L801,627L518,628L518,550L554,550ZM482,550L482,628L200,628L200,551L482,551L482,550ZM518,665L801,665C801.667,677 801.667,695.333 801,720L801,740L519,740L518,665ZM311,700C327.667,700 343.833,703.5 359.5,710.5C375.167,717.5 388.333,727.333 399,740C413.667,756.667 422,776.5 424,799.5C426,822.5 422,844.5 412,865.5C402,886.5 387,902 367,912C348.333,922.667 327.5,927.167 304.5,925.5C281.5,923.833 260.833,916.333 242.5,903C224.167,889.667 212,872.333 206,851C198,831.667 196.333,811.333 201,790C205.667,768.667 215.333,750.167 230,734.5C244.667,718.833 262.333,708.667 283,704C292.333,701.333 301.667,700 311,700Z" style="fill:rgb(255,152,0);fill-rule:nonzero;"/>
<path d="M242,226C206,232 174.833,248.333 148.5,275C122.167,301.667 106,333 100,369L100,884C106,919.333 122,950 148,976C174,1002 204.333,1018.333 239,1025L243,1026L758,1026L761,1025C796.333,1018.333 826.833,1001.833 852.5,975.5C878.167,949.167 894,918.333 900,883L900,368C894,332.667 877.667,301.667 851,275C824.333,248.333 793,232 757,226L242,226Z" style="fill:rgb(255,152,0);fill-rule:nonzero;"/>
<g transform="matrix(14.291431,0,0,14.291431,42.675613,168.675956)">
<path d="M35.778,26.682C38.344,26.682 42.193,26.705 47.324,26.752L53.061,26.752L53.061,32.07L33.259,32.14L33.259,26.682L35.778,26.682ZM53.131,10.938L53.131,16.256C48.187,16.303 41.563,16.303 33.259,16.256L33.329,11.008L53.131,10.938ZM30.74,18.845L30.74,24.163L11.008,24.093L11.008,18.915L30.74,18.845ZM30.67,11.008C30.717,12.175 30.74,13.924 30.74,16.256L11.008,16.256L11.008,11.008L30.67,11.008ZM18.775,37.178C19.941,37.178 21.073,37.423 22.169,37.913C23.265,38.402 24.186,39.09 24.933,39.977C25.959,41.143 26.542,42.531 26.682,44.14C26.822,45.749 26.542,47.289 25.842,48.758C25.143,50.228 24.093,51.312 22.694,52.012C21.387,52.758 19.93,53.073 18.32,52.956C16.711,52.84 15.265,52.315 13.982,51.382C12.699,50.449 11.848,49.236 11.428,47.744C10.868,46.391 10.752,44.968 11.078,43.475C11.405,41.983 12.081,40.688 13.107,39.592C14.134,38.496 15.37,37.784 16.816,37.458C17.469,37.271 18.122,37.178 18.775,37.178ZM33.259,18.845L53.131,18.845L53.131,24.163L33.259,24.163L33.259,18.845ZM30.74,26.752L30.74,32.14L11.008,32.14L11.008,26.752L30.74,26.752ZM33.259,34.729L53.061,34.729C53.108,35.568 53.108,36.851 53.061,38.577L53.061,39.977L33.329,39.977L33.259,34.729Z" style="fill:white;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-82,0)">
<g id="Artboard1" transform="matrix(1,0,0,1,82,0)">
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
<clipPath id="_clip1">
<rect x="0" y="0" width="64" height="64"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(1,0,0,1,-82,0)">
<path d="M114.235,58.85L101,58.85C93.825,58.85 88,53.025 88,45.85L88,18.367C88,11.192 93.825,5.367 101,5.367L127,5.367C134.175,5.367 140,11.192 140,18.367L140,31.684C138.344,30.666 136.522,29.892 134.583,29.414L134.583,18.367C134.583,14.182 131.185,10.784 127,10.784L101,10.784C96.815,10.784 93.417,14.182 93.417,18.367L93.417,45.85C93.417,50.036 96.815,53.434 101,53.434L111.638,53.434C112.218,55.387 113.102,57.211 114.235,58.85Z"/>
</g>
<g transform="matrix(0.121769,0,0,0.121769,14.308464,17.834503)">
<path d="M8.945,187.103L8.945,45C8.945,41.153 10.395,37.687 13.54,34.715C16.57,31.856 19.983,30.533 23.684,30.533L101.541,30.533C105.244,30.533 108.657,31.856 111.685,34.716C114.831,37.688 116.28,41.153 116.28,45L116.28,63.239C116.28,67.084 114.831,70.551 111.685,73.523C108.657,76.383 105.244,77.706 101.541,77.706L59.112,77.706L59.112,99.952L95.825,99.952C99.527,99.952 102.939,101.273 105.967,104.134C109.115,107.105 110.563,110.572 110.563,114.418L110.563,132.658C110.563,136.503 109.116,139.968 105.968,142.941C102.939,145.803 99.527,147.124 95.825,147.124L59.112,147.123L59.112,187.103C59.112,190.807 57.79,194.219 54.928,197.248C51.956,200.394 48.491,201.842 44.646,201.842L23.412,201.842C19.565,201.842 16.1,200.393 13.128,197.247C10.268,194.219 8.946,190.807 8.945,187.103Z"/>
</g>
<g transform="matrix(0.121769,0,0,0.121769,-40.771914,17.834503)">
<path d="M591.369,171.626C580.987,156.298 575.724,137.883 575.724,116.331C575.724,91.129 582.921,70.216 597.085,53.512C611.534,36.472 631.157,27.818 656.07,27.818C677.329,27.818 694.356,32.953 707.26,42.842C720.259,52.803 729.209,67.651 733.791,87.574C734,88.48 734.021,89.418 733.855,90.333C733.839,90.422 733.822,90.511 733.804,90.6C732.171,90.549 730.531,90.524 728.886,90.524C713.323,90.524 698.283,92.793 684.08,97.019C683.789,96.57 683.51,96.096 683.244,95.596C683,95.139 682.809,94.657 682.674,94.157C680.956,87.783 678.047,82.918 673.679,79.732C669.187,76.455 663.286,74.99 656.07,74.99C646.542,74.99 639.4,78.26 634.849,85.227C629.672,93.155 627.252,103.55 627.252,116.331C627.252,120.193 627.479,123.842 627.944,127.277C613.205,139.652 600.767,154.681 591.369,171.626Z"/>
</g>
<g transform="matrix(0.485113,0,0,0.485113,32,32.108844)">
<path d="M66.016,33C66.016,51.203 51.234,66 33,66C14.797,66 0,51.203 0,33C0,14.781 14.797,0 33,0C51.234,0 66.016,14.781 66.016,33ZM27.094,19.5C24.859,19.5 23.438,20.75 23.438,22.734C23.438,24.734 24.859,25.969 27.078,25.969L31.812,25.969L35.847,25.339L31.234,29.375L20.781,39.828C20.031,40.562 19.609,41.578 19.609,42.547C19.609,44.75 21.234,46.219 23.281,46.219C24.375,46.219 25.312,45.844 26.156,45.016L36.516,34.656L40.507,30.084L39.969,34.312L39.969,38.75C39.969,40.953 41.219,42.391 43.234,42.391C45.188,42.391 46.438,40.922 46.438,38.75L46.438,23.797C46.438,20.984 44.812,19.5 42.094,19.5L27.094,19.5Z" style="fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect id="Artboard1" x="0" y="0" width="64" height="64" style="fill:none;"/>
<g id="Artboard11" serif:id="Artboard1">
<g transform="matrix(0.787693,0,0,0.787693,6,6.006144)">
<path d="M32.595,65.998C14.563,65.781 0,51.084 0,33C0,14.781 14.781,0 33,0C51.1,0 65.797,14.564 66.013,32.597C64.003,31.361 61.8,30.409 59.459,29.798C58.946,25.474 57.39,21.467 55.038,18.044C53.252,19.386 50.843,20.54 47.998,21.441C48.59,23.904 48.996,26.56 49.179,29.359C44.485,30.163 40.247,32.317 36.882,35.406L35.437,35.406L35.437,36.848C32.698,39.819 30.69,43.476 29.7,47.53C22.786,47.922 16.949,49.55 13.541,52.117C13.713,51.988 13.892,51.861 14.082,51.738C17.087,54.794 20.831,57.118 25.018,58.434C22.762,56.224 20.842,53.084 19.416,49.278C20.904,48.804 22.542,48.417 24.297,48.124C25.593,51.493 27.274,54.199 29.137,55.808C29.348,57.858 29.815,59.833 30.505,61.701C28.928,61.293 27.428,60.491 26.037,59.349C27.43,60.505 28.934,61.317 30.515,61.729C31.07,63.227 31.769,64.655 32.595,65.998ZM6.474,35.406C6.886,40.076 8.511,44.4 11.044,48.053C12.829,46.735 15.216,45.6 18.025,44.712C17.325,41.847 16.882,38.72 16.753,35.406L6.474,35.406ZM52.484,14.02C52.335,14.133 52.181,14.244 52.014,14.35C48.978,11.231 45.176,8.866 40.916,7.541C43.22,9.772 45.175,12.973 46.615,16.866C45.134,17.339 43.502,17.726 41.755,18.022C40.131,13.743 37.888,10.527 35.437,9.228L35.437,18.669C42.758,18.365 48.953,16.691 52.484,14.02ZM30.697,4.271C28.855,4.713 27.116,5.696 25.532,7.13C27.115,5.709 28.851,4.737 30.688,4.299L30.697,4.271ZM21.697,30.562L30.594,30.562L30.594,23.513C27.88,23.402 25.274,23.103 22.853,22.645C22.247,25.113 21.847,27.793 21.697,30.562ZM40.497,7.15C38.915,5.712 37.177,4.726 35.335,4.279L35.343,4.307C37.181,4.749 38.916,5.726 40.497,7.15ZM10.972,18.051C8.486,21.673 6.889,25.948 6.478,30.562L16.757,30.562C16.893,27.318 17.327,24.253 18.005,21.438C15.168,20.539 12.761,19.388 10.972,18.051ZM25.091,7.542C20.832,8.867 17.03,11.234 13.995,14.355C13.823,14.245 13.663,14.131 13.509,14.015C17.058,16.692 23.268,18.369 30.594,18.67L30.594,9.212C28.138,10.505 25.887,13.729 24.26,18.022C22.509,17.726 20.874,17.338 19.389,16.863C20.831,12.972 22.788,9.772 25.091,7.542ZM22.875,43.507C25.29,43.051 27.889,42.754 30.594,42.643L30.594,35.406L21.687,35.406C21.835,38.238 22.247,40.982 22.875,43.507ZM43.162,22.644C40.747,23.101 38.146,23.399 35.437,23.512L35.437,30.562L44.319,30.562C44.169,27.793 43.769,25.113 43.162,22.644Z"/>
</g>
<g transform="matrix(0.485113,0,0,0.485113,31.974958,31.982526)">
<path d="M66.016,33C66.016,51.203 51.234,66 33,66C14.797,66 0,51.203 0,33C0,14.781 14.797,0 33,0C51.234,0 66.016,14.781 66.016,33ZM27.094,19.5C24.859,19.5 23.438,20.75 23.438,22.734C23.438,24.734 24.859,25.969 27.078,25.969L31.812,25.969L35.847,25.339L31.234,29.375L20.781,39.828C20.031,40.562 19.609,41.578 19.609,42.547C19.609,44.75 21.234,46.219 23.281,46.219C24.375,46.219 25.312,45.844 26.156,45.016L36.516,34.656L40.507,30.084L39.969,34.312L39.969,38.75C39.969,40.953 41.219,42.391 43.234,42.391C45.188,42.391 46.438,40.922 46.438,38.75L46.438,23.797C46.438,20.984 44.812,19.5 42.094,19.5L27.094,19.5Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

18
assets/icons/rssicon.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.069972,0,0,0.069972,-2.986098,-11.802594)">
<path d="M242,226C206,232 174.833,248.333 148.5,275C122.167,301.667 106,333 100,369L100,884C106,919.333 122,950 148,976C174,1002 204.333,1018.333 239,1025L243,1026L758,1026L761,1025C796.333,1018.333 826.833,1001.833 852.5,975.5C878.167,949.167 894,918.333 900,883L900,368C894,332.667 877.667,301.667 851,275C824.333,248.333 793,232 757,226L242,226Z" style="fill:rgb(204,93,21);fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,0.001401,0)">
<g transform="matrix(3.343963,0,0,3.343963,60.268889,206.334409)">
<circle cx="68" cy="189" r="24" style="fill:white;"/>
</g>
<g transform="matrix(3.343963,0,0,3.343963,60.268889,206.334409)">
<path d="M160,213L126,213C126,168.016 88.984,131 44,131L44,97C107.636,97 160,149.364 160,213Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(3.343963,0,0,3.343963,60.268889,206.334409)">
<path d="M184,213C184,136.198 120.802,73 44,73L44,38C140.002,38 219,116.998 219,213L184,213Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -596,3 +596,9 @@ body {
.ant-table-wrapper .ant-table-filter-column {
align-items: center;
}
span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
width: 50px;
min-width: 0;
height: 20px;
}

View File

@ -9,7 +9,6 @@
"private": true,
"homepage": "./",
"dependencies": {
"@ant-design/charts": "^2.6.5",
"@ant-design/icons": "^6.1.0",
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@codemirror/lang-cpp": "^6.0.3",
@ -39,6 +38,7 @@
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.9.1",
"@uiw/react-codemirror": "^4.25.1",
"@vscode/sudo-prompt": "^9.3.2",
"antd": "^5.27.1",
"antd-style": "^3.7.1",
"axios": "^1.11.0",
@ -46,11 +46,13 @@
"cross-env": "^10.0.0",
"dayjs": "^1.11.18",
"dotenv": "^17.2.1",
"electron-store": "^11.0.2",
"gcode-preview": "^2.18.0",
"json-schema-traverse": "^1.0.0",
"keycloak-js": "^26.2.0",
"keytar": "^7.9.0",
"lodash": "^4.17.23",
"loglevel": "^1.9.2",
"nanoid": "^5.1.14",
"online-3d-viewer": "^0.16.0",
"prop-types": "^15.8.1",
"react": "^19.1.1",
@ -59,6 +61,7 @@
"react-markdown": "^10.1.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^7.8.2",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"simplebar-react": "^3.3.2",
"socket.io-client": "*",
@ -77,7 +80,7 @@
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
"start": "serve -s build",
"build": "vite build",
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 cross-env NODE_ENV=development electron public/electron.js\"",
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
"build:electron": "vite build && electron-builder",
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main"
@ -131,6 +134,7 @@
"build": {
"appId": "com.tombutcher.farmcontrol",
"productName": "Farm Control",
"artifactName": "farmcontrol-${version}-${arch}.${ext}",
"icon": "assets/logos/farmcontrolicon.png",
"directories": {
"output": "app_dist"
@ -147,11 +151,23 @@
"arm64"
]
},
{
"target": "pkg",
"arch": [
"arm64"
]
},
{
"target": "dmg",
"arch": [
"x64"
]
},
{
"target": "pkg",
"arch": [
"x64"
]
}
],
"mergeASARs": true,
@ -168,8 +184,45 @@
]
}
},
"dmg": {
"background": "assets/dmg/background.png",
"iconSize": 100,
"window": {
"width": 540,
"height": 380
},
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"pkg": {
"installLocation": "/Applications",
"mustClose": [
"com.tombutcher.farmcontrol"
]
},
"win": {
"target": "nsis"
"target": [
"nsis",
"msiWrapped"
],
"protocols": [
{
"name": "Farm Control Protocol",
"schemes": [
"farmcontrol"
]
}
]
},
"linux": {
"target": "AppImage"
@ -179,6 +232,12 @@
"allowToChangeInstallationDirectory": true,
"include": "scripts/installer.nsh",
"perMachine": true
},
"msiWrapped": {
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
"perMachine": true,
"impersonate": false,
"wrappedInstallerArgs": "/S"
}
}
}

1442
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ allowBuilds:
electron: true
esbuild: true
sharp: true
keytar: true
workerd: true
core-js: true
electron-winstaller: true

263
public/appupdate.js Normal file
View File

@ -0,0 +1,263 @@
import { ipcMain } from 'electron'
import { createWriteStream, promises as fs } from 'fs'
import http from 'http'
import https from 'https'
import os from 'os'
import path from 'path'
import process from 'process'
import { launchMacInstaller } from './macappupdate.js'
import { launchWindowsInstaller } from './winappupdate.js'
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = {
darwin: {
extension: '.pkg',
osMatchers: ['darwin', 'mac', 'macos', 'osx']
},
win32: {
extension: '.msi',
osMatchers: ['win32', 'win', 'windows']
}
}
let runningUpdate = null
const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const normalizeArch = (arch) => {
if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch
}
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase()
if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false
const matchesArch =
artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch)
const matchesOs =
!artifactPlatform ||
target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === 'darwin' && name.includes('mac')) ||
(platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs
}
const selectUpdateArtifact = (
update,
platform = process.platform,
arch = process.arch
) => {
const target = SUPPORTED_TARGETS[platform]
if (!target) {
throw new Error(`App updates are not supported on ${platform}.`)
}
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const matchingArtifact = artifacts.find((artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch)
)
const fallbackArtifact = artifacts.find((artifact) => {
const name = getArtifactName(artifact).toLowerCase()
return artifact?.url && name.endsWith(target.extension)
})
if (!matchingArtifact && !fallbackArtifact) {
throw new Error(
`No ${target.extension} update artifact found for ${platform}/${arch}.`
)
}
return matchingArtifact || fallbackArtifact
}
const sendProgress = (webContents, payload) => {
if (!webContents || webContents.isDestroyed()) return
webContents.send(UPDATE_PROGRESS_CHANNEL, {
timestamp: new Date().toISOString(),
...payload
})
}
const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim()
if (
/cancel/i.test(combined) ||
/did not grant permission/i.test(combined) ||
/user canceled/i.test(combined)
) {
return 'Update installation was cancelled.'
}
if (/incorrect/i.test(combined)) {
return 'The administrator password was incorrect.'
}
return combined || 'Failed to install update.'
}
const installerHelpers = { sendProgress, getInstallErrorMessage }
const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects while downloading update.'))
return
}
const parsedUrl = new URL(url)
const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => {
const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume()
resolve(
getDownloadUrl(
new URL(location, parsedUrl).toString(),
redirectCount + 1
)
)
return
}
resolve({ response, url: parsedUrl.toString() })
})
request.on('error', reject)
})
const downloadArtifact = async (artifact, destinationPath, webContents) => {
const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
}
const totalBytes =
Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0
await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath)
response.on('data', (chunk) => {
downloadedBytes += chunk.length
const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100)
: null
sendProgress(webContents, {
phase: 'downloading',
percent,
downloadedBytes,
totalBytes,
message: totalBytes
? `Downloading update (${percent}%)`
: 'Downloading update'
})
})
response.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
response.pipe(output)
})
}
const restartApp = (app) => {
console.log('[app-update] restarting app')
app.relaunch()
app.exit(0)
}
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents, installerHelpers)
restartApp(app)
return
}
if (process.platform === 'win32') {
await launchWindowsInstaller(
app,
installerPath,
webContents,
installerHelpers
)
restartApp(app)
return
}
throw new Error(`App updates are not supported on ${process.platform}.`)
}
const runAppUpdate = async (app, update, webContents) => {
const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'farmcontrol-update-')
)
const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName)
sendProgress(webContents, {
phase: 'preparing',
percent: 0,
artifact,
message: 'Preparing update download'
})
await downloadArtifact(artifact, installerPath, webContents)
sendProgress(webContents, {
phase: 'downloaded',
percent: 100,
downloadedBytes: null,
totalBytes: null,
artifact,
message: 'Update downloaded'
})
await launchInstallerAndQuit(app, installerPath, webContents)
}
export function setupAppUpdateIPC(app) {
ipcMain.handle('app-update-start', async (event, update) => {
if (runningUpdate) return runningUpdate
const webContents = event.sender
runningUpdate = runAppUpdate(app, update, webContents)
.then(() => ({ ok: true }))
.catch((error) => {
sendProgress(webContents, {
phase: 'error',
percent: null,
message: error?.message || 'Failed to update app.'
})
throw error
})
.finally(() => {
runningUpdate = null
})
return runningUpdate
})
}

View File

@ -1,5 +1,7 @@
import { app, ipcMain, shell, globalShortcut } from 'electron'
import { createRequire } from 'module'
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
import Store from 'electron-store'
import { Buffer } from 'buffer'
import process from 'process'
import {
registerGlobalShortcuts,
setupSpotlightIPC
@ -12,22 +14,66 @@ import {
setupSingleInstanceLock,
handleDeepLinkFromArgv
} from './mainWindow.js'
import { setupAppUpdateIPC } from './appupdate.js'
// --- Keytar-backed auth session storage (main process) ---
const require = createRequire(import.meta.url)
let keytar = null
try {
// keytar is a native module; in some dev environments it may not be built yet.
keytar = require('keytar')
} catch (e) {
console.warn(
'[keytar] Not available; auth session persistence will be disabled.',
e?.message || e
)
// --- Auth session storage (main process) ---
const authStore = new Store({
name: 'auth-session'
})
const AUTH_SESSION_KEY = 'authSession'
const appSettingsStore = new Store({
name: 'settings'
})
const APP_SETTINGS_KEY = 'appSettings'
const serializeAuthSession = (session) => {
const sessionJson = JSON.stringify(session)
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(sessionJson).toString('base64')
return {
encrypted: true,
value: encrypted
}
}
return {
encrypted: false,
value: sessionJson
}
}
const KEYTAR_SERVICE = app.name || 'Farm Control'
const KEYTAR_ACCOUNT = 'authSession'
const deserializeAuthSession = (storedValue) => {
if (!storedValue) return null
if (typeof storedValue === 'object' && storedValue.encrypted === true) {
if (!safeStorage.isEncryptionAvailable()) {
console.warn(
'[auth-session] Encrypted auth session exists but encryption is unavailable on this system.'
)
return null
}
const decrypted = safeStorage.decryptString(
Buffer.from(storedValue.value, 'base64')
)
return JSON.parse(decrypted)
}
if (typeof storedValue === 'object' && typeof storedValue.value === 'string') {
return JSON.parse(storedValue.value)
}
if (typeof storedValue === 'string') {
return JSON.parse(storedValue)
}
// Legacy safety net if the object shape already matches the session structure.
if (typeof storedValue === 'object' && storedValue.token) {
return storedValue
}
return null
}
const gotTheLock = setupSingleInstanceLock(app)
@ -37,6 +83,7 @@ if (gotTheLock) {
registerGlobalShortcuts()
setupSpotlightIPC()
setupMainWindowIPC()
setupAppUpdateIPC(app)
setupMainWindowAppEvents(app)
setupDevAuthServer()
handleDeepLinkFromArgv()
@ -56,38 +103,52 @@ ipcMain.handle('os-info', () => {
ipcMain.handle('auth-session-get', async () => {
try {
if (!keytar) return null
const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
if (!raw) return null
return JSON.parse(raw)
const storedValue = authStore.get(AUTH_SESSION_KEY)
return deserializeAuthSession(storedValue)
} catch (e) {
console.warn('[keytar] Failed to read auth session.', e?.message || e)
console.warn('[auth-session] Failed to read auth session.', e?.message || e)
return null
}
})
ipcMain.handle('auth-session-set', async (event, session) => {
try {
if (!keytar) return false
if (!session || typeof session !== 'object') return false
await keytar.setPassword(
KEYTAR_SERVICE,
KEYTAR_ACCOUNT,
JSON.stringify(session)
)
authStore.set(AUTH_SESSION_KEY, serializeAuthSession(session))
return true
} catch (e) {
console.warn('[keytar] Failed to write auth session.', e?.message || e)
console.warn('[auth-session] Failed to write auth session.', e?.message || e)
return false
}
})
ipcMain.handle('auth-session-clear', async () => {
try {
if (!keytar) return false
return await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
authStore.delete(AUTH_SESSION_KEY)
return true
} catch (e) {
console.warn('[keytar] Failed to clear auth session.', e?.message || e)
console.warn('[auth-session] Failed to clear auth session.', e?.message || e)
return false
}
})
ipcMain.handle('app-settings-get', async () => {
try {
const settings = appSettingsStore.get(APP_SETTINGS_KEY)
return settings && typeof settings === 'object' ? settings : {}
} catch (e) {
console.warn('[app-settings] Failed to read settings.', e?.message || e)
return {}
}
})
ipcMain.handle('app-settings-set', async (event, settings) => {
try {
if (!settings || typeof settings !== 'object') return false
appSettingsStore.set(APP_SETTINGS_KEY, settings)
return true
} catch (e) {
console.warn('[app-settings] Failed to write settings.', e?.message || e)
return false
}
})

181
public/macappupdate.js Normal file
View File

@ -0,0 +1,181 @@
import { promises as fs } from 'fs'
import { createRequire } from 'module'
import path from 'path'
const require = createRequire(import.meta.url)
const sudo = require('@vscode/sudo-prompt')
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const parseMacInstallerProgress = (output) => {
const lines = String(output || '').split('\n')
let percent = null
let message = 'Installing update...'
for (const line of lines) {
if (line.startsWith('installer:PHASE:')) {
message = line.slice('installer:PHASE:'.length).trim() || message
} else if (line.startsWith('installer:STATUS:')) {
const status = line.slice('installer:STATUS:'.length).trim()
if (status) message = status
} else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
if (Number.isFinite(value)) {
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
}
} else if (
line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
!line.startsWith('installer:STATUS:') &&
!line.startsWith('installer:%')
) {
const text = line.slice('installer: '.length).trim()
if (text) message = text
}
}
return { percent, message }
}
const isMacInstallSuccessful = (output) =>
/installer: The (install|upgrade) was successful\./i.test(output)
const isMacInstallFailed = (output) =>
/installer: The install failed/i.test(output)
const buildMacInstallScript = (installerPath, logPath) =>
`sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / -verboseR 2>&1 | /usr/bin/tee ${quoteShellArg(logPath)}`
const startMacInstallerProgressWatch = (logPath, webContents, sendProgress) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
const poll = async () => {
try {
const stat = await fs.stat(logPath)
if (stat.size <= offset) return
const handle = await fs.open(logPath, 'r')
try {
const buffer = Buffer.alloc(stat.size - offset)
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
const { percent, message } = parseMacInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
lastPercent = percent
lastMessage = resolvedMessage
sendProgress(webContents, {
phase: 'installing',
percent,
message: resolvedMessage
})
}
} finally {
await handle.close()
}
} catch (error) {
if (error?.code !== 'ENOENT') {
console.error('[app-update] installer log poll error:', error)
}
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error('[app-update] installer log poll error:', error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
return installerOutput
}
}
export const launchMacInstaller = (
app,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const logPath = path.join(path.dirname(installerPath), 'install.log')
const installScript = buildMacInstallScript(installerPath, logPath)
const promptName = 'farmcontrol'
console.log('[app-update] launching macOS installer:', {
installerPath,
installScript,
logPath,
promptName
})
sendProgress(webContents, {
phase: 'installing',
percent: 0,
message: 'Enter your Mac password when prompted.'
})
app.focus({ steal: true })
app.dock?.show()
const stopProgressWatch = startMacInstallerProgressWatch(
logPath,
webContents,
sendProgress
)
return new Promise((resolve, reject) => {
sudo.exec(installScript, { name: promptName }, async (error, stdout, stderr) => {
const watchedOutput = await stopProgressWatch()
const output = `${stdout || ''}${stderr || ''}` || watchedOutput
await fs.unlink(logPath).catch(() => {})
if (stdout) console.log('[app-update] installer stdout:', stdout)
if (stderr) console.error('[app-update] installer stderr:', stderr)
if (error) {
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
if (isMacInstallFailed(output) || !isMacInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = parseMacInstallerProgress(output)
sendProgress(webContents, {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
console.log('[app-update] installer completed successfully')
resolve()
})
})
}

View File

@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
let win
let sidebarViewMenuSections = []
const PROTOCOL_PREFIX = 'farmcontrol://'
@ -40,6 +41,76 @@ function sendNavigateToRenderer(redirectPath) {
deliver()
}
function toElectronSidebarMenuItems(items = []) {
return items
.map((item) => {
if (item?.type === 'divider') {
return { type: 'separator' }
}
const menuItem = {
label: item.label
}
if (item?.children && Array.isArray(item.children) && item.children.length) {
menuItem.submenu = toElectronSidebarMenuItems(item.children)
} else if (item?.path) {
menuItem.click = () => sendNavigateToRenderer(item.path)
} else {
menuItem.enabled = false
}
return menuItem
})
.filter(Boolean)
}
function buildApplicationMenuTemplate() {
const env = (process.env.NODE_ENV || 'development').trim()
const viewSubmenu = sidebarViewMenuSections.map((section) => ({
label: section.label,
submenu: toElectronSidebarMenuItems(section.items || [])
}))
if (viewSubmenu.length === 0) {
viewSubmenu.push({ label: 'No sidebar items available', enabled: false })
}
if (env === 'development') {
viewSubmenu.push(
{ type: 'separator' },
{
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click: () => {
if (win && !win.isDestroyed()) {
win.webContents.toggleDevTools()
}
}
}
)
}
const template = [
{ role: 'fileMenu' },
{ role: 'editMenu' },
{ label: 'View', submenu: viewSubmenu },
{ role: 'windowMenu' }
]
if (process.platform === 'darwin') {
template.unshift({ role: 'appMenu' })
}
return template
}
function applyApplicationMenu() {
const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate())
Menu.setApplicationMenu(menu)
}
export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
@ -118,29 +189,7 @@ export function createWindow() {
}
})
// Set up custom menu bar
const env = (process.env.NODE_ENV || 'development').trim()
if (env === 'development') {
const devMenu = [
{
label: 'Developer',
submenu: [
{
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click: () => {
win.webContents.toggleDevTools()
}
}
]
}
]
const menu = Menu.buildFromTemplate(devMenu)
Menu.setApplicationMenu(menu)
} else {
Menu.setApplicationMenu(null)
}
applyApplicationMenu()
// For development, load from localhost; for production, load the built index.html
if (process.env.ELECTRON_START_URL) {
@ -157,6 +206,10 @@ export function getWindow() {
return win
}
export function getElectronVersion() {
return process.versions.electron
}
export function setupMainWindowIPC() {
// IPC handler to get window state
ipcMain.handle('window-state', () => {
@ -208,6 +261,18 @@ export function setupMainWindowIPC() {
}
return true
})
ipcMain.handle('set-sidebar-view-menu', (event, sidebarSections) => {
if (!Array.isArray(sidebarSections)) {
return false
}
sidebarViewMenuSections = sidebarSections
applyApplicationMenu()
return true
})
ipcMain.handle('electron-version', () => getElectronVersion())
}
export function setupMainWindowAppEvents(app) {

449
public/winappupdate.js Normal file
View File

@ -0,0 +1,449 @@
import { spawn } from 'child_process'
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
import process from 'process'
const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
const DEBUG_PREFIX = '[app-update][win-progress]'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const debugLog = (message, details) => {
if (details === undefined) {
console.log(`${DEBUG_PREFIX} ${message}`)
return
}
console.log(`${DEBUG_PREFIX} ${message}`, details)
}
const decodeMsiLogBuffer = (buffer) => {
if (!buffer?.length) return ''
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
debugLog('decoded MSI log as UTF-16 LE (BOM)')
return buffer.subarray(2).toString('utf16le')
}
const sample = buffer.subarray(0, Math.min(buffer.length, 64))
const looksUtf16 =
sample.length >= 4 &&
sample.filter((byte) => byte === 0).length > sample.length / 4
if (looksUtf16) {
debugLog('decoded MSI log as UTF-16 LE (heuristic)')
return buffer.toString('utf16le')
}
debugLog('decoded MSI log as UTF-8')
return buffer.toString('utf8')
}
const formatMsiActionName = (actionName) => {
const humanized = String(actionName)
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
.trim()
if (!humanized) return 'Installing update...'
return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...`
}
const parseWindowsInstallerProgress = (output) => {
const lines = String(output || '').split(/\r?\n/)
let percent = null
let message = 'Installing update...'
let totalTicks = 0
let currentTicks = 0
let actionStarts = 0
let actionEnds = 0
const matchedLines = []
for (const line of lines) {
const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./)
if (actionStart) {
actionStarts += 1
message = formatMsiActionName(actionStart[1])
matchedLines.push(`action-start:${actionStart[1]}`)
}
const doingAction = line.match(/Doing action:\s*(.+)$/)
if (doingAction && !actionStart) {
message = formatMsiActionName(doingAction[1])
matchedLines.push(`doing-action:${doingAction[1]}`)
}
if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) {
actionEnds += 1
matchedLines.push('action-ended')
}
const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/)
if (progressReset) {
totalTicks = Number.parseInt(progressReset[1], 10) || 0
currentTicks = 0
matchedLines.push(`progress-reset:${totalTicks}`)
}
const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/)
if (progressIncrement) {
currentTicks += Number.parseInt(progressIncrement[1], 10) || 0
matchedLines.push(`progress-increment:${progressIncrement[1]}`)
}
const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/)
if (progressAddition) {
totalTicks += Number.parseInt(progressAddition[1], 10) || 0
matchedLines.push(`progress-addition:${progressAddition[1]}`)
}
if (/Installation success or error status:\s*0\b/.test(line)) {
percent = 100
message = 'Installation complete. Restarting Farm Control...'
matchedLines.push('install-success')
}
}
if (percent !== 100) {
if (totalTicks > 0) {
percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100))
} else if (actionStarts > 0) {
percent = Math.min(
95,
Math.max(5, Math.round((actionEnds / actionStarts) * 90))
)
}
}
return {
percent,
message,
stats: {
lineCount: lines.length,
actionStarts,
actionEnds,
totalTicks,
currentTicks,
matchedLines: matchedLines.slice(-8)
}
}
}
const isWindowsInstallSuccessful = (output) =>
/Installation success or error status:\s*0\b/.test(output) ||
/MainEngineThread is returning 0\b/.test(output)
const isWindowsInstallFailed = (output) =>
/Installation success or error status:\s*[1-9]\d*\b/.test(output) ||
/MainEngineThread is returning [1-9]\d*\b/.test(output)
const isValidMsiPackage = async (filePath) => {
const handle = await fs.open(filePath, 'r')
try {
const header = Buffer.alloc(MSI_OLE_HEADER.length)
await handle.read(header, 0, header.length, 0)
return header.equals(MSI_OLE_HEADER)
} finally {
await handle.close()
}
}
const prepareInstallerPath = async (installerPath) => {
const fileName = path.basename(installerPath)
const updateDir = path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'FarmControl',
'Updates'
)
await fs.mkdir(updateDir, { recursive: true })
const stablePath = path.join(updateDir, fileName)
await fs.copyFile(installerPath, stablePath)
// Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec.
const resolvedPath = await fs.realpath(stablePath)
const stats = await fs.stat(resolvedPath)
if (!stats.isFile() || stats.size === 0) {
throw new Error('Update installer file is missing or empty.')
}
if (!(await isValidMsiPackage(resolvedPath))) {
throw new Error(
'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.'
)
}
return resolvedPath
}
const startWindowsInstallerProgressWatch = (
logPath,
webContents,
sendProgress
) => {
let installerOutput = ''
let lastLogSize = 0
let lastPercent = null
let lastMessage = null
let pollCount = 0
const poll = async () => {
pollCount += 1
try {
const stat = await fs.stat(logPath)
if (stat.size === 0) {
debugLog(`poll #${pollCount}: log exists but is empty`, { logPath })
return
}
if (stat.size === lastLogSize) {
debugLog(`poll #${pollCount}: no new log data`, {
logPath,
size: stat.size
})
return
}
const buffer = Buffer.alloc(stat.size)
const handle = await fs.open(logPath, 'r')
try {
await handle.read(buffer, 0, stat.size, 0)
} finally {
await handle.close()
}
lastLogSize = stat.size
installerOutput = decodeMsiLogBuffer(buffer)
const { percent, message, stats } =
parseWindowsInstallerProgress(installerOutput)
const resolvedPercent = percent ?? lastPercent ?? 0
const resolvedMessage = message || 'Installing update...'
debugLog(`poll #${pollCount}: parsed installer log`, {
logPath,
size: stat.size,
textLength: installerOutput.length,
preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '),
parsed: stats,
resolvedPercent,
resolvedMessage
})
if (
resolvedPercent !== lastPercent ||
resolvedMessage !== lastMessage
) {
debugLog(`poll #${pollCount}: sending progress update`, {
percent: resolvedPercent,
message: resolvedMessage
})
lastPercent = resolvedPercent
lastMessage = resolvedMessage
sendProgress(webContents, {
phase: 'installing',
percent: resolvedPercent,
message: resolvedMessage
})
} else {
debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, {
percent: resolvedPercent,
message: resolvedMessage
})
}
} catch (error) {
if (error?.code === 'ENOENT') {
debugLog(`poll #${pollCount}: log file not created yet`, { logPath })
return
}
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
debugLog('stopped progress watch', {
logPath,
finalSize: lastLogSize,
textLength: installerOutput.length,
pollCount
})
return installerOutput
}
}
export const launchWindowsInstaller = async (
app,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const resolvedPath = await prepareInstallerPath(installerPath)
const logPath = path.join(path.dirname(resolvedPath), 'install.log')
debugLog('prepared installer', {
installerPath,
resolvedPath,
logPath
})
sendProgress(webContents, {
phase: 'installing',
percent: 0,
message: 'Installing update...'
})
await fs.unlink(logPath).catch(() => {})
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
webContents,
sendProgress
)
return new Promise((resolve, reject) => {
let processOutput = ''
const startedAt = Date.now()
const installerArgs = [
'/i',
resolvedPath,
'/qn',
'/norestart',
'/L*v!',
logPath
]
debugLog('spawning msiexec', {
args: installerArgs,
elapsedMs: Date.now() - startedAt
})
const installerProcess = spawn('msiexec.exe', installerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
})
installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le')
processOutput += text
debugLog('msiexec stderr chunk', {
length: text.length,
preview: text.slice(0, 200)
})
})
installerProcess.on('spawn', () => {
debugLog('msiexec spawned', {
pid: installerProcess.pid,
elapsedMs: Date.now() - startedAt
})
})
installerProcess.on('error', async (error) => {
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
const watchedOutput = await stopProgressWatch()
debugLog('installer spawn failed', {
watchedOutputLength: watchedOutput.length,
processOutputLength: processOutput.length
})
const message = error?.message || 'Failed to start update installer.'
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(error)
})
installerProcess.on('exit', async (code, signal) => {
const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', {
code,
signal,
elapsedMs: Date.now() - startedAt,
watchedOutputLength: watchedOutput.length,
processOutputLength: processOutput.length,
parsed: finalParse.stats,
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
})
debugLog('keeping install log', { logPath })
if (code !== 0) {
const message = getInstallErrorMessage(null, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const succeeded =
isWindowsInstallSuccessful(output) ||
(code === 0 && !isWindowsInstallFailed(output))
debugLog('install success evaluation', {
succeeded,
isSuccessful: isWindowsInstallSuccessful(output),
isFailed: isWindowsInstallFailed(output),
exitCode: code
})
if (!succeeded) {
const message = getInstallErrorMessage(null, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = finalParse
sendProgress(webContents, {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
debugLog('installer completed successfully')
resolve()
})
})
}

View File

@ -28,9 +28,11 @@ import { ApiServerProvider } from './components/Dashboard/context/ApiServerConte
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
import AuthCallback from './components/App/AuthCallback.jsx'
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
import AuthLaunch from './components/App/AppLaunch.jsx'
import {
ProductionRoutes,
@ -75,71 +77,80 @@ const AppContent = () => {
<PrintServerProvider>
<ApiServerProvider>
<MessageProvider>
<NotificationProvider>
<SpotlightProvider>
<ActionsModalProvider>
<Routes>
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route
path='/'
element={
<PrivateRoute
component={() => (
<Navigate
to='/dashboard/production/overview'
replace
/>
)}
/>
}
/>
<Route
path='/auth/callback'
element={<AuthCallback />}
/>
<Route
path='/auth/marketplace/callback'
element={<MarketplaceAuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}
/>
<Route
path='/dashboard'
element={
<PrivateRoute component={() => <Dashboard />} />
}
>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{DeveloperRoutes}
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/>
}
/>
</Routes>
</ActionsModalProvider>
</SpotlightProvider>
</NotificationProvider>
<AppUpdateProvider>
<NotificationProvider>
<SpotlightProvider>
<ActionsModalProvider>
<Routes>
<Route
path='/applaunch'
element={<AuthLaunch />}
/>
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route
path='/'
element={
<PrivateRoute
component={() => (
<Navigate
to='/dashboard/production/overview'
replace
/>
)}
/>
}
/>
<Route
path='/auth/callback'
element={<AuthCallback />}
/>
<Route
path='/auth/marketplace/callback'
element={<MarketplaceAuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}
/>
<Route
path='/dashboard'
element={
<PrivateRoute
component={() => <Dashboard />}
/>
}
>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{DeveloperRoutes}
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/>
}
/>
</Routes>
</ActionsModalProvider>
</SpotlightProvider>
</NotificationProvider>
</AppUpdateProvider>
</MessageProvider>
</ApiServerProvider>
</PrintServerProvider>

View File

@ -0,0 +1,204 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Flex, Card, Alert, Button } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { customAlphabet } from 'nanoid'
import AuthParticles from './AppParticles'
import FarmControlLogo from '../Logos/FarmControlLogo'
import ExclamationOctagonIcon from '../Icons/ExclamationOctagonIcon'
import CheckIcon from '../Icons/CheckIcon'
import ReloadIcon from '../Icons/ReloadIcon'
import { ApiServerContext } from '../Dashboard/context/ApiServerContext'
const createLaunchSession = customAlphabet(
'01abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
32
)
const AuthLaunch = () => {
const location = useLocation()
const hasRedirected = useRef(false)
const startTimeoutRef = useRef(null)
const pollTimeoutRef = useRef(null)
const { getAppLaunchSession } = useContext(ApiServerContext)
const [launchError, setLaunchError] = useState(false)
const [launchErrorMessage, setLaunchErrorMessage] = useState('')
const [launchSuccess, setLaunchSuccess] = useState(false)
const handleRefresh = () => {
window.location.reload()
}
useEffect(() => {
let cancelled = false
const redirect = new URLSearchParams(location.search).get('redirect')
const redirectType = new URLSearchParams(location.search).get(
'redirectType'
)
if (!redirect) {
setLaunchError(true)
setLaunchErrorMessage('No redirect provided!')
return
}
if (!redirect || hasRedirected.current) {
return
}
startTimeoutRef.current = setTimeout(() => {
if (cancelled) {
return
}
hasRedirected.current = true
const launchSession = createLaunchSession()
let launchCheckCount = 0
setLaunchError(false)
setLaunchErrorMessage('')
setLaunchSuccess(false)
let redirectWithLaunchSession = redirect
try {
const redirectUrl = new URL(redirect, window.location.origin)
redirectUrl.searchParams.set('launchSession', launchSession)
redirectWithLaunchSession = redirectUrl.toString()
} catch {
const hasQuery = redirect.includes('?')
const separator = hasQuery ? '&' : '?'
redirectWithLaunchSession = `${redirect}${separator}launchSession=${encodeURIComponent(
launchSession
)}`
}
const link = document.createElement('a')
link.href = redirectWithLaunchSession
link.style.display = 'none'
if (redirectType === 'app-localhost') {
link.addEventListener('click', (event) => {
event.preventDefault()
window.open(
redirectWithLaunchSession,
'farmcontrol-launch',
'width=480,height=640,menubar=no,toolbar=no,location=yes,status=no,resizable=yes,scrollbars=yes'
)
})
}
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
const checkLaunchSession = async () => {
launchCheckCount += 1
let launchComplete = false
try {
const launchStatus = await getAppLaunchSession(launchSession)
launchComplete = launchStatus?.complete === true
} catch {
launchComplete = false
}
if (cancelled) {
return
}
if (launchComplete) {
setLaunchSuccess(true)
return
}
if (launchCheckCount >= 10) {
setLaunchError(true)
setLaunchErrorMessage('Failed to open Farm Control.')
return
}
pollTimeoutRef.current = setTimeout(() => {
checkLaunchSession()
}, 1000)
}
checkLaunchSession()
}, 0)
return () => {
cancelled = true
hasRedirected.current = false
if (startTimeoutRef.current) {
clearTimeout(startTimeoutRef.current)
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current)
}
}
}, [getAppLaunchSession, location.search])
return (
<div
style={{
backgroundColor: 'black'
}}
>
<div
style={{
backgroundColor: 'black',
minHeight: '100vh',
transition: 'opacity 0.5s ease-in-out',
opacity: 1
}}
>
<AuthParticles />
<Flex
align='center'
justify='center'
vertical
style={{ height: '100vh' }}
gap={'large'}
>
<Card style={{ borderRadius: 20 }}>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex>
</Card>
{!launchError && !launchSuccess && (
<Alert
message='Launching Farm Control please wait...'
icon={<LoadingOutlined />}
showIcon
/>
)}
{launchError && (
<>
<Alert
message={launchErrorMessage}
icon={<ExclamationOctagonIcon />}
type='error'
showIcon
/>
<Button
icon={<ReloadIcon />}
onClick={handleRefresh}
size='large'
/>
</>
)}
{launchSuccess && (
<Alert
message='Launch successful! You may now close this window.'
icon={<CheckIcon />}
type='success'
showIcon
/>
)}
</Flex>
</div>
</div>
)
}
export default AuthLaunch

View File

@ -1,50 +1,14 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import { Typography } from 'antd'
const { Text } = Typography
const items = [
{
key: 'sessionstorage',
label: 'Session Storage',
icon: <Text>🗃</Text>,
path: '/dashboard/developer/sessionstorage'
},
{
key: 'authcontextdebug',
label: 'Auth Debug',
icon: <Text>🔐</Text>,
path: '/dashboard/developer/authcontextdebug'
},
{
key: 'apicontextdebug',
label: 'API Debug',
icon: <Text>🌐</Text>,
path: '/dashboard/developer/apicontextdebug'
}
]
const routeKeyMap = {
'/dashboard/developer/sessionstorage': 'sessionstorage',
'/dashboard/developer/authcontextdebug': 'authcontextdebug',
'/dashboard/developer/apicontextdebug': 'apicontextdebug'
}
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
const DeveloperSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'sessionstorage'
})()
const includeDev = import.meta.env.MODE === 'development'
const items = getSidebarItems('developer', { includeDev })
const selectedKey = getSidebarSelectedKey('developer', location.pathname, {
includeDev
})
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}

View File

@ -1,51 +1,14 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import InvoiceIcon from '../../Icons/InvoiceIcon'
import PaymentIcon from '../../Icons/PaymentIcon'
import FinanceIcon from '../../Icons/FinanceIcon'
const items = [
{
key: 'overview',
label: 'Overview',
icon: <FinanceIcon />,
path: '/dashboard/finance/overview'
},
{ type: 'divider' },
{
key: 'invoices',
label: 'Invoices',
icon: <InvoiceIcon />,
path: '/dashboard/finance/invoices'
},
{
key: 'payments',
label: 'Payments',
icon: <PaymentIcon />,
path: '/dashboard/finance/payments'
}
]
const routeKeyMap = {
'/dashboard/finance/overview': 'overview',
'/dashboard/finance/invoices': 'invoices',
'/dashboard/finance/payments': 'payments'
}
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
const FinanceSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'overview'
})()
const includeDev = import.meta.env.MODE === 'development'
const items = getSidebarItems('finance', { includeDev })
const selectedKey = getSidebarSelectedKey('finance', location.pathname, {
includeDev
})
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}

View File

@ -80,6 +80,7 @@ const Invoices = () => {
type='invoice'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -34,6 +34,8 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
order: true,
to: true,
from: true,
toType: true,
fromType: true,
issuedAt: true,
dueAt: true
}}
@ -64,7 +66,9 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
sentAt: false,
paidAt: false,
cancelledAt: false,
overdueAt: false
overdueAt: false,
acknowledgedAt: false,
postedAt: false
}}
isEditing={false}
objectData={objectData}

View File

@ -80,6 +80,7 @@ const Payments = () => {
type='payment'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal
@ -105,4 +106,3 @@ const Payments = () => {
}
export default Payments

View File

@ -0,0 +1,46 @@
import { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../../context/ApiServerContext'
import { message } from 'antd'
import MessageDialogView from '../../common/MessageDialogView.jsx'
const AuthorisePayment = ({ onOk, objectData }) => {
const [authoriseLoading, setAuthoriseLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleAuthorise = async () => {
setAuthoriseLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Payment',
'authorise'
)
if (result) {
message.success('Payment authorised successfully')
onOk(result)
}
} catch (error) {
console.error('Error authorising payment:', error)
} finally {
setAuthoriseLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to authorise this payment?'}
description={`Authorising payment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to authorised.`}
onOk={handleAuthorise}
okText='Authorise'
okLoading={authoriseLoading}
/>
)
}
AuthorisePayment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default AuthorisePayment

View File

@ -0,0 +1,46 @@
import { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../../context/ApiServerContext'
import { message } from 'antd'
import MessageDialogView from '../../common/MessageDialogView.jsx'
const CancelPayment = ({ onOk, objectData }) => {
const [cancelLoading, setCancelLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleCancel = async () => {
setCancelLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Payment',
'cancel'
)
if (result) {
message.success('Payment cancelled successfully')
onOk(result)
}
} catch (error) {
console.error('Error cancelling payment:', error)
} finally {
setCancelLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to cancel this payment?'}
description={`Cancelling payment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to cancelled.`}
onOk={handleCancel}
okText='Cancel'
okLoading={cancelLoading}
/>
)
}
CancelPayment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default CancelPayment

View File

@ -0,0 +1,46 @@
import { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../../context/ApiServerContext'
import { message } from 'antd'
import MessageDialogView from '../../common/MessageDialogView.jsx'
const DeclinePayment = ({ onOk, objectData }) => {
const [declineLoading, setDeclineLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleDecline = async () => {
setDeclineLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Payment',
'decline'
)
if (result) {
message.success('Payment declined successfully')
onOk(result)
}
} catch (error) {
console.error('Error declining payment:', error)
} finally {
setDeclineLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to decline this payment?'}
description={`Declining payment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to declined.`}
onOk={handleDecline}
okText='Decline'
okLoading={declineLoading}
/>
)
}
DeclinePayment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default DeclinePayment

View File

@ -53,6 +53,8 @@ const NewPayment = ({ onOk, reset, defaultValues }) => {
updatedAt: false,
_reference: false,
postedAt: false,
authorisedAt: false,
declinedAt: false,
cancelledAt: false
}}
isEditing={false}

View File

@ -24,6 +24,9 @@ import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import { getModelByName } from '../../../../database/ObjectModels.js'
import PostPayment from './PostPayment.jsx'
import AuthorisePayment from './AuthorisePayment.jsx'
import DeclinePayment from './DeclinePayment.jsx'
import CancelPayment from './CancelPayment.jsx'
const log = loglevel.getLogger('PaymentInfo')
log.setLevel(config.logLevel)
@ -48,6 +51,9 @@ const PaymentInfo = () => {
objectData: {}
})
const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [authorisePaymentOpen, setAuthorisePaymentOpen] = useState(false)
const [declinePaymentOpen, setDeclinePaymentOpen] = useState(false)
const [cancelPaymentOpen, setCancelPaymentOpen] = useState(false)
const actions = {
edit: () => {
@ -69,6 +75,18 @@ const PaymentInfo = () => {
post: () => {
setPostPaymentOpen(true)
return true
},
authorise: () => {
setAuthorisePaymentOpen(true)
return true
},
decline: () => {
setDeclinePaymentOpen(true)
return true
},
cancel: () => {
setCancelPaymentOpen(true)
return true
}
}
@ -234,6 +252,60 @@ const PaymentInfo = () => {
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={authorisePaymentOpen}
onCancel={() => {
setAuthorisePaymentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<AuthorisePayment
onOk={() => {
setAuthorisePaymentOpen(false)
objectFormRef?.current.handleFetchObject()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={declinePaymentOpen}
onCancel={() => {
setDeclinePaymentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<DeclinePayment
onOk={() => {
setDeclinePaymentOpen(false)
objectFormRef?.current.handleFetchObject()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={cancelPaymentOpen}
onCancel={() => {
setCancelPaymentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<CancelPayment
onOk={() => {
setCancelPaymentOpen(false)
objectFormRef?.current.handleFetchObject()
}}
objectData={objectFormState.objectData}
/>
</Modal>
</>
)
}

View File

@ -80,6 +80,7 @@ const TaxRecords = () => {
type='taxRecord'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -86,6 +86,7 @@ const FilamentStocks = () => {
type='filamentStock'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -1,118 +1,14 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import StockAuditIcon from '../../Icons/StockAuditIcon'
import PurchaseOrderIcon from '../../Icons/PurchaseOrderIcon'
import ShipmentIcon from '../../Icons/ShipmentIcon'
import OrderItemIcon from '../../Icons/OrderItemIcon'
import InventoryIcon from '../../Icons/InventoryIcon'
import StockLocationIcon from '../../Icons/StockLocationIcon'
import StockTransferIcon from '../../Icons/StockTransferIcon'
const items = [
{
key: 'overview',
label: 'Overview',
icon: <InventoryIcon />,
path: '/dashboard/inventory/overview'
},
{ type: 'divider' },
{
key: 'filamentstocks',
label: 'Filament Stocks',
icon: <FilamentStockIcon />,
path: '/dashboard/inventory/filamentstocks'
},
{
key: 'partstocks',
label: 'Part Stocks',
icon: <PartStockIcon />,
path: '/dashboard/inventory/partstocks'
},
{
key: 'productstocks',
label: 'Product Stocks',
icon: <ProductStockIcon />,
path: '/dashboard/inventory/productstocks'
},
{ type: 'divider' },
{
key: 'purchaseorders',
label: 'Purchase Orders',
icon: <PurchaseOrderIcon />,
path: '/dashboard/inventory/purchaseorders'
},
{ type: 'divider' },
{
key: 'orderitems',
label: 'Order Items',
icon: <OrderItemIcon />,
path: '/dashboard/inventory/orderitems'
},
{
key: 'shipments',
label: 'Shipments',
icon: <ShipmentIcon />,
path: '/dashboard/inventory/shipments'
},
{ type: 'divider' },
{
key: 'stocklocations',
label: 'Stock Locations',
icon: <StockLocationIcon />,
path: '/dashboard/inventory/stocklocations'
},
{
key: 'stockevents',
label: 'Stock Events',
icon: <StockEventIcon />,
path: '/dashboard/inventory/stockevents'
},
{
key: 'stockaudits',
label: 'Stock Audits',
icon: <StockAuditIcon />,
path: '/dashboard/inventory/stockaudits'
},
{
key: 'stocktransfers',
label: 'Stock Transfers',
icon: <StockTransferIcon />,
path: '/dashboard/inventory/stocktransfers'
}
]
const routeKeyMap = {
'/dashboard/inventory/overview': 'overview',
'/dashboard/inventory/filamentstocks': 'filamentstocks',
'/dashboard/inventory/partstocks': 'partstocks',
'/dashboard/inventory/productstocks': 'productstocks',
'/dashboard/inventory/stocklocations': 'stocklocations',
'/dashboard/inventory/stocktransfers': 'stocktransfers',
'/dashboard/inventory/stockevents': 'stockevents',
'/dashboard/inventory/stockaudits': 'stockaudits',
'/dashboard/inventory/purchaseorders': 'purchaseorders',
'/dashboard/inventory/orderitems': 'orderitems',
'/dashboard/inventory/shipments': 'shipments'
}
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
const InventorySidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'overview'
})()
const includeDev = import.meta.env.MODE === 'development'
const items = getSidebarItems('inventory', { includeDev })
const selectedKey = getSidebarSelectedKey('inventory', location.pathname, {
includeDev
})
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}

View File

@ -80,6 +80,7 @@ const OrderItems = () => {
type='orderItem'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -86,6 +86,7 @@ const PartStocks = () => {
type='partStock'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -87,6 +87,7 @@ const ProductStocks = () => {
type='productStock'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -39,6 +39,7 @@ const NewProductStock = ({ onOk, reset, defaultValues }) => {
bordered={false}
visibleProperties={{
_id: false,
_reference: false,
createdAt: false,
updatedAt: false,
partStocks: false

View File

@ -80,6 +80,7 @@ const PurchaseOrders = () => {
type='purchaseOrder'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -80,6 +80,7 @@ const Shipments = () => {
type='shipment'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -86,6 +86,7 @@ const StockAudits = () => {
type='stockAudit'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -69,6 +69,7 @@ const StockEvents = () => {
type='stockEvent'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
</>

View File

@ -84,6 +84,7 @@ const StockLocations = () => {
type='stockLocation'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -84,6 +84,7 @@ const StockTransfers = () => {
type='stockTransfer'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -0,0 +1,193 @@
import { useContext, useEffect, useState } from 'react'
import {
Flex,
Typography,
Button,
Dropdown,
Skeleton,
Tag,
Divider
} from 'antd'
import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import DownloadIcon from '../../Icons/DownloadIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import { version as appVersion } from '../../../../package.json'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { AppUpdateContext } from '../context/AppUpdateContext'
import { useMediaQuery } from 'react-responsive'
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
const { Title, Text, Link } = Typography
const About = () => {
const [collapseState, updateCollapseState] = useCollapseState('About', {
updater: true
})
const { token } = useContext(AuthContext)
const { fetchApiServerVersion, fetchWsServerVersion } =
useContext(ApiServerContext)
const { isElectron, getElectronVersion } = useContext(ElectronContext)
const { checkForUpdates } = useContext(AppUpdateContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const buildNumber = import.meta.env.VITE_BUILD_NUMBER
? 'b' + import.meta.env.VITE_BUILD_NUMBER
: 'dev'
const developmentMode = import.meta.env.MODE === 'development'
const actions = [
{
label: 'Reload Window',
icon: <ReloadIcon />,
onClick: () => {
window.location.reload()
}
}
]
if (isElectron) {
actions.unshift(
{
label: 'Check for Updates',
icon: <DownloadIcon />,
onClick: checkForUpdates
},
{ type: 'divider' }
)
}
useEffect(() => {
if (token) {
fetchApiServerVersion().then((version) => {
setApiServerVersion(version)
})
fetchWsServerVersion().then((version) => {
setWsServerVersion(version)
})
}
}, [fetchApiServerVersion, fetchWsServerVersion, token])
useEffect(() => {
if (!isElectron) return
getElectronVersion()
.then((version) => {
setElectronVersion(version || 'unknown')
})
.catch(() => {
setElectronVersion('unknown')
})
}, [getElectronVersion, isElectron])
const [apiServerVersion, setApiServerVersion] = useState(null)
const [wsServerVersion, setWsServerVersion] = useState(null)
const [electronVersion, setElectronVersion] = useState(null)
const apiServerVersionText = apiServerVersion ? (
<Text>
{`v${apiServerVersion.version}-${apiServerVersion.buildNumber == 'dev' ? 'dev' : 'b' + apiServerVersion.buildNumber}`}
</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
const wsServerVersionText = wsServerVersion ? (
<Text>{`v${wsServerVersion.version}-${wsServerVersion.buildNumber == 'dev' ? 'dev' : 'b' + wsServerVersion.buildNumber}`}</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
const electronVersionText = electronVersion ? (
<Text>{`v${electronVersion}`}</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex vertical gap='large'>
<Flex vertical gap='middle' align='start'>
<Dropdown menu={{ items: actions }}>
<Button>Actions</Button>
</Dropdown>
</Flex>
<Flex vertical gap='large'>
<InfoCollapse
title='About Farm Control'
icon={<InfoCircleIcon />}
canCollapse={false}
active={collapseState.purchaseOrderStats}
onToggle={(isActive) =>
updateCollapseState('purchaseOrderStats', isActive)
}
className='no-t-padding-collapse'
collapseKey='purchaseOrderStats'
>
<Flex gap='large'>
<FarmControlAppIcon
style={{ width: '200px', height: '200px' }}
/>
<Flex vertical gap='small' justify='center'>
<Flex gap='middle' align='center'>
<Title level={2} style={{ margin: 0 }}>
Farm Control
</Title>
{developmentMode && !isMobile && (
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
>
Development
</Tag>
)}
</Flex>
<Text type='secondary'>
3D Printer ERP and Control Software.
</Text>
<Flex style={{ columnGap: '15px', rowGap: '8px' }} wrap='wrap'>
<Text type='secondary'>
User Interface:{' '}
<Text>
v{appVersion}-{buildNumber}
</Text>
</Text>
{isElectron && (
<Text type='secondary'>
Electron: {electronVersionText}
</Text>
)}
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
<Text type='secondary'>
Web Socket: {wsServerVersionText}
</Text>
</Flex>
<Flex gap='middle' align='center'>
{developmentMode && isMobile && (
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
>
Development
</Tag>
)}
<Link href='https://ci.tombutcher.work/job/farmcontrol'>
Jenkins
</Link>
<Divider type='vertical' style={{ margin: 0 }} />
<Link href='https://github.com/farmcontrol'>GitHub</Link>
</Flex>
</Flex>
</Flex>
</InfoCollapse>
</Flex>
</Flex>
</div>
)
}
export default About

View File

@ -80,6 +80,7 @@ const AppPasswords = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
<Modal

View File

@ -24,25 +24,12 @@ const NewAppPassword = ({ onOk, reset, defaultValues = {} }) => {
column={1}
bordered={false}
isEditing={true}
labelWidth={75}
required={true}
objectData={objectData}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='appPassword'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
@ -53,11 +40,13 @@ const NewAppPassword = ({ onOk, reset, defaultValues = {} }) => {
bordered={false}
visibleProperties={{
_id: false,
_reference: false,
createdAt: false,
updatedAt: false,
secret: false
}}
isEditing={false}
labelWidth={70}
objectData={objectData}
/>
)

View File

@ -52,10 +52,10 @@ const RegenerateAppPasswordSecret = ({ id }) => {
<Flex justify='center' style={{ minWidth: '395px' }}>
<Flex justify='center'>
<Flex gap='small' align='center' justify='center'>
<CopyButton size='default' text={appPassword} />
<Text code style={{ fontSize: '18px' }}>
{appPassword || '••••••••••••••••••••••••••••••••'}
</Text>
<CopyButton size='default' text={appPassword} />
<Button
type='text'
loading={loading}

View File

@ -0,0 +1,248 @@
import PropTypes from 'prop-types'
import { useState } from 'react'
import { Button, Flex, Modal, Progress, Typography, theme } from 'antd'
import CloudIcon from '../../../Icons/CloudIcon'
import HostIcon from '../../../Icons/HostIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
const { Text } = Typography
const formatBytes = (bytes) => {
if (!Number.isFinite(bytes) || bytes <= 0) return null
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${
units[unitIndex]
}`
}
const STAGE_CONFIG = {
download: {
icon: CloudIcon,
labels: {
pending: 'Download',
active: 'Downloading...',
complete: 'Downloaded',
error: 'Download failed'
}
},
install: {
icon: HostIcon,
labels: {
pending: 'Install',
active: 'Installing...',
complete: 'Installed',
error: 'Install failed'
}
},
restart: {
icon: ReloadIcon,
labels: {
pending: 'Restart',
active: 'Restarting...',
complete: 'Restarted',
error: 'Restart failed'
}
}
}
const getStageColor = (status, token) => {
if (status === 'complete') return token.colorSuccess
if (status === 'active') return token.colorPrimary
if (status === 'error') return token.colorError
return token.colorTextQuaternary
}
const getDownloadStageStatus = (phase, isError) => {
if (isError && ['preparing', 'downloading'].includes(phase)) return 'error'
if (['downloaded', 'installing'].includes(phase)) return 'complete'
if (['preparing', 'downloading'].includes(phase)) return 'active'
return 'pending'
}
const isInstallComplete = (phase, message) => {
if (phase !== 'installing') return false
const normalized = String(message || '').toLowerCase()
return (
normalized.includes('complete') ||
normalized.includes('successful') ||
normalized.includes('restarting')
)
}
const getInstallStageStatus = (phase, isError, message) => {
if (isError && ['downloaded', 'installing'].includes(phase)) return 'error'
if (isInstallComplete(phase, message)) return 'complete'
if (phase === 'installing') return 'active'
return 'pending'
}
const getRestartStageStatus = (phase, isError, message) => {
if (isError && isInstallComplete(phase, message)) return 'error'
if (isInstallComplete(phase, message)) return 'active'
return 'pending'
}
const getProgressStatus = (stageStatus) => {
if (stageStatus === 'error') return 'exception'
if (stageStatus === 'complete') return 'success'
return 'active'
}
const UpdateStage = ({ stage, status, percent, detail }) => {
const { token } = theme.useToken()
const config = STAGE_CONFIG[stage]
const StageIcon = config.icon
const resolvedPercent =
typeof percent === 'number' ? Math.min(percent, 100) : undefined
const resolvedStatus =
status !== 'error' && resolvedPercent === 100 ? 'complete' : status
const color = getStageColor(resolvedStatus, token)
const showProgress = resolvedStatus === 'active' && stage !== 'restart'
const StatusIcon =
resolvedStatus === 'complete'
? CheckCircleIcon
: resolvedStatus === 'error'
? XMarkCircleIcon
: StageIcon
return (
<Flex align='start' gap='middle' style={{ width: '100%' }}>
<StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} />
<Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}>
<Text style={{ flexShrink: 0 }}>{config.labels[resolvedStatus]}</Text>
{showProgress && (
<Flex vertical gap={2} style={{ flex: 1 }}>
<Progress
percent={resolvedPercent}
status={getProgressStatus(resolvedStatus)}
showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }}
/>
{detail && <Text type='secondary'>{detail}</Text>}
</Flex>
)}
</Flex>
</Flex>
)
}
UpdateStage.propTypes = {
stage: PropTypes.oneOf(['download', 'install', 'restart']).isRequired,
status: PropTypes.oneOf(['pending', 'active', 'complete', 'error'])
.isRequired,
percent: PropTypes.number,
detail: PropTypes.string
}
const AppUpdateProgress = ({ progress, update, onClose }) => {
const phase = progress?.phase || 'preparing'
const percent =
typeof progress?.percent === 'number'
? Math.min(progress.percent, 100)
: null
const downloaded = formatBytes(progress?.downloadedBytes)
const total = formatBytes(progress?.totalBytes)
const message = progress?.message || 'Preparing update'
const isError = phase === 'error'
const [errorModalOpen, setErrorModalOpen] = useState(true)
const downloadStatus = getDownloadStageStatus(phase, isError)
const installStatus = getInstallStageStatus(phase, isError, message)
const restartStatus = getRestartStageStatus(phase, isError, message)
const downloadPercent =
downloadStatus === 'active' ? (phase === 'preparing' ? 0 : percent) : null
const installPercent = installStatus === 'active' ? percent : null
const downloadDetail =
downloadStatus === 'active' && downloaded && total
? `${downloaded} of ${total}`
: null
const installDetail = installStatus === 'active' ? message : null
return (
<Flex vertical gap='middle'>
<Text>
Updating Farm Control to version{' '}
{update?.version ? `${update.version}` : 'unknown'} build{' '}
{update?.buildNumber ? `${update.buildNumber}` : 'unknown'} from branch{' '}
{update?.branch ? `${update.branch}` : 'unknown'}...
</Text>
<Flex vertical gap='middle'>
<UpdateStage
stage='download'
status={downloadStatus}
percent={downloadPercent}
detail={downloadDetail}
/>
<UpdateStage
stage='install'
status={installStatus}
percent={installPercent}
detail={installDetail}
/>
<UpdateStage stage='restart' status={restartStatus} />
</Flex>
<Modal
title='Update Failed'
open={isError && errorModalOpen == true}
centered
closable={false}
maskClosable={false}
onCancel={() => {
setErrorModalOpen(false)
onClose()
}}
footer={[
<Button
key='close'
onClick={() => {
setErrorModalOpen(false)
onClose()
}}
>
Close
</Button>
]}
>
<Text>{message}</Text>
</Modal>
</Flex>
)
}
AppUpdateProgress.propTypes = {
progress: PropTypes.shape({
phase: PropTypes.string,
percent: PropTypes.number,
downloadedBytes: PropTypes.number,
totalBytes: PropTypes.number,
message: PropTypes.string,
artifact: PropTypes.object
}),
update: PropTypes.object,
onClose: PropTypes.func
}
export default AppUpdateProgress

View File

@ -0,0 +1,148 @@
import PropTypes from 'prop-types'
import { useState } from 'react'
import { Button, Flex, Typography, Card, Dropdown, Modal, Table } from 'antd'
import TimeDisplay from '../../common/TimeDisplay'
import FarmControlAppIcon from '../../../Logos/FarmControlAppIcon'
import EyeIcon from '../../../Icons/EyeIcon'
const { Text, Title } = Typography
const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const primaryArtifact = artifacts.find((artifact) => artifact.url)
const [changesMenuOpen, setChangesMenuOpen] = useState(false)
const columns = [
{
title: 'Date',
dataIndex: 'date',
key: 'date',
width: 290,
render: (text, record) => {
return <TimeDisplay dateTime={record.date} showSince={true} />
}
},
{
title: 'Author',
dataIndex: 'author',
key: 'author',
width: 180
},
{
title: 'Message',
dataIndex: 'message',
key: 'message',
width: 500
}
]
const actionsMenu = {
items: [
{
label: 'View Changes',
key: 'viewChanges',
icon: <EyeIcon />
}
],
onClick: ({ key }) => {
if (key === 'viewChanges') {
setChangesMenuOpen(true)
}
}
}
return (
<>
<Flex vertical gap='middle'>
<Text>
A new Farm Control update is available. Would you like to update now?
</Text>
<Card styles={{ body: { padding: '12px 18px 12px 12px' } }}>
<Flex gap={12} style={{ width: '100%' }}>
<FarmControlAppIcon style={{ width: '70px', height: '70px' }} />
<Flex vertical gap={2} justify='center' style={{ width: '100%' }}>
<Title level={3} style={{ margin: 0 }}>
{'Farm Control UI'}
</Title>
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Flex style={{ columnGap: '15px', rowGap: '8px' }}>
<Text style={{ margin: 0 }} type='secondary'>
Version:{' '}
<Text>
{update?.version ? `v${update.version}` : 'Unknown'}
</Text>
</Text>
<Text style={{ margin: 0 }} type='secondary'>
Build Number:{' '}
<Text>
{update?.buildNumber
? `b${update.buildNumber}`
: 'Unknown'}
</Text>
</Text>
<Text style={{ margin: 0 }} type='secondary'>
Branch: <Text>{update?.branch || 'Unknown'}</Text>
</Text>
</Flex>
<Dropdown menu={actionsMenu}>
<Button size='small' type='text'>
Actions
</Button>
</Dropdown>
</Flex>
</Flex>
</Flex>
</Card>
<Flex justify='space-between' gap='small' align='center'>
<Flex gap='small'>
<Text type='secondary'>Built at:</Text>
<TimeDisplay dateTime={update?.builtAt} />
</Flex>
<Flex gap='small'>
<Button onClick={onCancel}>Not Now</Button>
<Button
type='primary'
onClick={() => onUpdate(update)}
disabled={!primaryArtifact}
>
Update Now
</Button>
</Flex>
</Flex>
<Modal
open={changesMenuOpen}
onCancel={() => setChangesMenuOpen(false)}
footer={null}
title='View Changes'
width={1200}
>
<div style={{ marginTop: '20px' }}>
<Table
dataSource={update?.changes}
columns={columns}
scroll={{ x: 1100 }}
bordered={true}
pagination={false}
className='child-table'
/>
</div>
</Modal>
</Flex>
</>
)
}
NewAppUpdate.propTypes = {
update: PropTypes.object,
onCancel: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired
}
export default NewAppUpdate

View File

@ -62,6 +62,7 @@ const AuditLogs = () => {
visibleColumns={columnVisibility}
type='auditLog'
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
</>

View File

@ -80,6 +80,7 @@ const CourierServices = () => {
type='courierService'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -26,6 +26,7 @@ const NewCourierService = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={80}
/>
)
},
@ -39,6 +40,7 @@ const NewCourierService = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={120}
objectData={objectData}
/>
)
@ -59,6 +61,7 @@ const NewCourierService = ({ onOk, defaultValues }) => {
}}
isEditing={false}
objectData={objectData}
labelWidth={120}
/>
)
}

View File

@ -79,6 +79,7 @@ const Couriers = () => {
type='courier'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -19,6 +19,7 @@ const NewCourier = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={75}
/>
)
},
@ -33,6 +34,7 @@ const NewCourier = ({ onOk, defaultValues }) => {
isEditing={true}
required={false}
objectData={objectData}
labelWidth={85}
/>
)
},
@ -52,6 +54,7 @@ const NewCourier = ({ onOk, defaultValues }) => {
}}
isEditing={false}
objectData={objectData}
labelWidth={85}
/>
)
}

View File

@ -80,6 +80,7 @@ const DocumentJobs = () => {
type='documentJob'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -35,6 +35,7 @@ const NewDocumentJob = ({ onOk, defaultValues = {} }) => {
column={1}
visibleProperties={{ name: false }}
bordered={false}
labelWidth={115}
isEditing={true}
required={true}
objectData={objectData}

View File

@ -79,6 +79,7 @@ const DocumentPrinters = () => {
type='documentPrinter'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -78,6 +78,7 @@ const DocumentSizes = () => {
type='documentSize'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -5,7 +5,10 @@ import WizardView from '../../common/WizardView'
const NewDocumentSize = ({ onOk, defaultValues }) => {
return (
<NewObjectForm type={'documentSize'} defaultValues={{ ...defaultValues }}>
<NewObjectForm
type={'documentSize'}
defaultValues={{ infiniteHeight: false, ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
@ -19,6 +22,7 @@ const NewDocumentSize = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={120}
/>
)
},
@ -36,6 +40,7 @@ const NewDocumentSize = ({ onOk, defaultValues }) => {
createdAt: false,
updatedAt: false
}}
labelWidth={120}
isEditing={false}
objectData={objectData}
/>

View File

@ -80,6 +80,7 @@ const DocumentTemplates = () => {
type='documentTemplate'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -21,6 +21,7 @@ const NewDocumentTemplate = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={true}
labelWidth={130}
objectData={objectData}
/>
)

View File

@ -84,6 +84,7 @@ const FilamentSkus = () => {
type='filamentSku'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -8,7 +8,11 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
<NewObjectForm
type='filamentSku'
reset={reset}
defaultValues={defaultValues}
defaultValues={{
overrideCost: false,
color: '#ff0000',
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -19,7 +23,7 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
<ObjectInfo
type='filamentSku'
column={1}
labelWidth={70}
labelWidth={80}
bordered={false}
isEditing={true}
required={true}
@ -29,27 +33,26 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
cost: false,
costWithTax: false,
costTaxRate: false,
overrideCost: false,
vendor: false
}}
/>
)
},
{
title: 'Color & Cost',
key: 'colorCost',
title: 'Cost',
key: 'cost',
content: (
<ObjectInfo
type='filamentSku'
column={1}
labelWidth={100}
labelWidth={120}
required={true}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
barcode: false,
filament: false,
name: false,
description: false
overrideCost: true,
cost: true,
costTaxRate: true,
costWithTax: true
}}
bordered={false}
isEditing={true}
@ -64,7 +67,7 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
<ObjectInfo
type='filamentSku'
column={1}
labelWidth={100}
labelWidth={110}
visibleProperties={{
barcode: true,
description: true
@ -85,11 +88,12 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
visibleProperties={{
createdAt: false,
updatedAt: false,
_id: false
_id: false,
_reference: false
}}
labelWidth={100}
bordered={false}
isEditing={false}
labelWidth={120}
objectData={objectData}
/>
)

View File

@ -86,6 +86,7 @@ const Filaments = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
<Modal

View File

@ -238,7 +238,7 @@ const FilamentInfo = () => {
}}
reset={newFilamentSkuOpen}
defaultValues={{
filament: filamentId ? { _id: filamentId } : undefined
filament: objectFormState?.objectData || undefined
}}
/>
</Modal>

View File

@ -18,7 +18,33 @@ const NewFilament = ({ onOk }) => {
bordered={false}
isEditing={true}
required={true}
labelWidth={120}
objectData={objectData}
visibleProperties={{
cost: false,
costTaxRate: false,
costWithTax: false
}}
/>
)
},
{
title: 'Cost',
key: 'cost',
content: (
<ObjectInfo
type='filament'
column={1}
bordered={false}
isEditing={true}
required={true}
labelWidth={120}
objectData={objectData}
visibleProperties={{
cost: true,
costTaxRate: true,
costWithTax: true
}}
/>
)
},
@ -32,6 +58,7 @@ const NewFilament = ({ onOk }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={90}
objectData={objectData}
/>
)
@ -50,6 +77,7 @@ const NewFilament = ({ onOk }) => {
createdAt: false,
updatedAt: false
}}
labelWidth={120}
isEditing={false}
objectData={objectData}
/>

View File

@ -68,6 +68,7 @@ const Files = () => {
type='file'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
</>

View File

@ -85,6 +85,7 @@ const Hosts = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
<Modal

View File

@ -85,11 +85,6 @@ const HostOTP = ({ id }) => {
>
<Flex justify='center'>
<Flex gap={'small'} align='center' justify='center'>
<CopyButton
size='default'
text={hostObject?.otp}
disabled={loading}
/>
<div>
<Input.OTP
disabled={loading}
@ -100,6 +95,11 @@ const HostOTP = ({ id }) => {
onPaste={(e) => e.preventDefault()} // prevent pasting
/>
</div>
<CopyButton
size='default'
text={hostObject?.otp}
disabled={loading}
/>
<div style={{ margin: '0 6px 0 8px', paddingBottom: '5px' }}>
{loading ? (
<Text>

View File

@ -64,6 +64,7 @@ const NewHost = ({ onOk }) => {
visibleProperties={{
_id: false,
createdAt: false,
_reference: false,
updatedAt: false,
operatingSystem: false,
'deviceInfo.os': false,

View File

@ -1,244 +1,14 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import FilamentIcon from '../../Icons/FilamentIcon'
import FilamentSkuIcon from '../../Icons/FilamentSkuIcon'
import PartIcon from '../../Icons/PartIcon'
import PartSkuIcon from '../../Icons/PartSkuIcon'
import ProductIcon from '../../Icons/ProductIcon'
import ProductCategoryIcon from '../../Icons/ProductCategoryIcon'
import ProductSkuIcon from '../../Icons/ProductSkuIcon'
import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import PersonIcon from '../../Icons/PersonIcon'
import HostIcon from '../../Icons/HostIcon'
import DocumentPrinterIcon from '../../Icons/DocumentPrinterIcon'
import DocumentTemplateIcon from '../../Icons/DocumentTemplateIcon'
import DocumentIcon from '../../Icons/DocumentIcon'
import DocumentSizeIcon from '../../Icons/DocumentSizeIcon'
import DocumentJobIcon from '../../Icons/DocumentJobIcon'
import FileIcon from '../../Icons/FileIcon'
import CourierIcon from '../../Icons/CourierIcon'
import CourierServiceIcon from '../../Icons/CourierServiceIcon'
import TaxRateIcon from '../../Icons/TaxRateIcon'
import TaxRecordIcon from '../../Icons/TaxRecordIcon'
import AppPasswordIcon from '../../Icons/AppPasswordIcon'
const items = [
{
key: 'filaments',
icon: <FilamentIcon />,
label: 'Filaments',
path: '/dashboard/management/filaments'
},
{
key: 'filamentSkus',
icon: <FilamentSkuIcon />,
label: 'Filament SKUs',
path: '/dashboard/management/filamentskus'
},
{
key: 'parts',
icon: <PartIcon />,
label: 'Parts',
path: '/dashboard/management/parts'
},
{
key: 'partSkus',
icon: <PartSkuIcon />,
label: 'Part SKUs',
path: '/dashboard/management/partskus'
},
{
key: 'products',
icon: <ProductIcon />,
label: 'Products',
path: '/dashboard/management/products'
},
{
key: 'productCategories',
icon: <ProductCategoryIcon />,
label: 'Product Categories',
path: '/dashboard/management/productcategories'
},
{
key: 'productSkus',
icon: <ProductSkuIcon />,
label: 'Product SKUs',
path: '/dashboard/management/productskus'
},
{
key: 'vendors',
icon: <VendorIcon />,
label: 'Vendors',
path: '/dashboard/management/vendors'
},
{
key: 'materials',
icon: <MaterialIcon />,
label: 'Materials',
path: '/dashboard/management/materials'
},
{ type: 'divider' },
{
key: 'couriers',
icon: <CourierIcon />,
label: 'Couriers',
path: '/dashboard/management/couriers'
},
{
key: 'courierServices',
icon: <CourierServiceIcon />,
label: 'Courier Services',
path: '/dashboard/management/courierservices'
},
{ type: 'divider' },
{
key: 'taxRates',
icon: <TaxRateIcon />,
label: 'Tax Rates',
path: '/dashboard/management/taxrates'
},
{
key: 'taxRecords',
icon: <TaxRecordIcon />,
label: 'Tax Records',
path: '/dashboard/management/taxrecords'
},
{ type: 'divider' },
{
key: 'noteTypes',
icon: <NoteTypeIcon />,
label: 'Note Types',
path: '/dashboard/management/notetypes'
},
{
key: 'documents',
icon: <DocumentIcon />,
label: 'Documents',
children: [
{
key: 'documentPrinters',
icon: <DocumentPrinterIcon />,
label: 'Document Printers',
path: '/dashboard/management/documentprinters'
},
{
key: 'documentJobs',
icon: <DocumentJobIcon />,
label: 'Document Jobs',
path: '/dashboard/management/documentjobs'
},
{
key: 'documentTemplates',
icon: <DocumentTemplateIcon />,
label: 'Document Templates',
path: '/dashboard/management/documenttemplates'
},
{
key: 'documentSizes',
icon: <DocumentSizeIcon />,
label: 'Document Sizes',
path: '/dashboard/management/documentsizes'
}
]
},
{ type: 'divider' },
{
key: 'hosts',
icon: <HostIcon />,
label: 'Hosts',
path: '/dashboard/management/hosts'
},
{ type: 'divider' },
{
key: 'users',
icon: <PersonIcon />,
label: 'Users',
path: '/dashboard/management/users'
},
{
key: 'appPasswords',
icon: <AppPasswordIcon />,
label: 'App Passwords',
path: '/dashboard/management/apppasswords'
},
{
key: 'settings',
icon: <SettingsIcon />,
label: 'Settings',
path: '/dashboard/management/settings'
},
{
key: 'files',
icon: <FileIcon />,
label: 'Files',
path: '/dashboard/management/files'
},
{
key: 'auditLogs',
icon: <AuditLogIcon />,
label: 'Audit Logs',
path: '/dashboard/management/auditlogs'
}
]
if (import.meta.env.MODE === 'development') {
items.push(
{ type: 'divider' },
{
key: 'developer',
icon: <DeveloperIcon />,
label: 'Developer',
path: '/dashboard/developer/sessionstorage'
}
)
}
const routeKeyMap = {
'/dashboard/management/filaments': 'filaments',
'/dashboard/management/filamentskus': 'filamentSkus',
'/dashboard/management/parts': 'parts',
'/dashboard/management/partskus': 'partSkus',
'/dashboard/management/users': 'users',
'/dashboard/management/apppasswords': 'appPasswords',
'/dashboard/management/products': 'products',
'/dashboard/management/productcategories': 'productCategories',
'/dashboard/management/productskus': 'productSkus',
'/dashboard/management/vendors': 'vendors',
'/dashboard/management/couriers': 'couriers',
'/dashboard/management/courierservices': 'courierServices',
'/dashboard/management/taxrates': 'taxRates',
'/dashboard/management/taxrecords': 'taxRecords',
'/dashboard/management/materials': 'materials',
'/dashboard/management/notetypes': 'noteTypes',
'/dashboard/management/settings': 'settings',
'/dashboard/management/auditlogs': 'auditLogs',
'/dashboard/management/files': 'files',
'/dashboard/management/hosts': 'hosts',
'/dashboard/management/documentsizes': 'documentSizes',
'/dashboard/management/documentprinters': 'documentPrinters',
'/dashboard/management/documenttemplates': 'documentTemplates',
'/dashboard/management/documentjobs': 'documentJobs'
}
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
const ManagementSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'filaments'
})()
const includeDev = import.meta.env.MODE === 'development'
const items = getSidebarItems('management', { includeDev })
const selectedKey = getSidebarSelectedKey('management', location.pathname, {
includeDev
})
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}

View File

@ -83,6 +83,7 @@ const Materials = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
<Modal

View File

@ -18,6 +18,7 @@ const NewMaterial = ({ onOk }) => {
bordered={false}
isEditing={true}
required={true}
labelWidth={70}
objectData={objectData}
/>
)
@ -32,6 +33,7 @@ const NewMaterial = ({ onOk }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={62}
objectData={objectData}
/>
)
@ -51,6 +53,7 @@ const NewMaterial = ({ onOk }) => {
updatedAt: false
}}
isEditing={false}
labelWidth={70}
objectData={objectData}
/>
)

View File

@ -80,6 +80,7 @@ const NoteTypes = () => {
type='noteType'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -23,6 +23,7 @@ const NewNoteType = ({ onOk }) => {
bordered={false}
isEditing={true}
required={true}
labelWidth={72}
objectData={objectData}
/>
)
@ -38,6 +39,7 @@ const NewNoteType = ({ onOk }) => {
isEditing={true}
required={false}
objectData={objectData}
labelWidth={65}
/>
)
},
@ -57,6 +59,7 @@ const NewNoteType = ({ onOk }) => {
}}
isEditing={false}
objectData={objectData}
labelWidth={70}
/>
)
}

View File

@ -83,6 +83,7 @@ const PartSkus = () => {
type='partSku'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -8,7 +8,11 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
<NewObjectForm
type='partSku'
reset={reset}
defaultValues={defaultValues}
defaultValues={{
overrideCost: false,
overridePrice: false,
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -32,6 +36,8 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
costTaxRate: false,
price: false,
priceWithTax: false,
overrideCost: false,
overridePrice: false,
margin: false,
amount: false,
priceTaxRate: false,
@ -47,15 +53,18 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
<ObjectInfo
type='partSku'
column={1}
labelWidth={100}
labelWidth={120}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
barcode: false,
part: false,
name: false,
description: false
overrideCost: true,
cost: true,
costTaxRate: true,
costWithTax: true,
overridePrice: true,
priceMode: true,
price: true,
margin: true,
priceTaxRate: true,
priceWithTax: true
}}
bordered={false}
isEditing={true}
@ -91,9 +100,10 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
visibleProperties={{
createdAt: false,
updatedAt: false,
_id: false
_id: false,
_reference: false
}}
labelWidth={100}
labelWidth={120}
bordered={false}
isEditing={false}
objectData={objectData}

View File

@ -85,6 +85,7 @@ const Parts = (filter) => {
cards={viewMode === 'cards'}
filter={filter}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -26,6 +26,7 @@ const NewPart = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={70}
visibleProperties={{
file: false,
priceMode: false,
@ -51,6 +52,7 @@ const NewPart = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={120}
visibleProperties={{
priceMode: true,
margin: true,
@ -74,6 +76,7 @@ const NewPart = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={50}
objectData={objectData}
/>
)

View File

@ -193,7 +193,7 @@ const PartInfo = () => {
}}
reset={newPartSkuOpen}
defaultValues={{
part: partId ? { _id: partId } : undefined
part: objectFormState?.objectData || undefined
}}
/>
</Modal>

View File

@ -83,6 +83,7 @@ const ProductCategories = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
<Modal

View File

@ -19,6 +19,7 @@ const NewProductCategory = ({ onOk }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={70}
/>
)
},
@ -38,6 +39,7 @@ const NewProductCategory = ({ onOk }) => {
}}
isEditing={false}
objectData={objectData}
labelWidth={70}
/>
)
}

View File

@ -84,6 +84,7 @@ const ProductSkus = () => {
type='productSku'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -8,7 +8,11 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
<NewObjectForm
type='productSku'
reset={reset}
defaultValues={defaultValues}
defaultValues={{
overrideCost: false,
overridePrice: false,
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -19,7 +23,7 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
<ObjectInfo
type='productSku'
column={1}
labelWidth={70}
labelWidth={80}
bordered={false}
isEditing={true}
required={true}
@ -35,6 +39,8 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
margin: false,
amount: false,
priceTaxRate: false,
overrideCost: false,
overridePrice: false,
vendor: false,
parts: false
}}
@ -48,49 +54,18 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
<ObjectInfo
type='productSku'
column={1}
labelWidth={100}
labelWidth={120}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
barcode: false,
product: false,
name: false,
description: false,
parts: false
}}
bordered={false}
isEditing={true}
objectData={objectData}
/>
)
},
{
title: 'Parts',
key: 'parts',
content: (
<ObjectInfo
type='productSku'
column={1}
labelWidth={100}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
barcode: false,
product: false,
name: false,
description: false,
priceMode: false,
cost: false,
costWithTax: false,
costTaxRate: false,
price: false,
priceWithTax: false,
margin: false,
amount: false,
priceTaxRate: false,
vendor: false
overrideCost: true,
cost: true,
costTaxRate: true,
costWithTax: true,
overridePrice: true,
priceMode: true,
price: true,
margin: true,
priceTaxRate: true,
priceWithTax: true
}}
bordered={false}
isEditing={true}
@ -126,9 +101,11 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
visibleProperties={{
createdAt: false,
updatedAt: false,
_id: false
_id: false,
_reference: false,
parts: false
}}
labelWidth={100}
labelWidth={120}
bordered={false}
isEditing={false}
objectData={objectData}

View File

@ -131,24 +131,26 @@ const ProductSkuInfo = () => {
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<InfoCollapse
title='Product SKU Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
collapseKey='info'
<ObjectForm
id={productSkuId}
type='productSku'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
<ObjectForm
id={productSkuId}
type='productSku'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Product SKU Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
@ -158,56 +160,60 @@ const ProductSkuInfo = () => {
parts: false
}}
/>
</>
)}
</ObjectForm>
</InfoCollapse>
</InfoCollapse>
<InfoCollapse
title='SKU Parts'
icon={<PartIcon />}
active={collapseState.parts}
onToggle={(expanded) =>
updateCollapseState('parts', expanded)
}
collapseKey='parts'
>
<ObjectProperty
{...getModelProperty('productSku', 'parts')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
size='medium'
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='SKU Parts'
icon={<PartIcon />}
active={collapseState.parts}
onToggle={(expanded) => updateCollapseState('parts', expanded)}
collapseKey='parts'
>
<ObjectProperty
{...getModelProperty('productSku', 'parts')}
isEditing={objectFormState.isEditing}
objectData={objectFormState.objectData}
loading={objectFormState.loading}
size='medium'
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={productSkuId} type='productSku' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': productSkuId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={productSkuId} type='productSku' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': productSkuId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</Flex>
</ScrollBox>
</Flex>

View File

@ -355,6 +355,7 @@ const Products = () => {
type={'product'}
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -197,7 +197,7 @@ const ProductInfo = () => {
}}
reset={newProductSkuOpen}
defaultValues={{
product: productId ? { _id: productId } : undefined
product: objectFormState?.objectData || undefined
}}
/>
</Modal>

View File

@ -1,121 +1,309 @@
import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
import { CaretLeftOutlined } from '@ant-design/icons'
import { useContext, useEffect, useMemo, useState } from 'react'
import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd'
import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
import { ApiServerContext } from '../context/ApiServerContext'
import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext'
import { useMessageContext } from '../context/MessageContext'
import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import ViewButton from '../common/ViewButton'
import EditButtons from '../common/EditButtons'
const { Title } = Typography
const { Text } = Typography
const { Option } = Select
const DEFAULT_UPDATE_BRANCH = 'main'
const Settings = () => {
const {
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem
} = useThemeContext()
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
useThemeContext()
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
const { isElectron, getAppSettings, setAppSettings } =
useContext(ElectronContext)
const { userProfile, setUserProfile } = useContext(AuthContext)
const { showSuccess, showError } = useMessageContext()
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
appearance: true
appearance: true,
appUpdates: true
})
const [isEditing, setIsEditing] = useState(false)
const [settingsLoading, setSettingsLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [appSettings, setAppSettingsState] = useState({})
const [draftSettings, setDraftSettings] = useState({})
const [branches, setBranches] = useState([])
const [branchLoading, setBranchLoading] = useState(false)
const handleThemeChange = (value) => {
if (value === 'system') {
toggleSystem()
} else {
if (isSystem) {
toggleSystem()
}
if (value === 'dark' && !isDarkMode) {
toggleTheme()
} else if (value === 'light' && isDarkMode) {
toggleTheme()
}
useEffect(() => {
const loadSettings = async () => {
setSettingsLoading(true)
const storedSettings = isElectron
? await getAppSettings()
: userProfile?.settings || {}
setAppSettingsState(storedSettings || {})
setDraftSettings(storedSettings || {})
setSettingsLoading(false)
}
}
const handleCompactChange = (value) => {
if (value === 'compact' && !isCompact) {
toggleCompact()
} else if (value === 'comfortable' && isCompact) {
toggleCompact()
loadSettings()
}, [getAppSettings, isElectron, userProfile?.settings])
useEffect(() => {
if (settingsLoading || isEditing) return
if (appSettings.theme) setThemeMode(appSettings.theme)
if (appSettings.density) setDensityMode(appSettings.density)
}, [
appSettings.density,
appSettings.theme,
isEditing,
setDensityMode,
setThemeMode,
settingsLoading
])
useEffect(() => {
if (!isElectron) {
setBranches([])
setBranchLoading(false)
return
}
}
const loadBranches = async () => {
setBranchLoading(true)
const availableBranches = await fetchAppUpdateBranches()
setBranches(availableBranches)
setDraftSettings((previous) => {
if (previous.appUpdateBranch) return previous
const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH)
? DEFAULT_UPDATE_BRANCH
: availableBranches[0]
return defaultBranch
? { ...previous, appUpdateBranch: defaultBranch }
: previous
})
setBranchLoading(false)
}
loadBranches()
}, [fetchAppUpdateBranches, isElectron])
const branchOptions = useMemo(
() =>
branches.map((branch) => (
<Option key={branch} value={branch}>
{branch}
</Option>
)),
[branches]
)
const viewItems = [
{ key: 'appearance', label: 'Appearance Settings' },
...(isElectron ? [{ key: 'appUpdates', label: 'App Update Settings' }] : [])
]
const getCurrentThemeValue = () => {
if (isSystem) return 'system'
return isDarkMode ? 'dark' : 'light'
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.appearance ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('appearance', keys.length > 0)
const currentThemeValue = getCurrentThemeValue()
const currentDensityValue = isCompact ? 'compact' : 'comfortable'
const currentBranch =
appSettings.appUpdateBranch ||
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
branches[0] ||
'Not configured'
const startEditing = () => {
setDraftSettings({
...appSettings,
appUpdateBranch:
currentBranch === 'Not configured' ? undefined : currentBranch,
theme: currentThemeValue,
density: currentDensityValue
})
setIsEditing(true)
}
const cancelEditing = () => {
setDraftSettings(appSettings)
setIsEditing(false)
}
const handleSave = async () => {
setSaving(true)
try {
const nextSettings = {
...appSettings,
theme: draftSettings.theme,
density: draftSettings.density,
...(isElectron
? { appUpdateBranch: draftSettings.appUpdateBranch }
: {})
}
const saved = isElectron
? await setAppSettings(nextSettings)
: Boolean(userProfile)
if (!saved) {
showError('Unable to save settings.')
return
}
if (!isElectron) {
setUserProfile((previous) => ({
...previous,
settings: {
...(previous?.settings || {}),
theme: draftSettings.theme,
density: draftSettings.density
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Appearance Settings
</Title>
</Flex>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Theme'>
<Select
value={getCurrentThemeValue()}
onChange={handleThemeChange}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
<Select
value={isCompact ? 'compact' : 'comfortable'}
onChange={handleCompactChange}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
}))
}
setThemeMode(draftSettings.theme)
setDensityMode(draftSettings.density)
setAppSettingsState(nextSettings)
setDraftSettings(nextSettings)
setIsEditing(false)
showSuccess('Settings saved.')
} finally {
setSaving(false)
}
}
return (
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
<Flex justify='space-between' align='center'>
<Space size='small'>
<ViewButton
disabled={settingsLoading}
items={viewItems}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleSave}
cancelEditing={cancelEditing}
startEditing={startEditing}
formValid={
Boolean(draftSettings.theme && draftSettings.density) &&
(!isElectron || Boolean(draftSettings.appUpdateBranch))
}
disabled={settingsLoading || (!isElectron && !userProfile)}
loading={saving}
/>
</Flex>
</div>
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Spin spinning={settingsLoading} indicator={<LoadingOutlined />}>
<Flex vertical gap='large'>
<InfoCollapse
title='Appearance Settings'
icon={<SettingOutlined />}
active={collapseState.appearance}
onToggle={(expanded) =>
updateCollapseState('appearance', expanded)
}
collapseKey='appearance'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Theme'>
{isEditing ? (
<Select
value={draftSettings.theme}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
theme: value
}))
}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
) : (
<Text>{currentThemeValue}</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
{isEditing ? (
<Select
value={draftSettings.density}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
density: value
}))
}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
) : (
<Text>{isCompact ? 'Compact' : 'Comfortable'}</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse>
{isElectron && (
<InfoCollapse
title='App Update Settings'
icon={<SettingOutlined />}
active={collapseState.appUpdates}
onToggle={(expanded) =>
updateCollapseState('appUpdates', expanded)
}
collapseKey='appUpdates'
>
<Descriptions bordered column={1}>
<Descriptions.Item label='Branch'>
{isEditing ? (
<Select
value={draftSettings.appUpdateBranch}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
appUpdateBranch: value
}))
}
style={{ width: '100%' }}
loading={branchLoading}
placeholder='Select a branch'
>
{branchOptions}
</Select>
) : (
<Text>{currentBranch}</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse>
)}
</Flex>
</Spin>
</div>
</Flex>
)
}

View File

@ -79,6 +79,7 @@ const TaxRates = () => {
type='taxRate'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -5,7 +5,10 @@ import WizardView from '../../common/WizardView'
const NewTaxRate = ({ onOk, defaultValues }) => {
return (
<NewObjectForm type={'taxRate'} defaultValues={{ ...defaultValues }}>
<NewObjectForm
type={'taxRate'}
defaultValues={{ active: true, ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
@ -19,6 +22,7 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
isEditing={true}
required={true}
objectData={objectData}
labelWidth={100}
/>
)
},
@ -32,6 +36,7 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={130}
objectData={objectData}
/>
)
@ -50,6 +55,7 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
createdAt: false,
updatedAt: false
}}
labelWidth={130}
isEditing={false}
objectData={objectData}
/>

View File

@ -47,11 +47,10 @@ const SetAppPassword = ({ id }) => {
<Flex justify='center' style={{ minWidth: '395px' }}>
<Flex justify='center'>
<Flex gap='small' align='center' justify='center'>
<CopyButton size='default' text={appPassword} />
<Text code style={{ fontSize: '18px' }}>
{appPassword || '••••••••••••••••••••••••••••••••'}
</Text>
<CopyButton size='default' text={appPassword} />
<Button
type='texts'
loading={loading}

View File

@ -79,6 +79,7 @@ const Vendors = () => {
type='vendor'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -21,6 +21,7 @@ const NewVendor = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={true}
labelWidth={80}
objectData={objectData}
/>
)
@ -35,6 +36,7 @@ const NewVendor = ({ onOk, defaultValues }) => {
bordered={false}
isEditing={true}
required={false}
labelWidth={85}
objectData={objectData}
/>
)
@ -54,6 +56,7 @@ const NewVendor = ({ onOk, defaultValues }) => {
updatedAt: false
}}
isEditing={false}
labelWidth={80}
objectData={objectData}
/>
)

View File

@ -82,6 +82,7 @@ const GCodeFiles = () => {
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -1,11 +1,9 @@
import PropTypes from 'prop-types'
import { useMessageContext } from '../../context/MessageContext'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewGCodeFile = ({ onOk, defaultValues }) => {
const { showSuccess } = useMessageContext()
return (
<NewObjectForm
type={'gcodeFile'}
@ -78,7 +76,6 @@ const NewGCodeFile = ({ onOk, defaultValues }) => {
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
showSuccess('Finished uploading GCode file!')
onOk()
}
}}

View File

@ -86,6 +86,7 @@ const Jobs = () => {
visibleColumns={columnVisibility}
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
<Modal

View File

@ -1,11 +1,9 @@
import PropTypes from 'prop-types'
import { useMessageContext } from '../../context/MessageContext'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewJob = ({ onOk, defaultValues }) => {
const { showSuccess } = useMessageContext()
return (
<NewObjectForm
type={'job'}
@ -42,6 +40,7 @@ const NewJob = ({ onOk, defaultValues }) => {
labelWidth='100px'
visibleProperties={{
_id: false,
_reference: false,
createdAt: false,
updatedAt: false,
startedAt: false,
@ -63,7 +62,6 @@ const NewJob = ({ onOk, defaultValues }) => {
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
showSuccess('New job created successfully.')
onOk()
}
}}

View File

@ -1,11 +1,9 @@
import PropTypes from 'prop-types'
import { useMessageContext } from '../../context/MessageContext'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewPrinter = ({ onOk, defaultValues }) => {
const { showSuccess } = useMessageContext()
return (
<NewObjectForm
type={'printer'}
@ -70,6 +68,7 @@ const NewPrinter = ({ onOk, defaultValues }) => {
bordered={false}
visibleProperties={{
_id: false,
_reference: false,
createdAt: false,
updatedAt: false,
connectedAt: false,
@ -98,7 +97,6 @@ const NewPrinter = ({ onOk, defaultValues }) => {
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
showSuccess('New printer added successfully.')
onOk()
}
}}

View File

@ -1,67 +1,14 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import ProductionIcon from '../../Icons/ProductionIcon'
import PrinterIcon from '../../Icons/PrinterIcon'
import JobIcon from '../../Icons/JobIcon'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import SubJobIcon from '../../Icons/SubJobIcon'
const items = [
{
key: 'overview',
icon: <ProductionIcon />,
label: 'Overview',
path: '/dashboard/production/overview'
},
{ type: 'divider' },
{
key: 'printers',
icon: <PrinterIcon />,
label: 'Printers',
path: '/dashboard/production/printers'
},
{
key: 'jobs',
icon: <JobIcon />,
label: 'Jobs',
path: '/dashboard/production/jobs'
},
{
key: 'subJobs',
icon: <SubJobIcon />,
label: 'Sub Jobs',
path: '/dashboard/production/subjobs'
},
{
key: 'gcodeFiles',
icon: <GCodeFileIcon />,
label: 'GCode Files',
path: '/dashboard/production/gcodefiles'
}
]
const routeKeyMap = {
'/dashboard/production/overview': 'overview',
'/dashboard/production/printers': 'printers',
'/dashboard/production/jobs': 'jobs',
'/dashboard/production/subjobs': 'subJobs',
'/dashboard/production/gcodefiles': 'gcodeFiles'
}
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
const ProductionSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'overview'
})()
const includeDev = import.meta.env.MODE === 'development'
const items = getSidebarItems('production', { includeDev })
const selectedKey = getSidebarSelectedKey('production', location.pathname, {
includeDev
})
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}

View File

@ -71,6 +71,7 @@ const SubJobs = () => {
visibleColumns={columnVisibility}
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
expandHeight={true}
/>
</Flex>
</>

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