Compare commits
No commits in common. "main" and "windows-auth-fix" have entirely different histories.
main
...
windows-au
28
Jenkinsfile
vendored
@ -20,7 +20,7 @@ def deploy() {
|
||||
|
||||
stage('Build (Ubuntu)') {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production pnpm build:cloudflare"
|
||||
sh 'NODE_ENV=production pnpm build:cloudflare'
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,41 +70,21 @@ def buildOnLabel(label, buildCommand) {
|
||||
stage("Build (${label})") {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
if (isUnix()) {
|
||||
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
|
||||
sh "NODE_ENV=production ${buildCommand}"
|
||||
} else {
|
||||
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
|
||||
bat "set NODE_ENV=production && ${buildCommand}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage("Archive Artifacts (${label})") {
|
||||
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
|
||||
archiveArtifacts artifacts: 'app_dist/**/*.dmg, app_dist/**/*.exe', 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'),
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@ -2,9 +2,6 @@
|
||||
<!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(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>
|
||||
<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;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.8 KiB |
@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
@ -1,13 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@ -1,18 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -596,9 +596,3 @@ 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;
|
||||
}
|
||||
|
||||
67
package.json
@ -9,6 +9,7 @@
|
||||
"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",
|
||||
@ -38,7 +39,6 @@
|
||||
"@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,13 +46,11 @@
|
||||
"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",
|
||||
@ -61,7 +59,6 @@
|
||||
"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": "*",
|
||||
@ -80,7 +77,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 --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
||||
"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\"",
|
||||
"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"
|
||||
@ -134,7 +131,6 @@
|
||||
"build": {
|
||||
"appId": "com.tombutcher.farmcontrol",
|
||||
"productName": "Farm Control",
|
||||
"artifactName": "farmcontrol-${version}-${arch}.${ext}",
|
||||
"icon": "assets/logos/farmcontrolicon.png",
|
||||
"directories": {
|
||||
"output": "app_dist"
|
||||
@ -151,23 +147,11 @@
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pkg",
|
||||
"arch": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pkg",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mergeASARs": true,
|
||||
@ -184,45 +168,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"msiWrapped"
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Farm Control Protocol",
|
||||
"schemes": [
|
||||
"farmcontrol"
|
||||
]
|
||||
}
|
||||
]
|
||||
"target": "nsis"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage"
|
||||
@ -232,12 +179,6 @@
|
||||
"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
@ -2,6 +2,7 @@ allowBuilds:
|
||||
electron: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
keytar: true
|
||||
workerd: true
|
||||
core-js: true
|
||||
electron-winstaller: true
|
||||
|
||||
@ -1,263 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import { Buffer } from 'buffer'
|
||||
import process from 'process'
|
||||
import { app, ipcMain, shell, globalShortcut } from 'electron'
|
||||
import { createRequire } from 'module'
|
||||
import {
|
||||
registerGlobalShortcuts,
|
||||
setupSpotlightIPC
|
||||
@ -14,66 +12,22 @@ import {
|
||||
setupSingleInstanceLock,
|
||||
handleDeepLinkFromArgv
|
||||
} from './mainWindow.js'
|
||||
import { setupAppUpdateIPC } from './appupdate.js'
|
||||
|
||||
// --- 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 deserializeAuthSession = (storedValue) => {
|
||||
if (!storedValue) return null
|
||||
|
||||
if (typeof storedValue === 'object' && storedValue.encrypted === true) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
// --- 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(
|
||||
'[auth-session] Encrypted auth session exists but encryption is unavailable on this system.'
|
||||
'[keytar] Not available; auth session persistence will be disabled.',
|
||||
e?.message || e
|
||||
)
|
||||
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 KEYTAR_SERVICE = app.name || 'Farm Control'
|
||||
const KEYTAR_ACCOUNT = 'authSession'
|
||||
|
||||
const gotTheLock = setupSingleInstanceLock(app)
|
||||
|
||||
@ -83,7 +37,6 @@ if (gotTheLock) {
|
||||
registerGlobalShortcuts()
|
||||
setupSpotlightIPC()
|
||||
setupMainWindowIPC()
|
||||
setupAppUpdateIPC(app)
|
||||
setupMainWindowAppEvents(app)
|
||||
setupDevAuthServer()
|
||||
handleDeepLinkFromArgv()
|
||||
@ -103,52 +56,38 @@ ipcMain.handle('os-info', () => {
|
||||
|
||||
ipcMain.handle('auth-session-get', async () => {
|
||||
try {
|
||||
const storedValue = authStore.get(AUTH_SESSION_KEY)
|
||||
return deserializeAuthSession(storedValue)
|
||||
if (!keytar) return null
|
||||
const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.warn('[auth-session] Failed to read auth session.', e?.message || e)
|
||||
console.warn('[keytar] 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
|
||||
authStore.set(AUTH_SESSION_KEY, serializeAuthSession(session))
|
||||
await keytar.setPassword(
|
||||
KEYTAR_SERVICE,
|
||||
KEYTAR_ACCOUNT,
|
||||
JSON.stringify(session)
|
||||
)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[auth-session] Failed to write auth session.', e?.message || e)
|
||||
console.warn('[keytar] Failed to write auth session.', e?.message || e)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('auth-session-clear', async () => {
|
||||
try {
|
||||
authStore.delete(AUTH_SESSION_KEY)
|
||||
return true
|
||||
if (!keytar) return false
|
||||
return await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
|
||||
} catch (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)
|
||||
console.warn('[keytar] Failed to clear auth session.', e?.message || e)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -6,7 +6,6 @@ const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
let win
|
||||
let sidebarViewMenuSections = []
|
||||
|
||||
const PROTOCOL_PREFIX = 'farmcontrol://'
|
||||
|
||||
@ -41,76 +40,6 @@ 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`, '') || '/'
|
||||
@ -189,7 +118,29 @@ export function createWindow() {
|
||||
}
|
||||
})
|
||||
|
||||
applyApplicationMenu()
|
||||
// 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)
|
||||
}
|
||||
|
||||
// For development, load from localhost; for production, load the built index.html
|
||||
if (process.env.ELECTRON_START_URL) {
|
||||
@ -206,10 +157,6 @@ 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', () => {
|
||||
@ -261,18 +208,6 @@ 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) {
|
||||
|
||||
@ -1,449 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
13
src/App.jsx
@ -28,11 +28,9 @@ 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,
|
||||
@ -77,15 +75,10 @@ const AppContent = () => {
|
||||
<PrintServerProvider>
|
||||
<ApiServerProvider>
|
||||
<MessageProvider>
|
||||
<AppUpdateProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path='/applaunch'
|
||||
element={<AuthLaunch />}
|
||||
/>
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
@ -121,13 +114,10 @@ const AppContent = () => {
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => <Dashboard />}
|
||||
/>
|
||||
<PrivateRoute component={() => <Dashboard />} />
|
||||
}
|
||||
>
|
||||
{ProductionRoutes}
|
||||
@ -150,7 +140,6 @@ const AppContent = () => {
|
||||
</ActionsModalProvider>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
</AppUpdateProvider>
|
||||
</MessageProvider>
|
||||
</ApiServerProvider>
|
||||
</PrintServerProvider>
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
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
|
||||
@ -1,14 +1,50 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardSidebar from '../common/DashboardSidebar'
|
||||
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
|
||||
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'
|
||||
}
|
||||
|
||||
const DeveloperSidebar = (props) => {
|
||||
const location = useLocation()
|
||||
const includeDev = import.meta.env.MODE === 'development'
|
||||
const items = getSidebarItems('developer', { includeDev })
|
||||
const selectedKey = getSidebarSelectedKey('developer', location.pathname, {
|
||||
includeDev
|
||||
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'
|
||||
})()
|
||||
|
||||
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
|
||||
}
|
||||
|
||||
@ -1,14 +1,51 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardSidebar from '../common/DashboardSidebar'
|
||||
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
|
||||
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'
|
||||
}
|
||||
|
||||
const FinanceSidebar = (props) => {
|
||||
const location = useLocation()
|
||||
const includeDev = import.meta.env.MODE === 'development'
|
||||
const items = getSidebarItems('finance', { includeDev })
|
||||
const selectedKey = getSidebarSelectedKey('finance', location.pathname, {
|
||||
includeDev
|
||||
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'
|
||||
})()
|
||||
|
||||
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
|
||||
}
|
||||
|
||||
@ -80,7 +80,6 @@ const Invoices = () => {
|
||||
type='invoice'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -34,8 +34,6 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
|
||||
order: true,
|
||||
to: true,
|
||||
from: true,
|
||||
toType: true,
|
||||
fromType: true,
|
||||
issuedAt: true,
|
||||
dueAt: true
|
||||
}}
|
||||
@ -66,9 +64,7 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
|
||||
sentAt: false,
|
||||
paidAt: false,
|
||||
cancelledAt: false,
|
||||
overdueAt: false,
|
||||
acknowledgedAt: false,
|
||||
postedAt: false
|
||||
overdueAt: false
|
||||
}}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
|
||||
@ -80,7 +80,6 @@ const Payments = () => {
|
||||
type='payment'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -106,3 +105,4 @@ const Payments = () => {
|
||||
}
|
||||
|
||||
export default Payments
|
||||
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
@ -53,8 +53,6 @@ const NewPayment = ({ onOk, reset, defaultValues }) => {
|
||||
updatedAt: false,
|
||||
_reference: false,
|
||||
postedAt: false,
|
||||
authorisedAt: false,
|
||||
declinedAt: false,
|
||||
cancelledAt: false
|
||||
}}
|
||||
isEditing={false}
|
||||
|
||||
@ -24,9 +24,6 @@ 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)
|
||||
@ -51,9 +48,6 @@ 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: () => {
|
||||
@ -75,18 +69,6 @@ const PaymentInfo = () => {
|
||||
post: () => {
|
||||
setPostPaymentOpen(true)
|
||||
return true
|
||||
},
|
||||
authorise: () => {
|
||||
setAuthorisePaymentOpen(true)
|
||||
return true
|
||||
},
|
||||
decline: () => {
|
||||
setDeclinePaymentOpen(true)
|
||||
return true
|
||||
},
|
||||
cancel: () => {
|
||||
setCancelPaymentOpen(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,60 +234,6 @@ 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -86,7 +86,6 @@ const FilamentStocks = () => {
|
||||
type='filamentStock'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -1,14 +1,118 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardSidebar from '../common/DashboardSidebar'
|
||||
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
|
||||
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'
|
||||
}
|
||||
|
||||
const InventorySidebar = (props) => {
|
||||
const location = useLocation()
|
||||
const includeDev = import.meta.env.MODE === 'development'
|
||||
const items = getSidebarItems('inventory', { includeDev })
|
||||
const selectedKey = getSidebarSelectedKey('inventory', location.pathname, {
|
||||
includeDev
|
||||
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'
|
||||
})()
|
||||
|
||||
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
|
||||
}
|
||||
|
||||
@ -80,7 +80,6 @@ const OrderItems = () => {
|
||||
type='orderItem'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -86,7 +86,6 @@ const PartStocks = () => {
|
||||
type='partStock'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -87,7 +87,6 @@ const ProductStocks = () => {
|
||||
type='productStock'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -39,7 +39,6 @@ const NewProductStock = ({ onOk, reset, defaultValues }) => {
|
||||
bordered={false}
|
||||
visibleProperties={{
|
||||
_id: false,
|
||||
_reference: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
partStocks: false
|
||||
|
||||
@ -80,7 +80,6 @@ const PurchaseOrders = () => {
|
||||
type='purchaseOrder'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -80,7 +80,6 @@ const Shipments = () => {
|
||||
type='shipment'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -86,7 +86,6 @@ const StockAudits = () => {
|
||||
type='stockAudit'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -69,7 +69,6 @@ const StockEvents = () => {
|
||||
type='stockEvent'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -84,7 +84,6 @@ const StockLocations = () => {
|
||||
type='stockLocation'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -84,7 +84,6 @@ const StockTransfers = () => {
|
||||
type='stockTransfer'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
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
|
||||
@ -80,7 +80,6 @@ const AppPasswords = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -24,12 +24,25 @@ 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',
|
||||
@ -40,13 +53,11 @@ const NewAppPassword = ({ onOk, reset, defaultValues = {} }) => {
|
||||
bordered={false}
|
||||
visibleProperties={{
|
||||
_id: false,
|
||||
_reference: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
secret: false
|
||||
}}
|
||||
isEditing={false}
|
||||
labelWidth={70}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -1,248 +0,0 @@
|
||||
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
|
||||
@ -1,148 +0,0 @@
|
||||
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
|
||||
@ -62,7 +62,6 @@ const AuditLogs = () => {
|
||||
visibleColumns={columnVisibility}
|
||||
type='auditLog'
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -80,7 +80,6 @@ const CourierServices = () => {
|
||||
type='courierService'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -26,7 +26,6 @@ const NewCourierService = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={80}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -40,7 +39,6 @@ const NewCourierService = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={120}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -61,7 +59,6 @@ const NewCourierService = ({ onOk, defaultValues }) => {
|
||||
}}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
labelWidth={120}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,7 +79,6 @@ const Couriers = () => {
|
||||
type='courier'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -19,7 +19,6 @@ const NewCourier = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={75}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -34,7 +33,6 @@ const NewCourier = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={false}
|
||||
objectData={objectData}
|
||||
labelWidth={85}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -54,7 +52,6 @@ const NewCourier = ({ onOk, defaultValues }) => {
|
||||
}}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
labelWidth={85}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -80,7 +80,6 @@ const DocumentJobs = () => {
|
||||
type='documentJob'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -35,7 +35,6 @@ const NewDocumentJob = ({ onOk, defaultValues = {} }) => {
|
||||
column={1}
|
||||
visibleProperties={{ name: false }}
|
||||
bordered={false}
|
||||
labelWidth={115}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
|
||||
@ -79,7 +79,6 @@ const DocumentPrinters = () => {
|
||||
type='documentPrinter'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -78,7 +78,6 @@ const DocumentSizes = () => {
|
||||
type='documentSize'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -5,10 +5,7 @@ import WizardView from '../../common/WizardView'
|
||||
|
||||
const NewDocumentSize = ({ onOk, defaultValues }) => {
|
||||
return (
|
||||
<NewObjectForm
|
||||
type={'documentSize'}
|
||||
defaultValues={{ infiniteHeight: false, ...defaultValues }}
|
||||
>
|
||||
<NewObjectForm type={'documentSize'} defaultValues={{ ...defaultValues }}>
|
||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||
const steps = [
|
||||
{
|
||||
@ -22,7 +19,6 @@ const NewDocumentSize = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={120}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -40,7 +36,6 @@ const NewDocumentSize = ({ onOk, defaultValues }) => {
|
||||
createdAt: false,
|
||||
updatedAt: false
|
||||
}}
|
||||
labelWidth={120}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
/>
|
||||
|
||||
@ -80,7 +80,6 @@ const DocumentTemplates = () => {
|
||||
type='documentTemplate'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -21,7 +21,6 @@ const NewDocumentTemplate = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
labelWidth={130}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -84,7 +84,6 @@ const FilamentSkus = () => {
|
||||
type='filamentSku'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -8,11 +8,7 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
|
||||
<NewObjectForm
|
||||
type='filamentSku'
|
||||
reset={reset}
|
||||
defaultValues={{
|
||||
overrideCost: false,
|
||||
color: '#ff0000',
|
||||
...defaultValues
|
||||
}}
|
||||
defaultValues={defaultValues}
|
||||
>
|
||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||
const steps = [
|
||||
@ -23,7 +19,7 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
|
||||
<ObjectInfo
|
||||
type='filamentSku'
|
||||
column={1}
|
||||
labelWidth={80}
|
||||
labelWidth={70}
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
@ -33,26 +29,27 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
|
||||
cost: false,
|
||||
costWithTax: false,
|
||||
costTaxRate: false,
|
||||
overrideCost: false,
|
||||
vendor: false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Cost',
|
||||
key: 'cost',
|
||||
title: 'Color & Cost',
|
||||
key: 'colorCost',
|
||||
content: (
|
||||
<ObjectInfo
|
||||
type='filamentSku'
|
||||
column={1}
|
||||
labelWidth={120}
|
||||
required={true}
|
||||
labelWidth={100}
|
||||
visibleProperties={{
|
||||
overrideCost: true,
|
||||
cost: true,
|
||||
costTaxRate: true,
|
||||
costWithTax: true
|
||||
_id: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
barcode: false,
|
||||
filament: false,
|
||||
name: false,
|
||||
description: false
|
||||
}}
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
@ -67,7 +64,7 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
|
||||
<ObjectInfo
|
||||
type='filamentSku'
|
||||
column={1}
|
||||
labelWidth={110}
|
||||
labelWidth={100}
|
||||
visibleProperties={{
|
||||
barcode: true,
|
||||
description: true
|
||||
@ -88,12 +85,11 @@ const NewFilamentSku = ({ onOk, reset, defaultValues }) => {
|
||||
visibleProperties={{
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
_id: false,
|
||||
_reference: false
|
||||
_id: false
|
||||
}}
|
||||
labelWidth={100}
|
||||
bordered={false}
|
||||
isEditing={false}
|
||||
labelWidth={120}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -86,7 +86,6 @@ const Filaments = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -238,7 +238,7 @@ const FilamentInfo = () => {
|
||||
}}
|
||||
reset={newFilamentSkuOpen}
|
||||
defaultValues={{
|
||||
filament: objectFormState?.objectData || undefined
|
||||
filament: filamentId ? { _id: filamentId } : undefined
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -18,33 +18,7 @@ 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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -58,7 +32,6 @@ const NewFilament = ({ onOk }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={90}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -77,7 +50,6 @@ const NewFilament = ({ onOk }) => {
|
||||
createdAt: false,
|
||||
updatedAt: false
|
||||
}}
|
||||
labelWidth={120}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
/>
|
||||
|
||||
@ -68,7 +68,6 @@ const Files = () => {
|
||||
type='file'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -85,7 +85,6 @@ const Hosts = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -85,6 +85,11 @@ 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}
|
||||
@ -95,11 +100,6 @@ 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>
|
||||
|
||||
@ -64,7 +64,6 @@ const NewHost = ({ onOk }) => {
|
||||
visibleProperties={{
|
||||
_id: false,
|
||||
createdAt: false,
|
||||
_reference: false,
|
||||
updatedAt: false,
|
||||
operatingSystem: false,
|
||||
'deviceInfo.os': false,
|
||||
|
||||
@ -1,14 +1,244 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardSidebar from '../common/DashboardSidebar'
|
||||
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
|
||||
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'
|
||||
}
|
||||
|
||||
const ManagementSidebar = (props) => {
|
||||
const location = useLocation()
|
||||
const includeDev = import.meta.env.MODE === 'development'
|
||||
const items = getSidebarItems('management', { includeDev })
|
||||
const selectedKey = getSidebarSelectedKey('management', location.pathname, {
|
||||
includeDev
|
||||
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'
|
||||
})()
|
||||
|
||||
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
|
||||
}
|
||||
|
||||
@ -83,7 +83,6 @@ const Materials = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -18,7 +18,6 @@ const NewMaterial = ({ onOk }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
labelWidth={70}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -33,7 +32,6 @@ const NewMaterial = ({ onOk }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={62}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -53,7 +51,6 @@ const NewMaterial = ({ onOk }) => {
|
||||
updatedAt: false
|
||||
}}
|
||||
isEditing={false}
|
||||
labelWidth={70}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -80,7 +80,6 @@ const NoteTypes = () => {
|
||||
type='noteType'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -23,7 +23,6 @@ const NewNoteType = ({ onOk }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
labelWidth={72}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -39,7 +38,6 @@ const NewNoteType = ({ onOk }) => {
|
||||
isEditing={true}
|
||||
required={false}
|
||||
objectData={objectData}
|
||||
labelWidth={65}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -59,7 +57,6 @@ const NewNoteType = ({ onOk }) => {
|
||||
}}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
labelWidth={70}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -83,7 +83,6 @@ const PartSkus = () => {
|
||||
type='partSku'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -8,11 +8,7 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
|
||||
<NewObjectForm
|
||||
type='partSku'
|
||||
reset={reset}
|
||||
defaultValues={{
|
||||
overrideCost: false,
|
||||
overridePrice: false,
|
||||
...defaultValues
|
||||
}}
|
||||
defaultValues={defaultValues}
|
||||
>
|
||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||
const steps = [
|
||||
@ -36,8 +32,6 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
|
||||
costTaxRate: false,
|
||||
price: false,
|
||||
priceWithTax: false,
|
||||
overrideCost: false,
|
||||
overridePrice: false,
|
||||
margin: false,
|
||||
amount: false,
|
||||
priceTaxRate: false,
|
||||
@ -53,18 +47,15 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
|
||||
<ObjectInfo
|
||||
type='partSku'
|
||||
column={1}
|
||||
labelWidth={120}
|
||||
labelWidth={100}
|
||||
visibleProperties={{
|
||||
overrideCost: true,
|
||||
cost: true,
|
||||
costTaxRate: true,
|
||||
costWithTax: true,
|
||||
overridePrice: true,
|
||||
priceMode: true,
|
||||
price: true,
|
||||
margin: true,
|
||||
priceTaxRate: true,
|
||||
priceWithTax: true
|
||||
_id: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
barcode: false,
|
||||
part: false,
|
||||
name: false,
|
||||
description: false
|
||||
}}
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
@ -100,10 +91,9 @@ const NewPartSku = ({ onOk, reset, defaultValues }) => {
|
||||
visibleProperties={{
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
_id: false,
|
||||
_reference: false
|
||||
_id: false
|
||||
}}
|
||||
labelWidth={120}
|
||||
labelWidth={100}
|
||||
bordered={false}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
|
||||
@ -85,7 +85,6 @@ const Parts = (filter) => {
|
||||
cards={viewMode === 'cards'}
|
||||
filter={filter}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -26,7 +26,6 @@ const NewPart = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={70}
|
||||
visibleProperties={{
|
||||
file: false,
|
||||
priceMode: false,
|
||||
@ -52,7 +51,6 @@ const NewPart = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={120}
|
||||
visibleProperties={{
|
||||
priceMode: true,
|
||||
margin: true,
|
||||
@ -76,7 +74,6 @@ const NewPart = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={50}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -193,7 +193,7 @@ const PartInfo = () => {
|
||||
}}
|
||||
reset={newPartSkuOpen}
|
||||
defaultValues={{
|
||||
part: objectFormState?.objectData || undefined
|
||||
part: partId ? { _id: partId } : undefined
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -83,7 +83,6 @@ const ProductCategories = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -19,7 +19,6 @@ const NewProductCategory = ({ onOk }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={70}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -39,7 +38,6 @@ const NewProductCategory = ({ onOk }) => {
|
||||
}}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
labelWidth={70}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -84,7 +84,6 @@ const ProductSkus = () => {
|
||||
type='productSku'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -8,11 +8,7 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
|
||||
<NewObjectForm
|
||||
type='productSku'
|
||||
reset={reset}
|
||||
defaultValues={{
|
||||
overrideCost: false,
|
||||
overridePrice: false,
|
||||
...defaultValues
|
||||
}}
|
||||
defaultValues={defaultValues}
|
||||
>
|
||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||
const steps = [
|
||||
@ -23,7 +19,7 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
|
||||
<ObjectInfo
|
||||
type='productSku'
|
||||
column={1}
|
||||
labelWidth={80}
|
||||
labelWidth={70}
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
@ -39,8 +35,6 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
|
||||
margin: false,
|
||||
amount: false,
|
||||
priceTaxRate: false,
|
||||
overrideCost: false,
|
||||
overridePrice: false,
|
||||
vendor: false,
|
||||
parts: false
|
||||
}}
|
||||
@ -54,18 +48,49 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
|
||||
<ObjectInfo
|
||||
type='productSku'
|
||||
column={1}
|
||||
labelWidth={120}
|
||||
labelWidth={100}
|
||||
visibleProperties={{
|
||||
overrideCost: true,
|
||||
cost: true,
|
||||
costTaxRate: true,
|
||||
costWithTax: true,
|
||||
overridePrice: true,
|
||||
priceMode: true,
|
||||
price: true,
|
||||
margin: true,
|
||||
priceTaxRate: true,
|
||||
priceWithTax: true
|
||||
_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
|
||||
}}
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
@ -101,11 +126,9 @@ const NewProductSku = ({ onOk, reset, defaultValues }) => {
|
||||
visibleProperties={{
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
_id: false,
|
||||
_reference: false,
|
||||
parts: false
|
||||
_id: false
|
||||
}}
|
||||
labelWidth={120}
|
||||
labelWidth={100}
|
||||
bordered={false}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
|
||||
@ -130,6 +130,13 @@ const ProductSkuInfo = () => {
|
||||
actions={actions}
|
||||
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}
|
||||
@ -141,16 +148,7 @@ const ProductSkuInfo = () => {
|
||||
}}
|
||||
>
|
||||
{({ 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}
|
||||
@ -160,29 +158,26 @@ const ProductSkuInfo = () => {
|
||||
parts: false
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ObjectForm>
|
||||
</InfoCollapse>
|
||||
</ActionHandler>
|
||||
<InfoCollapse
|
||||
title='SKU Parts'
|
||||
icon={<PartIcon />}
|
||||
active={collapseState.parts}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('parts', expanded)
|
||||
}
|
||||
onToggle={(expanded) => updateCollapseState('parts', expanded)}
|
||||
collapseKey='parts'
|
||||
>
|
||||
<ObjectProperty
|
||||
{...getModelProperty('productSku', 'parts')}
|
||||
isEditing={isEditing}
|
||||
objectData={objectData}
|
||||
loading={loading}
|
||||
isEditing={objectFormState.isEditing}
|
||||
objectData={objectFormState.objectData}
|
||||
loading={objectFormState.loading}
|
||||
size='medium'
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
)}
|
||||
</ObjectForm>
|
||||
</ActionHandler>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
@ -214,7 +209,6 @@ const ProductSkuInfo = () => {
|
||||
)}
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -355,7 +355,6 @@ const Products = () => {
|
||||
type={'product'}
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -197,7 +197,7 @@ const ProductInfo = () => {
|
||||
}}
|
||||
reset={newProductSkuOpen}
|
||||
defaultValues={{
|
||||
product: objectFormState?.objectData || undefined
|
||||
product: productId ? { _id: productId } : undefined
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -1,218 +1,83 @@
|
||||
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd'
|
||||
import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
|
||||
import { CaretLeftOutlined } 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 { Text } = Typography
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||
|
||||
const Settings = () => {
|
||||
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 {
|
||||
isDarkMode,
|
||||
toggleTheme,
|
||||
isCompact,
|
||||
toggleCompact,
|
||||
isSystem,
|
||||
toggleSystem
|
||||
} = useThemeContext()
|
||||
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
setSettingsLoading(true)
|
||||
const storedSettings = isElectron
|
||||
? await getAppSettings()
|
||||
: userProfile?.settings || {}
|
||||
setAppSettingsState(storedSettings || {})
|
||||
setDraftSettings(storedSettings || {})
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
|
||||
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
|
||||
appearance: true
|
||||
})
|
||||
|
||||
setBranchLoading(false)
|
||||
const handleThemeChange = (value) => {
|
||||
if (value === 'system') {
|
||||
toggleSystem()
|
||||
} else {
|
||||
if (isSystem) {
|
||||
toggleSystem()
|
||||
}
|
||||
if (value === 'dark' && !isDarkMode) {
|
||||
toggleTheme()
|
||||
} else if (value === 'light' && isDarkMode) {
|
||||
toggleTheme()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 handleCompactChange = (value) => {
|
||||
if (value === 'compact' && !isCompact) {
|
||||
toggleCompact()
|
||||
} else if (value === 'comfortable' && isCompact) {
|
||||
toggleCompact()
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentThemeValue = () => {
|
||||
if (isSystem) return 'system'
|
||||
return isDarkMode ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
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 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)
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.appearance ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('appearance', keys.length > 0)
|
||||
}
|
||||
collapseKey='appearance'
|
||||
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
|
||||
@ -226,84 +91,31 @@ const Settings = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='Theme'>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draftSettings.theme}
|
||||
onChange={(value) =>
|
||||
setDraftSettings((previous) => ({
|
||||
...previous,
|
||||
theme: value
|
||||
}))
|
||||
}
|
||||
value={getCurrentThemeValue()}
|
||||
onChange={handleThemeChange}
|
||||
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
|
||||
}))
|
||||
}
|
||||
value={isCompact ? 'compact' : 'comfortable'}
|
||||
onChange={handleCompactChange}
|
||||
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>
|
||||
)}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</Spin>
|
||||
</div>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -79,7 +79,6 @@ const TaxRates = () => {
|
||||
type='taxRate'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -5,10 +5,7 @@ import WizardView from '../../common/WizardView'
|
||||
|
||||
const NewTaxRate = ({ onOk, defaultValues }) => {
|
||||
return (
|
||||
<NewObjectForm
|
||||
type={'taxRate'}
|
||||
defaultValues={{ active: true, ...defaultValues }}
|
||||
>
|
||||
<NewObjectForm type={'taxRate'} defaultValues={{ ...defaultValues }}>
|
||||
{({ handleSubmit, submitLoading, objectData, formValid }) => {
|
||||
const steps = [
|
||||
{
|
||||
@ -22,7 +19,6 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
|
||||
isEditing={true}
|
||||
required={true}
|
||||
objectData={objectData}
|
||||
labelWidth={100}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -36,7 +32,6 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={130}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -55,7 +50,6 @@ const NewTaxRate = ({ onOk, defaultValues }) => {
|
||||
createdAt: false,
|
||||
updatedAt: false
|
||||
}}
|
||||
labelWidth={130}
|
||||
isEditing={false}
|
||||
objectData={objectData}
|
||||
/>
|
||||
|
||||
@ -80,7 +80,6 @@ const TaxRecords = () => {
|
||||
type='taxRecord'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -47,10 +47,11 @@ 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}
|
||||
|
||||
@ -79,7 +79,6 @@ const Vendors = () => {
|
||||
type='vendor'
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -21,7 +21,6 @@ const NewVendor = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={true}
|
||||
labelWidth={80}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -36,7 +35,6 @@ const NewVendor = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
isEditing={true}
|
||||
required={false}
|
||||
labelWidth={85}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
@ -56,7 +54,6 @@ const NewVendor = ({ onOk, defaultValues }) => {
|
||||
updatedAt: false
|
||||
}}
|
||||
isEditing={false}
|
||||
labelWidth={80}
|
||||
objectData={objectData}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -82,7 +82,6 @@ const GCodeFiles = () => {
|
||||
cards={viewMode === 'cards'}
|
||||
visibleColumns={columnVisibility}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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'}
|
||||
@ -76,6 +78,7 @@ const NewGCodeFile = ({ onOk, defaultValues }) => {
|
||||
onSubmit={async () => {
|
||||
const result = await handleSubmit()
|
||||
if (result) {
|
||||
showSuccess('Finished uploading GCode file!')
|
||||
onOk()
|
||||
}
|
||||
}}
|
||||
|
||||
@ -86,7 +86,6 @@ const Jobs = () => {
|
||||
visibleColumns={columnVisibility}
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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'}
|
||||
@ -40,7 +42,6 @@ const NewJob = ({ onOk, defaultValues }) => {
|
||||
labelWidth='100px'
|
||||
visibleProperties={{
|
||||
_id: false,
|
||||
_reference: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
startedAt: false,
|
||||
@ -62,6 +63,7 @@ const NewJob = ({ onOk, defaultValues }) => {
|
||||
onSubmit={async () => {
|
||||
const result = await handleSubmit()
|
||||
if (result) {
|
||||
showSuccess('New job created successfully.')
|
||||
onOk()
|
||||
}
|
||||
}}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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'}
|
||||
@ -68,7 +70,6 @@ const NewPrinter = ({ onOk, defaultValues }) => {
|
||||
bordered={false}
|
||||
visibleProperties={{
|
||||
_id: false,
|
||||
_reference: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
connectedAt: false,
|
||||
@ -97,6 +98,7 @@ const NewPrinter = ({ onOk, defaultValues }) => {
|
||||
onSubmit={async () => {
|
||||
const result = await handleSubmit()
|
||||
if (result) {
|
||||
showSuccess('New printer added successfully.')
|
||||
onOk()
|
||||
}
|
||||
}}
|
||||
|
||||
@ -1,14 +1,67 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardSidebar from '../common/DashboardSidebar'
|
||||
import { getSidebarItems, getSidebarSelectedKey } from '../../../database/Sidebars'
|
||||
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'
|
||||
}
|
||||
|
||||
const ProductionSidebar = (props) => {
|
||||
const location = useLocation()
|
||||
const includeDev = import.meta.env.MODE === 'development'
|
||||
const items = getSidebarItems('production', { includeDev })
|
||||
const selectedKey = getSidebarSelectedKey('production', location.pathname, {
|
||||
includeDev
|
||||
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'
|
||||
})()
|
||||
|
||||
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
|
||||
}
|
||||
|
||||
@ -71,7 +71,6 @@ const SubJobs = () => {
|
||||
visibleColumns={columnVisibility}
|
||||
cards={viewMode === 'cards'}
|
||||
showFilterSidebar={showFilterSidebar}
|
||||
expandHeight={true}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||