Refactor file handling and update API methods for improved file management. Renamed fetchObjectContent to fetchFileContent, updated related function calls, and enhanced file preview capabilities. Removed unused components and optimized imports.

This commit is contained in:
Tom Butcher 2025-11-16 17:02:15 +00:00
parent ae33f27dfb
commit 48631c98c3
55 changed files with 20531 additions and 2160 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

38
design_files/mainlogo.svg Normal file
View File

@ -0,0 +1,38 @@
<?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 1026 416" 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.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.94)">
<path d="M10500,13761C10385,13730 9777,13525 9707,13494C9696,13488 9672,13479 9655,13473C9555,13440 8733,13121 8657,13087C8640,13080 8515,13028 8379,12972C8243,12917 8118,12865 8101,12857C8043,12831 7770,12716 7765,12716C7762,12716 7735,12704 7705,12691C7583,12636 7499,12600 7424,12570C7380,12553 7243,12495 7119,12442C6995,12389 6858,12332 6815,12315C6771,12297 6722,12277 6704,12269C6687,12261 6630,12238 6578,12217C6526,12196 6459,12167 6428,12153L6373,12127L6376,12020C6379,11899 6378,11900 6462,11939C6486,11949 6554,11978 6615,12002C6676,12026 6750,12057 6781,12071C6811,12085 6865,12108 6901,12123C6937,12138 6981,12157 6998,12165C7016,12172 7103,12209 7193,12247C7452,12354 8182,12662 8216,12678C8234,12686 8338,12729 8447,12773C8557,12818 8661,12860 8678,12868C8704,12880 9014,13002 9382,13146C9658,13253 10378,13503 10521,13540C10626,13567 10780,13549 10902,13494C10951,13472 11490,13252 11702,13167C11777,13137 11874,13099 11918,13081C11961,13064 12091,13012 12206,12966C12322,12920 12426,12879 12437,12874C12511,12842 12547,12827 12684,12773C12830,12716 12861,12703 12940,12668C12960,12660 12979,12653 12983,12653C12987,12653 13016,12641 13047,12626C13079,12612 13107,12600 13111,12600C13114,12600 13131,12593 13150,12585C13168,12578 13199,12564 13220,12555C13240,12546 13280,12529 13309,12518C13338,12507 13393,12483 13432,12465C13472,12447 13506,12432 13509,12432C13511,12432 13626,12383 13763,12322C14071,12188 14182,12137 14519,11974C14608,11931 14685,11897 14690,11897C14696,11897 14700,11945 14700,12007L14700,12118L14582,12174C14517,12204 14424,12249 14375,12272C14203,12353 14000,12446 13813,12528C13709,12573 13602,12621 13577,12634L13529,12658L13529,12962C13529,13130 13528,13273 13527,13280C13525,13290 13488,13293 13369,13291L13214,13288L13211,13043L13209,12799L13180,12806C13164,12810 13090,12838 13015,12868C12797,12957 12414,13112 12311,13153C12259,13174 12203,13197 12185,13204C12168,13211 12073,13249 11975,13288C11877,13326 11783,13365 11765,13372C11748,13380 11649,13420 11545,13461C11441,13502 11342,13542 11324,13550C11307,13558 11215,13595 11120,13634C11024,13673 10932,13711 10915,13719C10864,13743 10680,13787 10633,13786C10609,13786 10549,13774 10500,13761Z" style="fill-rule:nonzero;"/>
<path d="M3927,11427C3927,10789 3929,10675 3943,10628C3961,10566 4002,10529 4069,10513C4130,10497 4306,10488 4384,10496L4452,10503L4452,10752L4390,10752C4302,10752 4277,10764 4263,10812C4255,10838 4253,10970 4255,11203L4258,11555L4299,11558C4392,11565 4444,11622 4450,11723L4454,11792L4254,11792L4251,11927C4248,12046 4244,12067 4225,12093C4186,12145 4146,12160 4032,12167L3927,12173L3927,11427Z" style="fill-rule:nonzero;"/>
<path d="M4512,12164C4508,12159 4505,11781 4505,11323L4505,10490L4670,10489L4835,10489L4832,10807C4829,11160 4838,11272 4878,11361C4929,11472 4991,11512 5114,11512C5240,11512 5316,11456 5357,11331C5378,11266 5380,11238 5384,10875L5388,10490L5713,10490L5709,10959C5706,11369 5703,11437 5687,11492C5634,11672 5537,11758 5345,11797C5244,11818 5163,11812 5073,11779C5004,11753 4922,11701 4878,11656C4861,11638 4844,11624 4840,11624C4836,11624 4832,11724 4831,11846C4828,12089 4824,12108 4767,12142C4735,12161 4526,12179 4512,12164Z" style="fill-rule:nonzero;"/>
<path d="M7032,12164C7028,12159 7025,11781 7025,11323L7025,10490L7359,10490L7363,10891L7366,11293L7396,11359C7434,11440 7479,11481 7556,11504C7655,11532 7768,11503 7826,11434C7894,11353 7906,11262 7906,10839L7907,10490L8233,10490L8230,10965C8226,11428 8225,11442 8202,11513C8174,11597 8158,11624 8107,11680C8024,11768 7835,11826 7697,11805C7601,11790 7505,11746 7427,11679L7362,11624L7359,11842C7355,12082 7348,12112 7287,12143C7253,12161 7045,12177 7032,12164Z" style="fill-rule:nonzero;"/>
<path d="M8323,12163C8319,12159 8316,12100 8316,12031L8316,11907L8642,11907L8642,11986C8642,12128 8607,12159 8437,12166C8378,12168 8327,12167 8323,12163Z" style="fill-rule:nonzero;"/>
<path d="M9626,12164C9622,12159 9618,12035 9618,11887L9618,11619L9570,11673C9471,11787 9307,11835 9140,11799C9026,11774 8969,11744 8892,11665C8835,11607 8817,11580 8791,11512C8742,11384 8732,11320 8732,11135C8732,10946 8747,10864 8800,10759C8845,10671 8879,10629 8950,10577C9038,10512 9150,10478 9303,10471C9656,10456 9868,10594 9927,10880C9941,10948 9944,11048 9944,11498C9944,12097 9945,12085 9871,12133C9841,12153 9813,12159 9734,12165C9679,12169 9630,12168 9626,12164ZM9406,11507C9533,11480 9599,11375 9614,11177C9625,11033 9589,10890 9526,10822C9481,10774 9423,10752 9340,10752C9176,10752 9090,10846 9072,11043C9056,11218 9094,11381 9167,11451C9197,11480 9289,11518 9331,11518C9344,11518 9378,11514 9406,11507Z" style="fill-rule:nonzero;"/>
<path d="M13652,11401L13655,10630L13685,10585C13703,10558 13729,10534 13752,10524C13806,10502 13974,10488 14083,10496L14175,10503L14175,10752L14101,10752C14036,10752 14024,10755 14001,10778L13975,10804L13978,11179L13981,11555L14029,11562C14123,11575 14175,11639 14175,11739L14175,11792L13977,11792L13974,11925C13971,12046 13968,12063 13946,12094C13910,12144 13875,12157 13756,12165L13650,12173L13652,11401Z" style="fill-rule:nonzero;"/>
<path d="M6252,11803C5928,11751 5773,11513 5789,11093C5797,10880 5838,10752 5933,10640C6024,10535 6160,10474 6328,10462C6621,10442 6829,10563 6916,10802C6930,10839 6941,10873 6941,10878C6941,10892 6687,10891 6651,10878C6636,10872 6604,10851 6579,10831C6511,10775 6452,10752 6375,10752C6231,10752 6158,10827 6127,11007L6121,11046L6964,11046L6959,11196C6955,11284 6946,11371 6936,11408C6882,11607 6768,11730 6584,11786C6517,11806 6330,11815 6252,11803ZM6474,11520C6531,11494 6578,11435 6598,11362C6607,11329 6615,11296 6615,11289C6615,11280 6553,11277 6374,11277C6100,11277 6119,11269 6150,11369C6172,11440 6228,11504 6284,11524C6343,11544 6423,11543 6474,11520Z" style="fill-rule:nonzero;"/>
<path d="M10433,11797C10217,11752 10077,11612 10020,11386C10007,11333 10002,11269 10002,11141C10002,10943 10016,10870 10077,10745C10184,10524 10457,10414 10749,10475C10912,10508 11032,10599 11102,10742C11123,10785 11144,10836 11147,10855L11154,10890L11006,10887C10871,10884 10856,10881 10829,10859C10813,10846 10774,10818 10742,10796C10687,10758 10683,10757 10590,10757C10506,10757 10491,10760 10458,10783C10396,10827 10360,10885 10348,10962C10344,10988 10339,11017 10336,11028C10331,11045 10358,11046 10754,11049L11177,11051L11175,11167C11170,11454 11079,11648 10908,11739C10873,11758 10820,11780 10790,11788C10710,11810 10519,11815 10433,11797ZM10684,11520C10741,11494 10790,11436 10809,11370C10839,11269 10858,11277 10583,11277L10340,11277L10347,11316C10365,11416 10428,11500 10505,11527C10555,11544 10638,11541 10684,11520Z" style="fill-rule:nonzero;"/>
<path d="M11684,11802C11546,11778 11429,11711 11357,11613C11263,11485 11231,11345 11238,11094C11241,10982 11248,10904 11260,10862C11334,10594 11525,10458 11825,10458C12110,10459 12285,10575 12373,10825C12405,10913 12405,10918 12406,11125C12406,11313 12403,11343 12382,11418C12368,11464 12345,11526 12329,11555C12289,11629 12203,11710 12126,11748C12013,11804 11829,11826 11684,11802ZM11915,11502C11936,11493 11965,11476 11979,11463C12097,11355 12099,10947 11982,10822C11924,10759 11813,10734 11736,10766C11619,10815 11568,10928 11568,11141C11568,11333 11621,11459 11723,11504C11766,11523 11868,11522 11915,11502Z" style="fill-rule:nonzero;"/>
<path d="M8316,11792L8316,10490L8642,10490L8641,11091C8641,11675 8641,11693 8620,11727C8590,11777 8545,11792 8421,11792L8316,11792Z" style="fill-rule:nonzero;"/>
<path d="M12487,11332C12493,10833 12497,10794 12555,10687C12590,10622 12635,10583 12725,10538C12834,10482 12905,10466 13030,10465C13161,10465 13255,10486 13364,10539C13449,10580 13494,10625 13531,10706C13581,10814 13587,10887 13587,11357L13587,11792L13474,11791C13381,11791 13355,11788 13328,11771C13267,11734 13267,11738 13262,11282C13256,10828 13256,10832 13197,10776C13160,10742 13117,10729 13039,10729C12942,10728 12877,10760 12844,10825C12821,10872 12821,10876 12821,11332L12821,11792L12482,11792L12487,11332Z" style="fill-rule:nonzero;"/>
<g transform="matrix(15.8122,-2.42153e-14,-2.42153e-14,-15.8122,3391.19,15795.7)">
<path d="M85.585,376.285L85.585,396.867C85.585,398.121 86.368,399.061 87.674,399.061C88.98,399.061 89.816,398.121 89.816,396.867L89.816,386.994L90.077,386.994C91.383,389.501 94.622,391.487 99.062,391.487C106.376,391.487 111.809,386.001 111.809,378.322C111.809,370.538 106.376,364.635 98.174,364.635C91.226,364.635 85.585,368.867 85.585,376.285ZM89.816,379.367L89.816,376.494C89.816,371.113 93.264,368.553 98.227,368.553C104.391,368.553 107.525,372.576 107.525,378.113C107.525,383.859 104.391,387.621 98.697,387.621C93.473,387.621 89.816,384.591 89.816,379.367Z" style="fill-rule:nonzero;"/>
<path d="M132.078,365.314C131.19,364.949 130.25,364.635 128.996,364.635C125.6,364.635 122.779,367.09 121.943,369.023L121.735,369.023L121.735,367.195C121.735,365.941 120.899,365.001 119.593,365.001C118.287,365.001 117.503,365.941 117.503,367.195L117.503,388.927C117.503,390.181 118.287,391.121 119.593,391.121C120.899,391.121 121.735,390.181 121.735,388.927L121.735,376.755C121.735,371.897 124.399,368.605 128.578,368.605C129.675,368.605 130.406,368.919 131.033,369.18C131.19,369.232 131.503,369.285 131.66,369.285C132.809,369.285 133.645,368.396 133.645,367.299C133.645,366.307 133.018,365.628 132.078,365.314Z" style="fill-rule:nonzero;"/>
<path d="M150.519,391.487C158.302,391.487 163.944,385.792 163.944,378.061C163.944,370.329 158.302,364.635 150.519,364.635C142.787,364.635 137.145,370.329 137.145,378.061C137.145,385.792 142.787,391.487 150.519,391.487ZM141.377,378.061C141.377,372.576 145.034,368.553 150.519,368.553C156.004,368.553 159.713,372.576 159.713,378.061C159.713,383.546 156.004,387.569 150.519,387.569C145.034,387.569 141.377,383.546 141.377,378.061Z" style="fill-rule:nonzero;"/>
<path d="M169.691,376.285L169.691,396.867C169.691,398.121 170.474,399.061 171.78,399.061C173.086,399.061 173.922,398.121 173.922,396.867L173.922,386.994L174.183,386.994C175.489,389.501 178.728,391.487 183.169,391.487C190.482,391.487 195.915,386.001 195.915,378.322C195.915,370.538 190.482,364.635 182.281,364.635C175.333,364.635 169.691,368.867 169.691,376.285ZM173.922,379.367L173.922,376.494C173.922,371.113 177.37,368.553 182.333,368.553C188.497,368.553 191.631,372.576 191.631,378.113C191.631,383.859 188.497,387.621 182.803,387.621C177.579,387.621 173.922,384.591 173.922,379.367Z" style="fill-rule:nonzero;"/>
<path d="M221.147,375.606L204.9,375.606C205.736,371.165 208.975,368.344 213.363,368.344C217.699,368.344 221.147,371.165 221.147,375.606ZM200.46,378.061C200.46,385.74 205.632,391.487 213.99,391.487C217.386,391.487 220.311,390.389 222.244,388.979C223.237,388.352 223.498,387.725 223.498,387.098C223.498,386.054 222.871,385.061 221.565,385.061C221.147,385.061 220.625,385.165 219.841,385.74C218.535,386.785 216.602,387.621 214.095,387.621C208.975,387.621 205.266,384.33 204.744,379.158L221.669,379.158C222.662,379.158 225.274,378.844 225.274,374.979C225.274,369.441 220.363,364.635 213.363,364.635C205.997,364.635 200.46,370.173 200.46,378.061Z" style="fill-rule:nonzero;"/>
<path d="M245.23,365.314C244.342,364.949 243.401,364.635 242.147,364.635C238.752,364.635 235.931,367.09 235.095,369.023L234.886,369.023L234.886,367.195C234.886,365.941 234.05,365.001 232.744,365.001C231.438,365.001 230.655,365.941 230.655,367.195L230.655,388.927C230.655,390.181 231.438,391.121 232.744,391.121C234.05,391.121 234.886,390.181 234.886,388.927L234.886,376.755C234.886,371.897 237.55,368.605 241.73,368.605C242.827,368.605 243.558,368.919 244.185,369.18C244.342,369.232 244.655,369.285 244.812,369.285C245.961,369.285 246.797,368.396 246.797,367.299C246.797,366.307 246.17,365.628 245.23,365.314Z" style="fill-rule:nonzero;"/>
<path d="M254.79,359.254L254.79,365.001L252.073,365.001C250.819,365.001 249.879,365.732 249.879,366.934C249.879,368.188 250.819,368.919 252.073,368.919L254.79,368.919L254.79,381.718C254.79,388.561 258.551,391.121 263.618,391.121L265.029,391.121C266.282,391.121 267.118,390.389 267.118,389.136C267.118,387.83 266.282,387.098 265.029,387.098L263.618,387.098C260.902,387.098 259.021,385.74 259.021,381.718L259.021,368.919L264.872,368.919C266.126,368.919 267.014,368.188 267.014,366.934C267.014,365.732 266.126,365.001 264.872,365.001L259.021,365.001L259.021,359.254C259.021,357.948 258.185,357.008 256.879,357.008C255.573,357.008 254.79,357.948 254.79,359.254Z" style="fill-rule:nonzero;"/>
<path d="M294.022,365.001C292.716,365.001 291.932,365.941 291.932,367.195L291.932,381.352C291.932,384.016 289.79,387.307 284.253,387.307C280.178,387.307 277.409,385.531 277.409,380.882L277.409,367.195C277.409,365.941 276.574,365.001 275.268,365.001C273.962,365.001 273.178,365.941 273.178,367.195L273.178,380.882C273.178,388.091 277.723,391.121 283.26,391.121C288.014,391.121 290.731,388.875 291.671,386.942L291.932,386.942L291.932,388.718C291.932,394.778 288.693,397.703 283.73,397.703C281.275,397.703 279.499,397.024 278.036,396.345C277.723,396.188 277.357,396.136 277.096,396.136C275.894,396.136 275.006,397.024 275.006,398.173C275.006,398.8 275.268,399.427 275.999,399.949C277.671,401.151 280.857,401.726 283.574,401.726C291.253,401.726 296.163,396.658 296.163,388.979L296.163,367.195C296.163,365.941 295.328,365.001 294.022,365.001Z" style="fill-rule:nonzero;"/>
<path d="M342.448,364.635C338.687,364.635 335.866,366.202 334.246,368.292C332.627,365.889 329.806,364.635 327.194,364.635C324.007,364.635 321.186,366.673 320.403,367.874L320.194,367.874L320.194,366.986C320.089,365.837 319.306,365.001 318.052,365.001C316.746,365.001 315.962,365.941 315.962,367.195L315.962,388.927C315.962,390.181 316.746,391.121 318.052,391.121C319.358,391.121 320.194,390.181 320.194,388.927L320.194,373.412C320.194,370.643 322.754,368.449 325.888,368.449C329.022,368.449 331.582,370.695 331.582,373.412L331.582,388.927C331.582,390.181 332.366,391.121 333.672,391.121C334.978,391.121 335.814,390.181 335.814,388.927L335.814,373.412C335.814,370.695 338.373,368.449 341.508,368.449C344.694,368.449 347.202,370.434 347.202,373.673L347.202,388.927C347.202,390.181 347.985,391.121 349.291,391.121C350.597,391.121 351.381,390.181 351.381,388.927L351.381,373.464C351.381,367.717 347.202,364.635 342.448,364.635Z" style="fill-rule:nonzero;"/>
<path d="M369.926,391.487C375.15,391.487 377.762,388.718 378.807,387.151L379.12,387.151L379.12,388.979C379.12,390.181 379.904,391.121 381.21,391.121C382.516,391.121 383.3,390.181 383.3,388.927L383.3,376.128C383.3,368.867 377.71,364.635 370.553,364.635C362.56,364.635 357.127,370.538 357.127,378.27C357.127,386.001 362.508,391.487 369.926,391.487ZM361.359,378.061C361.359,372.576 364.493,368.553 370.658,368.553C375.62,368.553 379.12,371.113 379.12,376.337L379.12,379.367C379.12,384.277 375.725,387.621 370.187,387.621C364.493,387.621 361.359,383.859 361.359,378.061Z" style="fill-rule:nonzero;"/>
<path d="M403.412,364.635C398.972,364.635 396.098,366.725 394.688,368.605L394.427,368.605L394.427,367.195C394.427,365.941 393.591,365.001 392.285,365.001C390.979,365.001 390.195,365.941 390.195,367.195L390.195,388.927C390.195,390.181 390.979,391.121 392.285,391.121C393.591,391.121 394.427,390.181 394.427,388.927L394.427,374.509C394.427,371.531 396.725,368.449 402.106,368.449C406.181,368.449 408.949,370.225 408.949,374.874L408.949,388.927C408.949,390.181 409.733,391.121 411.039,391.121C412.345,391.121 413.181,390.181 413.181,388.927L413.181,374.874C413.181,367.613 408.793,364.635 403.412,364.635Z" style="fill-rule:nonzero;"/>
<path d="M431.726,391.487C436.95,391.487 439.562,388.718 440.607,387.151L440.92,387.151L440.92,388.979C440.92,390.181 441.704,391.121 443.01,391.121C444.316,391.121 445.099,390.181 445.099,388.927L445.099,376.128C445.099,368.867 439.51,364.635 432.353,364.635C424.36,364.635 418.927,370.538 418.927,378.27C418.927,386.001 424.308,391.487 431.726,391.487ZM423.159,378.061C423.159,372.576 426.293,368.553 432.457,368.553C437.42,368.553 440.92,371.113 440.92,376.337L440.92,379.367C440.92,384.277 437.525,387.621 431.987,387.621C426.293,387.621 423.159,383.859 423.159,378.061Z" style="fill-rule:nonzero;"/>
<path d="M463.749,390.964C468.294,390.964 471.167,388.509 472.525,386.524L472.839,386.524L472.839,388.666C472.839,394.778 469.078,397.755 463.801,397.755C460.04,397.755 457.794,396.397 456.331,395.822C456.018,395.666 455.652,395.613 455.338,395.613C454.189,395.613 453.301,396.554 453.301,397.651C453.301,398.278 453.562,398.905 454.294,399.427C456.018,400.733 459.779,401.673 463.54,401.673C472.003,401.673 477.018,396.24 477.018,388.613L477.018,376.128C477.018,368.867 471.428,364.635 464.428,364.635C456.226,364.635 450.846,370.591 450.846,378.218C450.846,385.897 456.331,390.964 463.749,390.964ZM455.077,378.113C455.077,372.576 458.316,368.553 464.428,368.553C469.339,368.553 472.839,371.113 472.839,376.337L472.839,379.106C472.839,384.016 469.391,387.255 463.958,387.255C458.107,387.255 455.077,383.494 455.077,378.113Z" style="fill-rule:nonzero;"/>
<path d="M503.451,375.606L487.205,375.606C488.041,371.165 491.279,368.344 495.668,368.344C500.004,368.344 503.451,371.165 503.451,375.606ZM482.764,378.061C482.764,385.74 487.936,391.487 496.294,391.487C499.69,391.487 502.616,390.389 504.548,388.979C505.541,388.352 505.802,387.725 505.802,387.098C505.802,386.054 505.175,385.061 503.869,385.061C503.451,385.061 502.929,385.165 502.145,385.74C500.839,386.785 498.906,387.621 496.399,387.621C491.279,387.621 487.57,384.33 487.048,379.158L503.974,379.158C504.966,379.158 507.578,378.844 507.578,374.979C507.578,369.441 502.668,364.635 495.668,364.635C488.302,364.635 482.764,370.173 482.764,378.061Z" style="fill-rule:nonzero;"/>
<path d="M539.445,364.635C535.683,364.635 532.862,366.202 531.243,368.292C529.624,365.889 526.803,364.635 524.191,364.635C521.004,364.635 518.183,366.673 517.399,367.874L517.19,367.874L517.19,366.986C517.086,365.837 516.302,365.001 515.049,365.001C513.743,365.001 512.959,365.941 512.959,367.195L512.959,388.927C512.959,390.181 513.743,391.121 515.049,391.121C516.355,391.121 517.19,390.181 517.19,388.927L517.19,373.412C517.19,370.643 519.75,368.449 522.885,368.449C526.019,368.449 528.579,370.695 528.579,373.412L528.579,388.927C528.579,390.181 529.362,391.121 530.668,391.121C531.974,391.121 532.81,390.181 532.81,388.927L532.81,373.412C532.81,370.695 535.37,368.449 538.504,368.449C541.691,368.449 544.198,370.434 544.198,373.673L544.198,388.927C544.198,390.181 544.982,391.121 546.288,391.121C547.594,391.121 548.378,390.181 548.378,388.927L548.378,373.464C548.378,367.717 544.198,364.635 539.445,364.635Z" style="fill-rule:nonzero;"/>
<path d="M574.811,375.606L558.564,375.606C559.4,371.165 562.639,368.344 567.027,368.344C571.363,368.344 574.811,371.165 574.811,375.606ZM554.124,378.061C554.124,385.74 559.296,391.487 567.654,391.487C571.05,391.487 573.975,390.389 575.908,388.979C576.901,388.352 577.162,387.725 577.162,387.098C577.162,386.054 576.535,385.061 575.229,385.061C574.811,385.061 574.289,385.165 573.505,385.74C572.199,386.785 570.266,387.621 567.759,387.621C562.639,387.621 558.93,384.33 558.408,379.158L575.333,379.158C576.326,379.158 578.938,378.844 578.938,374.979C578.938,369.441 574.027,364.635 567.027,364.635C559.661,364.635 554.124,370.173 554.124,378.061Z" style="fill-rule:nonzero;"/>
<path d="M597.535,364.635C593.095,364.635 590.222,366.725 588.811,368.605L588.55,368.605L588.55,367.195C588.55,365.941 587.714,365.001 586.408,365.001C585.102,365.001 584.319,365.941 584.319,367.195L584.319,388.927C584.319,390.181 585.102,391.121 586.408,391.121C587.714,391.121 588.55,390.181 588.55,388.927L588.55,374.509C588.55,371.531 590.849,368.449 596.229,368.449C600.304,368.449 603.073,370.225 603.073,374.874L603.073,388.927C603.073,390.181 603.856,391.121 605.162,391.121C606.468,391.121 607.304,390.181 607.304,388.927L607.304,374.874C607.304,367.613 602.916,364.635 597.535,364.635Z" style="fill-rule:nonzero;"/>
<path d="M617.543,359.254L617.543,365.001L614.827,365.001C613.573,365.001 612.633,365.732 612.633,366.934C612.633,368.188 613.573,368.919 614.827,368.919L617.543,368.919L617.543,381.718C617.543,388.561 621.305,391.121 626.372,391.121L627.782,391.121C629.036,391.121 629.872,390.389 629.872,389.136C629.872,387.83 629.036,387.098 627.782,387.098L626.372,387.098C623.655,387.098 621.775,385.74 621.775,381.718L621.775,368.919L627.626,368.919C628.879,368.919 629.767,368.188 629.767,366.934C629.767,365.732 628.879,365.001 627.626,365.001L621.775,365.001L621.775,359.254C621.775,357.948 620.939,357.008 619.633,357.008C618.327,357.008 617.543,357.948 617.543,359.254Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

19101
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,7 @@ const ApiContextDebug = () => {
fetchObjects,
fetchObjectsByProperty,
fetchSpotlightData,
fetchObjectContent,
fetchFileContent,
fetchTemplatePreview,
fetchNotes,
fetchHostOTP,
@ -292,20 +292,20 @@ const ApiContextDebug = () => {
}
}
const testFetchObjectContent = async () => {
const testfetchFileContent = async () => {
try {
msgApi.loading('Testing fetchObjectContent...', 0)
await fetchObjectContent(
msgApi.loading('Testing fetchFileContent...', 0)
await fetchFileContent(
testInputs.objectId,
'gcodefile',
testInputs.fileName
)
msgApi.destroy()
msgApi.success('fetchObjectContent test completed')
msgApi.success('fetchFileContent test completed')
} catch (err) {
msgApi.destroy()
msgApi.error('fetchObjectContent test failed')
console.error('fetchObjectContent error:', err)
msgApi.error('fetchFileContent test failed')
console.error('fetchFileContent error:', err)
}
}
@ -723,8 +723,8 @@ const ApiContextDebug = () => {
<Button onClick={testFetchSpotlightData} block>
Test fetchSpotlightData
</Button>
<Button onClick={testFetchObjectContent} block>
Test fetchObjectContent
<Button onClick={testfetchFileContent} block>
Test fetchFileContent
</Button>
<Button onClick={testFetchNotes} block>
Test fetchNotes

View File

@ -11,11 +11,12 @@ import {
import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import { AuthContext } from '../context/AuthContext.jsx'
import BoolDisplay from '../common/BoolDisplay.jsx'
import DataTree from '../common/DataTree.jsx'
const { Text, Paragraph } = Typography
const { Text } = Typography
const AuthContextDebug = () => {
const { authenticated, userProfile, token, loading, loginWithSSO, logout } =
const { authenticated, userProfile, loading, loginWithSSO, logout } =
useContext(AuthContext)
const [msgApi, contextHolder] = message.useMessage()
@ -73,24 +74,10 @@ const AuthContextDebug = () => {
<Descriptions.Item label='Loading'>
<BoolDisplay value={loading} />
</Descriptions.Item>
<Descriptions.Item label='Token'>
<Paragraph>
<pre>{token || <Text type='secondary'>None</Text>}</pre>
</Paragraph>
</Descriptions.Item>
<Descriptions.Item label='User Profile'>
<pre style={{ margin: 0, fontSize: 12 }}>
{userProfile ? (
<Paragraph>
<pre>
{JSON.stringify(
// eslint-disable-next-line
{ ...userProfile, access_token: '...' },
null,
2
)}
</pre>
</Paragraph>
<DataTree data={userProfile} defaultExpandAll={true} />
) : (
<Text>n/a</Text>
)}

View File

@ -2,6 +2,7 @@ import { useState } from 'react'
import { Descriptions, Button, Typography, Flex, Space, Dropdown } from 'antd'
import ReloadIcon from '../../Icons/ReloadIcon'
import BoolDisplay from '../common/BoolDisplay'
import DataTree from '../common/DataTree'
const { Text } = Typography
@ -14,6 +15,16 @@ const getSessionStorageItems = () => {
return items
}
const isJsonString = (str) => {
if (typeof str !== 'string') return false
try {
const parsed = JSON.parse(str)
return typeof parsed === 'object' && parsed !== null
} catch {
return false
}
}
const SessionStorage = () => {
const [items, setItems] = useState(getSessionStorageItems())
@ -64,14 +75,18 @@ const SessionStorage = () => {
isBool = true
boolValue = value === 'true'
}
// Check if value is JSON
const isJson = isJsonString(value)
return (
<Descriptions.Item label={key} key={key} span={2}>
{isBool ? (
<BoolDisplay value={boolValue} />
) : isJson ? (
<DataTree data={JSON.parse(value)} />
) : (
<Text code style={{ wordBreak: 'break-all' }}>
{value}
</Text>
<Text code>{value}</Text>
)}
</Descriptions.Item>
)

View File

@ -1,148 +1,29 @@
// src/partStocks.js
import { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import { useState, useRef } from 'react'
import { AuthContext } from '../context/AuthContext'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import NewPartStock from './PartStocks/NewPartStock'
import IdDisplay from '../common/IdDisplay'
import PartStockIcon from '../../Icons/PartStockIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import PartStockState from '../common/PartStockState'
import TimeDisplay from '../common/TimeDisplay'
import useColumnVisibility from '../hooks/useColumnVisibility'
import ObjectTable from '../common/ObjectTable'
import config from '../../../config'
const { Text } = Typography
import ListIcon from '../../Icons/ListIcon'
import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
import ColumnViewButton from '../common/ColumnViewButton'
const PartStocks = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const tableRef = useRef()
const [newPartStockOpen, setNewPartStockOpen] = useState(false)
const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('partStocks')
const getPartStockActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/dashboard/inventory/partstocks/info?partStockId=${id}`)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: 'icon',
width: 40,
fixed: 'left',
render: () => <PartStockIcon></PartStockIcon>
},
{
title: 'Part Name',
dataIndex: 'part',
key: 'name',
width: 200,
fixed: 'left',
render: (part) => <Text ellipsis>{part.name}</Text>
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => (
<IdDisplay id={text} type={'partstock'} longId={false} />
)
},
{
title: 'State',
key: 'state',
width: 350,
render: (record) => <PartStockState partStock={record} />
},
{
title: 'Current Quantity',
dataIndex: 'currentQuantity',
key: 'currentQuantity',
width: 160,
render: (currentQuantity) => <Text ellipsis>{currentQuantity}</Text>
},
{
title: 'Starting Quantity',
dataIndex: 'startingQuantity',
key: 'startingQuantity',
width: 160,
render: (startingQuantity) => <Text ellipsis>{startingQuantity}</Text>
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
return <TimeDisplay dateTime={updatedAt} />
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleIcon />}
onClick={() =>
navigate(
`/dashboard/inventory/partstocks/info?partStockId=${record._id}`
)
}
/>
<Dropdown menu={getPartStockActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const [columnVisibility, setColumnVisibility] =
useColumnVisibility('partStocks')
const actionItems = {
items: [
@ -171,23 +52,40 @@ const PartStocks = () => {
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='partStock'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
</Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
columns={columns}
url={`${config.backendUrl}/partstocks`}
authenticated={authenticated}
visibleColumns={columnVisibility}
type='partStock'
cards={viewMode === 'cards'}
/>
</Flex>
<Modal
open={newPartStockOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={700}
width={800}
onCancel={() => {
setNewPartStockOpen(false)
}}

View File

@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useContext, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd'
import loglevel from 'loglevel'
@ -22,6 +22,7 @@ import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import FileIcon from '../../../Icons/FileIcon.jsx'
import FilePreview from '../../common/FilePreview.jsx'
import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
import { ApiServerContext } from '../../context/ApiServerContext.jsx'
const log = loglevel.getLogger('FileInfo')
log.setLevel(config.logLevel)
@ -41,11 +42,12 @@ const FileInfo = () => {
editLoading: false,
formValid: false,
lock: null,
loading: false,
loading: true,
objectData: {
_id: fileId
}
})
const { fetchFileContent } = useContext(ApiServerContext)
const actions = {
reload: () => {
@ -64,6 +66,10 @@ const FileInfo = () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
download: () => {
fetchFileContent(objectFormState?.objectData, true)
return true
},
delete: () => {
objectFormRef?.current?.handleDelete?.()
return true

View File

@ -4,7 +4,7 @@ import { useState, useRef } from 'react'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import ObjectTable from '../common/ObjectTable'
import NewProduct from './Products/NewProduct'
import NewPart from './Parts/NewPart'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
@ -20,7 +20,7 @@ import ColumnViewButton from '../common/ColumnViewButton'
const Parts = (filter) => {
const [messageApi, contextHolder] = message.useMessage()
const [newProductOpen, setNewProductOpen] = useState(false)
const [newPartOpen, setNewPartOpen] = useState(false)
const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('part')
const [columnVisibility, setColumnVisibility] = useColumnVisibility('part')
@ -28,8 +28,8 @@ const Parts = (filter) => {
const actionItems = {
items: [
{
label: 'New Product',
key: 'newProduct',
label: 'New Part',
key: 'newPart',
icon: <PlusIcon />
},
{ type: 'divider' },
@ -42,8 +42,8 @@ const Parts = (filter) => {
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newProduct') {
setNewProductOpen(true)
} else if (key === 'newPart') {
setNewPartOpen(true)
}
}
}
@ -82,21 +82,21 @@ const Parts = (filter) => {
/>
</Flex>
<Modal
open={newProductOpen}
open={newPartOpen}
footer={null}
width={700}
onCancel={() => {
setNewProductOpen(false)
setNewPartOpen(false)
}}
destroyOnHidden={true}
>
<NewProduct
<NewPart
onOk={() => {
setNewProductOpen(false)
messageApi.success('Product created successfully!')
setNewPartOpen(false)
messageApi.success('Part created successfully!')
tableRef.current?.reload()
}}
reset={newProductOpen}
reset={newPartOpen}
/>
</Modal>
</>

View File

@ -0,0 +1,110 @@
import PropTypes from 'prop-types'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewPart = ({ onOk }) => {
return (
<NewObjectForm
type={'part'}
defaultValues={{ priceMode: 'margin', globalPricing: true }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='part'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
visibleProperties={{
file: false,
priceMode: false,
globalPricing: false,
amount: false,
margin: false
}}
/>
)
},
{
title: 'Pricing',
key: 'pricing',
content: (
<ObjectInfo
type='part'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
visibleProperties={{
priceMode: true,
globalPricing: true,
amount: true,
margin: true
}}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='part'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='part'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Part'
onSubmit={() => {
handleSubmit()
onOk()
}}
/>
)
}}
</NewObjectForm>
)
}
NewPart.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewPart

View File

@ -1,4 +1,4 @@
import { useRef, useState, useContext } from 'react'
import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd'
import useCollapseState from '../../hooks/useCollapseState'
@ -17,14 +17,12 @@ import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
const PartInfo = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null)
const partId = new URLSearchParams(location.search).get('partId')
const { fetchObjectContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
info: true,
parts: true,
@ -55,13 +53,6 @@ const PartInfo = () => {
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
download: () => {
if (partId && objectFormRef?.current?.getObjectData) {
const objectData = objectFormRef.current.getObjectData()
fetchObjectContent(partId, 'part', `${objectData?.name || 'part'}.stl`)
return true
}
}
}

View File

@ -1,509 +1,104 @@
import PropTypes from 'prop-types'
import { useState, useContext, useEffect, useRef } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Input,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Upload,
Descriptions,
Modal,
Progress,
Form,
Checkbox,
InputNumber
} from 'antd'
import { DeleteOutlined, EyeOutlined } from '@ant-design/icons'
import { AuthContext } from '../../context/AuthContext'
import PartIcon from '../../../Icons/PartIcon'
import VendorSelect from '../../common/VendorSelect'
import config from '../../../../config'
const { Dragger } = Upload
const { Title, Text } = Typography
const initialNewProductForm = {
name: '',
parts: [],
vendor: null,
marginOrPrice: false,
margin: 0,
price: 0
}
const NewProduct = ({ onOk, reset }) => {
// UI state
const [messageApi, contextHolder] = message.useMessage()
const [currentStep, setCurrentStep] = useState(0)
const [newProductLoading, setNewProductLoading] = useState(false)
const [nextEnabled, setNextEnabled] = useState(false)
const [newProductForm] = Form.useForm()
const [newProductFormValues, setNewProductFormValues] = useState(
initialNewProductForm
)
const newProductFormUpdateValues = Form.useWatch([], newProductForm)
// Combined parts and files state
const [parts, setParts] = useState([])
const [fileUrls, setFileUrls] = useState({})
const [uploadProgress, setUploadProgress] = useState({})
// Preview state
const [previewVisible, setPreviewVisible] = useState(false)
const [previewFile, setPreviewFile] = useState(null)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
const previewTimerRef = useRef(null)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const { token, authenticated } = useContext(AuthContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
newProductForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newProductForm, newProductFormUpdateValues])
useEffect(() => {
if (reset) {
newProductForm.resetFields()
}
}, [reset, newProductForm])
useEffect(() => {
setMarginOrPrice(newProductFormValues.marginOrPrice)
}, [newProductFormValues])
// Effect: Cleanup file URLs on unmount
useEffect(() => {
return () => {
Object.values(fileUrls).forEach(URL.revokeObjectURL)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
}
}, [fileUrls])
useEffect(() => {
setNewProductFormValues((prev) => ({ ...prev, parts: parts }))
}, [parts, setNewProductFormValues])
// File handlers
const handleFileAdd = (file) => {
const objectUrl = URL.createObjectURL(file)
const defaultName = file.name.replace(/\.[^/.]+$/, '')
setParts((prev) => [
{
name: defaultName,
file,
uid: file.uid
},
...prev
])
setFileUrls((prev) => ({ ...prev, [file.uid]: objectUrl }))
setUploadProgress((prev) => ({ ...prev, [file.uid]: 0 }))
return false // Prevent default upload
}
const handleFileRemove = (index) => {
setParts((prev) => {
const newParts = [...prev]
const removedPart = newParts[index]
newParts.splice(index, 1)
// Cleanup URL and progress
if (removedPart && fileUrls[removedPart.uid]) {
URL.revokeObjectURL(fileUrls[removedPart.uid])
setFileUrls((urls) => {
const newUrls = { ...urls }
delete newUrls[removedPart.uid]
return newUrls
})
setUploadProgress((progress) => {
const newProgress = { ...progress }
delete newProgress[removedPart.uid]
return newProgress
})
}
return newParts
})
}
const handleNameChange = (index, newName) => {
setParts((prev) => {
const newParts = [...prev]
newParts[index] = { ...newParts[index], name: newName }
return newParts
})
}
const handlePreview = (file) => {
setPreviewFile(file)
setPreviewVisible(true)
setIsPreviewLoading(true)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
previewTimerRef.current = setTimeout(() => {
setIsPreviewLoading(false)
}, 300)
}
const handleNewProduct = async () => {
setNewProductLoading(true)
try {
const result = await axios.post(
`${config.backendUrl}/products`,
newProductFormValues,
{
withCredentials: true // Important for including cookies
}
)
await uploadParts(result.data.parts)
onOk()
} catch (error) {
messageApi.error('Error creating new product: ' + error.message)
} finally {
setNewProductLoading(false)
}
}
// Submit handler
const uploadParts = async (partIds) => {
if (!authenticated) return
try {
// Upload files sequentially for each part
for (let i = 0; i < parts.length; i++) {
const formData = new FormData()
formData.append('partFile', parts[i].file)
await axios.post(
`${config.backendUrl}/parts/${partIds[i]}/content`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
setUploadProgress((prev) => ({
...prev,
[parts[i].uid]: percentCompleted
}))
}
}
)
}
} catch (error) {
messageApi.error('Error creating product: ' + error.message)
}
}
// Step Contents
const uploadStep = (
<Flex gap='middle' vertical>
{parts.length != 0 ? (
<div style={{ maxHeight: '200px', overflowY: 'scroll' }}>
<Flex vertical gap='small'>
{parts.map((part, index) => (
<Flex key={part.uid} gap='small' align='center'>
<Input
placeholder='Part name'
value={part.name}
onChange={(e) => handleNameChange(index, e.target.value)}
style={{ flex: 1 }}
/>
<Button
icon={<EyeOutlined />}
onClick={() => handlePreview(part.file)}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleFileRemove(index)}
/>
</Flex>
))}
</Flex>
</div>
) : null}
<Dragger
name='parts'
multiple
fileList={[]}
showUploadList={false}
beforeUpload={handleFileAdd}
customRequest={({ onSuccess }) => setTimeout(() => onSuccess('ok'), 0)}
>
<Flex style={{ height: '100%' }} vertical>
<p className='ant-upload-drag-icon'>
<PartIcon />
</p>
<p className='ant-upload-text'>Click or drag 3D Model files here</p>
<p className='ant-upload-hint'>
Supported file extensions: .stl, .3mf
</p>
</Flex>
</Dragger>
</Flex>
)
const detailsStep = (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input placeholder='Enter product name' />
</Form.Item>
<Form.Item
label='Vendor'
name='vendor'
rules={[
{
required: true,
message: 'Please enter a vendor.'
}
]}
>
<VendorSelect />
</Form.Item>
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
label={'Margin'}
name='margin'
style={{ flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
label={'Price'}
name='price'
style={{ flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item name='marginOrPrice' valuePropName='checked'>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
</>
)
const summaryStep = (
<Descriptions
column={1}
size='small'
items={[
{
key: 'name',
label: 'Name',
children: <Text>{newProductFormValues?.name}</Text>
},
{
key: 'vendor',
label: 'Vendor',
children: <Text>{newProductFormValues?.vendor?.name}</Text>
},
{
key: 'marginPrice',
label: !marginOrPrice ? 'Margin' : 'Price',
children: !marginOrPrice ? (
<Text>{newProductFormValues?.margin}%</Text>
) : (
<Text>£{newProductFormValues?.price}</Text>
)
},
...parts.map((part, index) => ({
key: part.uid,
label: `Part ${index + 1}`,
children: (
<Flex gap='middle' align='center'>
<span>{part.name}</span>
<Progress
percent={uploadProgress[part.uid] || 0}
size='small'
style={{ width: '120px', marginBottom: 0 }}
/>
</Flex>
)
}))
]}
/>
)
const steps = [
{ title: 'Upload Parts', content: uploadStep },
{ title: 'Details', content: detailsStep },
{ title: 'Summary', content: summaryStep }
]
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewProduct = ({ onOk }) => {
return (
<Flex gap='middle'>
{contextHolder}
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
<NewObjectForm type={'product'} defaultValues={{ priceMode: 'margin' }}>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='product'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
visibleProperties={{
priceMode: false,
margin: false,
amount: false
}}
/>
</div>
)}
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Product
</Title>
<Form
name='basic'
autoComplete='off'
form={newProductForm}
onFinish={handleNewProduct}
onValuesChange={(changedValues) =>
setNewProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
)
},
{
title: 'Pricing',
key: 'pricing',
content: (
<ObjectInfo
type='product'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
visibleProperties={{
priceMode: true,
margin: true,
amount: true
}}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='product'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='product'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
initialValues={initialNewProductForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
</Form>
<Flex justify='end'>
<Button
style={{ margin: '0 8px' }}
onClick={() => {
setCurrentStep((prev) => prev - 1)
setNextEnabled(true)
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Product'
onSubmit={() => {
handleSubmit()
onOk()
}}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep < steps.length - 1 ? (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep((prev) => prev + 1)
setNextEnabled(false)
/>
)
}}
>
Next
</Button>
) : (
<Button
type='primary'
loading={newProductLoading}
onClick={() => {
newProductForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Flex>
<Modal
open={previewVisible}
footer={null}
onCancel={() => {
setPreviewVisible(false)
setPreviewFile(null)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
}}
style={{ top: 30 }}
width='90%'
>
<Flex style={{ minWidth: '100%', minHeight: '80vh' }}>
{previewFile && !isPreviewLoading ? (
<div style={{ flexGrow: 1 }}></div>
) : (
<div
style={{
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '80vh'
}}
>
Loading 3D model...
</div>
)}
</Flex>
</Modal>
</Flex>
</NewObjectForm>
)
}
NewProduct.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewProduct

View File

@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card, Typography } from 'antd'
import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config.js'
@ -21,8 +21,8 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import EyeIcon from '../../../Icons/EyeIcon.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
const { Text } = Typography
import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
import FilePreview from '../../common/FilePreview.jsx'
const log = loglevel.getLogger('GCodeFileInfo')
log.setLevel(config.logLevel)
@ -175,17 +175,16 @@ const GCodeFileInfo = () => {
}
collapseKey='preview'
>
{objectData?.file?._id ? (
<Card>
{objectData?.gcodeFileInfo?.thumbnail ? (
<img
src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
<FilePreview
file={objectData?.file}
style={{ width: '100%', height: '100%' }}
/>
) : (
<Text>n/a</Text>
)}
</Card>
) : (
<MissingPlaceholder message={'No file.'} />
)}
</InfoCollapse>
</Flex>
)

View File

@ -1,18 +1,9 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import { useMediaQuery } from 'react-responsive'
import { Typography, Flex, Steps, Divider } from 'antd'
import NewObjectButtons from '../../common/NewObjectButtons'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
const { Title } = Typography
import WizardView from '../../common/WizardView'
const NewJob = ({ onOk }) => {
const [currentStep, setCurrentStep] = useState(0)
const isMobile = useMediaQuery({ maxWidth: 768 })
return (
<NewObjectForm
type={'job'}
@ -57,43 +48,16 @@ const NewJob = ({ onOk }) => {
}
]
return (
<Flex gap='middle'>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
{!isMobile && (
<Divider type='vertical' style={{ height: 'unset' }} />
)}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ margin: 0 }}>
New Job
</Title>
<div style={{ minHeight: '260px', marginBottom: 8 }}>
{steps[currentStep].content}
</div>
<NewObjectButtons
currentStep={currentStep}
totalSteps={steps.length}
onPrevious={() => setCurrentStep((prev) => prev - 1)}
onNext={() => setCurrentStep((prev) => prev + 1)}
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Job'
onSubmit={() => {
handleSubmit()
onOk()
}}
formValid={formValid}
submitLoading={submitLoading}
/>
</Flex>
</Flex>
)
}}
</NewObjectForm>

View File

@ -276,7 +276,10 @@ const ControlPrinter = () => {
}}
</ObjectForm>
) : (
<MissingPlaceholder message={'No job.'} />
<MissingPlaceholder
message={'No job.'}
hasBackground={false}
/>
)}
</InfoCollapse>
<InfoCollapse
@ -313,7 +316,10 @@ const ControlPrinter = () => {
}}
</ObjectForm>
) : (
<MissingPlaceholder message={'No sub job.'} />
<MissingPlaceholder
message={'No sub job.'}
hasBackground={false}
/>
)}
</InfoCollapse>
<InfoCollapse
@ -351,7 +357,10 @@ const ControlPrinter = () => {
}}
</ObjectForm>
) : (
<MissingPlaceholder message={'No sub job.'} />
<MissingPlaceholder
message={'No filament stock.'}
hasBackground={false}
/>
)}
</InfoCollapse>
</Flex>

View File

@ -42,7 +42,7 @@ const ActionHandler = forwardRef(
// Execute action and clear from URL
useEffect(() => {
if (
!loading &&
loading == false &&
action &&
actions[action] &&
lastExecutedAction.current !== action

View File

@ -123,7 +123,6 @@ const DashboardNavigation = () => {
} else {
setApiServerState('disconnected')
}
console.log('Connecting/connected', connecting, connected)
}, [connecting, connected])
const handleMainMenuClick = ({ key }) => {
@ -221,7 +220,11 @@ const DashboardNavigation = () => {
}
/>
{isMobile && <div style={{ flexGrow: 1 }} />}
<Flex gap={'small'} align='center' style={{ marginTop: '-2px', marginRight: '6px' }}>
<Flex
gap={'small'}
align='center'
style={{ marginTop: '-2px', marginRight: '6px' }}
>
<Space>
<KeyboardShortcut
shortcut='alt+q'

View File

@ -1,30 +0,0 @@
// FilamentSelect.js
import PropTypes from 'prop-types'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = ['diameter', 'type', 'vendor.name']
const FilamentSelect = ({ onChange, filter, useFilter, value }) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/filaments`}
propertyOrder={propertyOrder}
filter={filter}
useFilter={useFilter}
value={value}
onChange={onChange}
placeholder='Select Filament'
type={'filament'}
/>
)
}
FilamentSelect.propTypes = {
onChange: PropTypes.func,
value: PropTypes.object,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
export default FilamentSelect

View File

@ -1,50 +0,0 @@
import { Flex, Typography, Badge } from 'antd'
import PropTypes from 'prop-types'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import IdDisplay from './IdDisplay'
const { Text } = Typography
const FilamentStockDisplay = ({
filamentStock,
longId = false,
showIcon = true,
showColor = true,
showId = true,
showCopy = true
}) => {
FilamentStockDisplay.propTypes = {
filamentStock: PropTypes.shape({
_id: PropTypes.string.isRequired,
filament: PropTypes.shape({
name: PropTypes.string,
color: PropTypes.string
}),
currentNetWeight: PropTypes.number
}).isRequired,
longId: PropTypes.bool,
showIcon: PropTypes.bool,
showColor: PropTypes.bool,
showId: PropTypes.bool,
showCopy: PropTypes.bool
}
return (
<Flex gap={'small'} align='center'>
{showIcon && <FilamentStockIcon />}
{showColor && <Badge color={filamentStock.filament.color} />}
<Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text>
{showId && (
<IdDisplay
id={filamentStock._id}
longId={longId}
type={'filamentstock'}
showCopy={showCopy}
/>
)}
</Flex>
)
}
export default FilamentStockDisplay

View File

@ -1,35 +0,0 @@
import PropTypes from 'prop-types'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const FilamentStockSelect = ({
onChange,
filter = {},
useFilter = false,
value,
disabled = false
}) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/filamentstocks`}
propertyOrder={['tags']}
filter={filter}
useFilter={useFilter}
value={value}
onChange={onChange}
disabled={disabled}
placeholder='Select a filament stock'
type='filamentstock'
/>
)
}
FilamentStockSelect.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.object,
filter: PropTypes.object,
useFilter: PropTypes.bool,
disabled: PropTypes.bool
}
export default FilamentStockSelect

View File

@ -1,49 +0,0 @@
import PropTypes from 'prop-types'
import { Progress, Flex, Space } from 'antd'
import StateTag from './StateTag'
const getProgressColor = (percent) => {
if (percent <= 50) {
return '#52c41a' // green[5]
} else if (percent <= 80) {
// Interpolate between green and yellow
const ratio = (percent - 50) / 30
return `rgb(${Math.round(255 * ratio)}, ${Math.round(255 * (1 - ratio))}, 0)`
} else {
// Interpolate between yellow and red
const ratio = (percent - 80) / 20
return `rgb(255, ${Math.round(255 * (1 - ratio))}, 0)`
}
}
const FilamentStockState = ({
state = { type: 'unknown' },
showProgress = true,
showState = true
}) => {
return (
<Flex gap='small' align={'center'}>
{showState && (
<Space>
<StateTag state={state.type} />
</Space>
)}
{showProgress && state.type === 'partiallyconsumed' ? (
<Progress
percent={Math.round((state.percent || 0) * 100)}
style={{ width: '150px', marginBottom: '2px' }}
strokeColor={getProgressColor(Math.round((state.percent || 0) * 100))}
showInfo={false}
/>
) : null}
</Flex>
)
}
FilamentStockState.propTypes = {
state: PropTypes.object,
showProgress: PropTypes.bool,
showState: PropTypes.bool
}
export default FilamentStockState

View File

@ -22,9 +22,10 @@ const FileList = ({
showPreview = true,
showInfo = true,
showDownload = true,
defaultPreviewOpen = false
defaultPreviewOpen = false,
card = true
}) => {
const { fetchFileContent } = useContext(ApiServerContext)
const { fetchFileContent, flushFile } = useContext(ApiServerContext)
const navigate = useNavigate()
const [previewOpen, setPreviewOpen] = useState(defaultPreviewOpen)
const infoAction = getModelByName('file').actions.filter(
@ -40,6 +41,7 @@ const FileList = ({
}
const handleRemove = (fileToRemove) => {
flushFile(fileToRemove._id)
if (multiple) {
const currentFiles = Array.isArray(files) ? files : []
const updatedFiles = currentFiles.filter((file) => {
@ -59,22 +61,17 @@ const FileList = ({
const filesToRender = multiple ? files : [files]
return (
<div style={{ width: '100%' }}>
{filesToRender.map((file, index) => (
<Card
styles={{ body: { padding: '10px' } }}
key={file._id || file.id || index}
style={{
marginTop: index > 0 && multiple ? '4px' : undefined
}}
>
const renderFileContent = (file) => (
<Flex vertical gap='10px'>
<Flex justify='space-between'>
<Flex justify={card ? 'space-between' : 'start'}>
<Flex gap={'small'} align='center'>
<FileIcon style={{ margin: 0, fontSize: '24px' }} />
<FileIcon
style={{ margin: 0, fontSize: card == true ? '24px' : '16px' }}
/>
<Text>{file.name || file.filename || 'Unknown file'}</Text>
<Text style={{ marginTop: '1px' }}>
{file.name || file.filename || 'Unknown file'}
</Text>
<Tag>{file.extension}</Tag>
</Flex>
<Flex gap={'small'} align='center'>
@ -122,13 +119,45 @@ const FileList = ({
</Flex>
{previewOpen ? (
<>
<Divider style={{ margin: 0 }} />
<Divider style={{ marginTop: 0, marginBottom: card ? 0 : '4px' }} />
<FilePreview file={file} style={{ width: '100%' }} />
</>
) : null}
</Flex>
)
return (
<div style={{ width: '100%' }}>
{filesToRender.map((file, index) => {
const key = file._id || file.id || index
const style = {
marginTop: index > 0 && multiple ? '4px' : undefined
}
if (card) {
return (
<Card
styles={{ body: { padding: '10px' } }}
key={key}
style={style}
>
{renderFileContent(file)}
</Card>
))}
)
} else {
return (
<div
key={key}
style={{
...style
}}
>
{index > 0 && <Divider />}
{renderFileContent(file)}
</div>
)
}
})}
</div>
)
}
@ -141,7 +170,8 @@ FileList.propTypes = {
showPreview: PropTypes.string,
showInfo: PropTypes.bool,
showDownload: PropTypes.bool,
defaultPreviewOpen: PropTypes.bool
defaultPreviewOpen: PropTypes.bool,
card: PropTypes.bool
}
export default FileList

View File

@ -26,8 +26,6 @@ const FilePreview = ({ file, style = {} }) => {
}
}, [file._id, file?.type, fetchPreview, token])
console.log('file', file)
if (loading == true || !file?.type) {
return <LoadingPlaceholder message={'Loading file preview...'} />
}

View File

@ -14,7 +14,8 @@ const FileUpload = ({
onChange,
multiple = true,
defaultPreviewOpen = false,
showPreview = true
showPreview = true,
showInfo
}) => {
const { uploadFile } = useContext(ApiServerContext)
@ -39,6 +40,9 @@ const FileUpload = ({
// Track if there are no items in the list
const [hasNoItems, setHasNoItems] = useState(false)
// Track the selected file from ObjectSelect
const [selectedFile, setSelectedFile] = useState(null)
// Update hasNoItems when currentFiles changes
useEffect(() => {
const noItems = multiple
@ -71,13 +75,39 @@ const FileUpload = ({
return false // Prevent default upload behavior
}
// Handle adding selected file to the list
const handleAddSelectedFile = () => {
if (selectedFile) {
if (multiple) {
// For multiple files, add to existing array
const newFiles = [...currentFiles, selectedFile]
setCurrentFiles(newFiles)
onChange(newFiles)
} else {
// For single file, replace the value
setCurrentFiles(selectedFile)
onChange(selectedFile)
}
// Clear the selection
setSelectedFile(null)
}
}
return (
<Flex gap={'small'} vertical>
{hasNoItems ? (
<Flex gap={'small'} align='center'>
<Space.Compact style={{ flexGrow: 1 }}>
<ObjectSelect type={'file'} />
<Button icon={<PlusIcon />} />
<ObjectSelect
type={'file'}
value={selectedFile}
onChange={setSelectedFile}
/>
<Button
icon={<PlusIcon />}
onClick={handleAddSelectedFile}
disabled={!selectedFile}
/>
</Space.Compact>
<Text style={{ whiteSpace: 'nowrap' }} type='secondary'>
or
@ -97,7 +127,7 @@ const FileUpload = ({
files={currentFiles}
multiple={multiple}
editing={true}
showInfo={false}
showInfo={showInfo || false}
showPreview={showPreview}
defaultPreviewOpen={defaultPreviewOpen}
onChange={(updatedFiles) => {
@ -112,7 +142,8 @@ FileUpload.propTypes = {
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
onChange: PropTypes.func,
multiple: PropTypes.bool,
showPreview: PropTypes.string,
showPreview: PropTypes.bool,
showInfo: PropTypes.bool,
defaultPreviewOpen: PropTypes.bool
}

View File

@ -1,35 +0,0 @@
// GCodeFileSelect.js
import PropTypes from 'prop-types'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = [
'filament.diameter',
'filament.type',
'filament.vendor.name'
]
const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/gcodefiles/properties`}
propertyOrder={propertyOrder}
filter={filter}
useFilter={useFilter}
onChange={onChange}
showSearch={true}
style={style}
placeholder='Select GCode File'
type='gcodeFile'
/>
)
}
GCodeFileSelect.propTypes = {
onChange: PropTypes.func,
filter: PropTypes.object,
useFilter: PropTypes.bool,
style: PropTypes.object
}
export default GCodeFileSelect

View File

@ -1,121 +0,0 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
import StockAuditIcon from '../../Icons/StockAuditIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
const InventorySidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('inventory')
const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 2) {
setSelectedKey(pathParts[2]) // Return the section (inventory/management)
}
}, [location.pathname])
const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
}
const items = [
{
key: 'overview',
label: <Link to='/dashboard/inventory/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{ type: 'divider' },
{
key: 'filamentstocks',
label: (
<Link to='/dashboard/inventory/filamentstocks'>Filament Stocks</Link>
),
icon: <FilamentStockIcon />
},
{
key: 'partstocks',
label: <Link to='/dashboard/inventory/partstocks'>Part Stocks</Link>,
icon: <PartStockIcon />
},
{
key: 'productstocks',
label: (
<Link to='/dashboard/inventory/productstocks'>Product Stocks</Link>
),
icon: <ProductStockIcon />
},
{ type: 'divider' },
{
key: 'stockevents',
label: <Link to='/dashboard/inventory/stockevents'>Stock Events</Link>,
icon: <StockEventIcon />
},
{
key: 'stockaudits',
label: <Link to='/dashboard/inventory/stockaudits'>Stock Audits</Link>,
icon: <StockAuditIcon />
}
]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
items={items}
style={{ width: '100%' }}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex
style={{ height: '100%' }}
vertical
className='ant-menu-root ant-menu-inline ant-menu-light'
>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
style={{ height: '100%', flexGrow: 1, border: 'none' }}
items={items}
_internalDisableMenuItemTitleTooltip
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
size='large'
type='text'
icon={collapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />}
style={{ flexGrow: 1 }}
onClick={() => handleCollapse(!collapsed)}
/>
</Flex>
</Flex>
</Sider>
)
}
export default InventorySidebar

View File

@ -1,43 +0,0 @@
import PropTypes from 'prop-types'
import { Progress, Flex, Space } from 'antd'
import { useState, useEffect } from 'react'
import StateTag from './StateTag'
const JobState = ({ state, showProgress = true, showState = true }) => {
const [currentState, setCurrentState] = useState(
state || { type: 'unknown', progress: 0 }
)
useEffect(() => {
if (state) {
setCurrentState(state)
}
}, [state])
return (
<Flex gap='small' align={'center'}>
{showState && (
<Space>
<StateTag state={currentState?.type} />
</Space>
)}
{showProgress &&
(currentState.type === 'printing' ||
currentState.type === 'processing') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status='active'
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
</Flex>
)
}
JobState.propTypes = {
state: PropTypes.object,
showProgress: PropTypes.bool,
showState: PropTypes.bool
}
export default JobState

View File

@ -1,129 +0,0 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import { CaretDownFilled } from '@ant-design/icons'
import FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
import { useMediaQuery } from 'react-responsive'
import SettingsIcon from '../../Icons/SettingsIcon'
const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
const ManagementSidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 2) {
setSelectedKey(pathParts[2]) // Return the section (production/management)
}
}, [location.pathname])
const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
}
const items = [
{
key: 'filaments',
label: <Link to='/dashboard/management/filaments'>Filaments</Link>,
icon: <FilamentIcon />
},
{
key: 'parts',
label: <Link to='/dashboard/management/parts'>Parts</Link>,
icon: <PartIcon />
},
{
key: 'products',
label: <Link to='/dashboard/management/products'>Products</Link>,
icon: <ProductIcon />
},
{
key: 'vendors',
label: <Link to='/dashboard/management/vendors'>Vendors</Link>,
icon: <VendorIcon />
},
{
key: 'materials',
label: <Link to='/dashboard/management/materials'>Materials</Link>,
icon: <MaterialIcon />
},
{
key: 'notetypes',
label: <Link to='/dashboard/management/notetypes'>Note Types</Link>,
icon: <NoteTypeIcon />
},
{ type: 'divider' },
{
key: 'settings',
label: <Link to='/dashboard/management/settings'>Settings</Link>,
icon: <SettingsIcon />
},
{
key: 'auditlogs',
label: <Link to='/dashboard/management/auditlogs'>Audit Log</Link>,
icon: <AuditLogIcon />
}
]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['filaments']}
items={items}
style={{ width: '100%' }}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex
style={{ height: '100%' }}
vertical
className='ant-menu-root ant-menu-inline ant-menu-light'
>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['filaments']}
items={items}
_internalDisableMenuItemTitleTooltip
style={{ flexGrow: 1, border: 'none' }}
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
size='large'
type='text'
icon={collapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />}
style={{ flexGrow: 1 }}
onClick={() => handleCollapse(!collapsed)}
/>
</Flex>
</Flex>
</Sider>
)
}
export default ManagementSidebar

View File

@ -1,8 +1,9 @@
import { useState, useEffect, useContext } from 'react'
import { useState, useEffect, useContext, useCallback } from 'react'
import { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
import PropTypes from 'prop-types'
import merge from 'lodash/merge'
import { getModelByName } from '../../../database/ObjectModels'
/**
* NewObjectForm is a reusable form component for creating new objects.
@ -25,12 +26,46 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const [messageApi, contextHolder] = message.useMessage()
const { createObject, showError } = useContext(ApiServerContext)
// Get the model definition for this object type
const model = getModelByName(type)
// Function to calculate computed values from model properties
const calculateComputedValues = useCallback((currentData, model) => {
if (!model || !model.properties) return {}
const computedValues = {}
model.properties.forEach((property) => {
// Check if this property has a computed value function
if (property.value && typeof property.value === 'function') {
try {
const computedValue = property.value(currentData)
if (computedValue !== undefined) {
computedValues[property.name] = computedValue
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
}
}
})
return computedValues
}, [])
// Set initial form values when defaultValues change
useEffect(() => {
if (Object.keys(defaultValues).length > 0) {
form.setFieldsValue(defaultValues)
// Calculate computed values for initial data
const computedValues = calculateComputedValues(defaultValues, model)
const initialFormData = { ...defaultValues, ...computedValues }
form.setFieldsValue(initialFormData)
setObjectData(initialFormData)
}
}, [form, defaultValues])
}, [form, defaultValues, calculateComputedValues, model])
// Validate form on change
useEffect(() => {
@ -67,8 +102,20 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
layout='vertical'
style={style}
onValuesChange={(values) => {
// Calculate computed values based on current form data
const currentFormData = { ...objectData, ...values }
const computedValues = calculateComputedValues(currentFormData, model)
// Update form with computed values if any were calculated
if (Object.keys(computedValues).length > 0) {
form.setFieldsValue(computedValues)
}
// Merge all values (user input + computed values)
const allValues = { ...values, ...computedValues }
setObjectData((prev) => {
return merge({}, prev, values)
return merge({}, prev, allValues)
})
}}
>

View File

@ -4,7 +4,8 @@ import {
useContext,
useCallback,
forwardRef,
useImperativeHandle
useImperativeHandle,
useRef
} from 'react'
import { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
@ -12,6 +13,7 @@ import { AuthContext } from '../context/AuthContext'
import PropTypes from 'prop-types'
import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge'
import { getModelByName } from '../../../database/ObjectModels'
/**
* ObjectForm is a reusable form component for editing any object type.
@ -50,30 +52,130 @@ const ObjectForm = forwardRef(
showError,
connected,
subscribeToObjectUpdates,
subscribeToObjectLock
subscribeToObjectLock,
flushFile
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// Get the model definition for this object type
const model = getModelByName(type)
// Check if the model has properties with type 'file' or 'fileList'
const hasFileProperties = useCallback(() => {
if (!model || !model.properties) return false
return model.properties.some(
(property) => property.type === 'file' || property.type === 'fileList'
)
}, [model])
const flushOrphanFiles = useCallback(() => {
if (!model || !model.properties || !objectData) return
model.properties.forEach((property) => {
if (property.type === 'file') {
// Handle single file property
const fileId =
objectData[property.name]?._id || objectData[property.name]
if (fileId) {
flushFile(fileId)
}
} else if (property.type === 'fileList') {
// Handle fileList property
const fileList = objectData[property.name]
if (Array.isArray(fileList)) {
fileList.forEach((file) => {
const fileId = file?._id || file
if (fileId) {
flushFile(fileId)
}
})
}
}
})
}, [model, objectData, flushFile])
// Refs to store current values for cleanup
const currentIdRef = useRef(id)
const currentTypeRef = useRef(type)
const currentIsEditingRef = useRef(isEditing)
const currentUnlockObjectRef = useRef(unlockObject)
const currentHasFilePropertiesRef = useRef(hasFileProperties)
const currentFlushOrphanFilesRef = useRef(flushOrphanFiles)
// Update refs when values change
useEffect(() => {
currentIdRef.current = id
currentTypeRef.current = type
currentIsEditingRef.current = isEditing
currentUnlockObjectRef.current = unlockObject
currentHasFilePropertiesRef.current = hasFileProperties
currentFlushOrphanFilesRef.current = flushOrphanFiles
})
// Function to calculate computed values from model properties
const calculateComputedValues = useCallback((currentData, model) => {
if (!model || !model.properties) return {}
const computedValues = {}
model.properties.forEach((property) => {
// Check if this property has a computed value function
if (property.value && typeof property.value === 'function') {
try {
const computedValue = property.value(currentData)
if (computedValue !== undefined) {
computedValues[property.name] = computedValue
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
}
}
})
return computedValues
}, [])
// Validate form on change
useEffect(() => {
form
.validateFields({ validateOnly: true })
.then(() => {
setFormValid(true)
onStateChange({ formValid: true, objectData: form.getFieldsValue() })
onStateChange({
formValid: true,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
})
})
.catch(() => {
onStateChange({ formValid: true, objectData: form.getFieldsValue() })
onStateChange({
formValid: true,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
})
})
}, [form, formUpdateValues])
// Cleanup on unmount
useEffect(() => {
return () => {
if (id) {
unlockObject(id, type)
if (currentIdRef.current) {
currentUnlockObjectRef.current(
currentIdRef.current,
currentTypeRef.current
)
}
// Call flushOrphanFiles if component was editing and model has file properties
if (
currentIsEditingRef.current &&
currentHasFilePropertiesRef.current()
) {
currentFlushOrphanFilesRef.current()
}
}
}, [id, type, unlockObject])
}, []) // Empty dependency array - only run on mount/unmount
const handleFetchObject = useCallback(async () => {
try {
@ -85,7 +187,12 @@ const ObjectForm = forwardRef(
onStateChange({ lock: lockEvent })
setObjectData(data)
setServerObjectData(data)
form.setFieldsValue(data)
// Calculate and set computed values on initial load
const computedValues = calculateComputedValues(data, model)
const initialFormData = { ...data, ...computedValues }
form.setFieldsValue(initialFormData)
setFetchLoading(false)
onStateChange({ loading: false })
} catch (err) {
@ -157,8 +264,12 @@ const ObjectForm = forwardRef(
const cancelEditing = () => {
if (serverObjectData) {
form.setFieldsValue(serverObjectData)
setObjectData(serverObjectData)
// Recalculate computed values when canceling
const computedValues = calculateComputedValues(serverObjectData, model)
const resetFormData = { ...serverObjectData, ...computedValues }
form.setFieldsValue(resetFormData)
setObjectData(resetFormData)
}
setIsEditing(false)
onStateChange({ isEditing: false })
@ -247,8 +358,24 @@ const ObjectForm = forwardRef(
if (onEdit != undefined) {
onEdit(values)
}
// Calculate computed values based on current form data
const currentFormData = { ...objectData, ...values }
const computedValues = calculateComputedValues(
currentFormData,
model
)
// Update form with computed values if any were calculated
if (Object.keys(computedValues).length > 0) {
form.setFieldsValue(computedValues)
}
// Merge all values (user input + computed values)
const allValues = { ...values, ...computedValues }
setObjectData((prev) => {
return { ...prev, ...values }
return { ...prev, ...allValues }
})
}}
>

View File

@ -46,13 +46,24 @@ const ObjectInfo = ({
objectPropertyProps = { ...objectPropertyProps, showHyperlink }
}
// Filter items based on visibleProperties
// If a property key exists in visibleProperties and is false, hide it
// If all values in visibleProperties are true, use whitelist mode
// Otherwise, use blacklist mode (hide properties set to false)
const visibleValues = Object.values(visibleProperties)
const isWhitelistMode =
visibleValues.length > 0 && visibleValues.every((value) => value === true)
items = items.filter((item) => {
const propertyName = item.name
if (isWhitelistMode) {
// Whitelist mode: only show properties that are explicitly set to true
return visibleProperties[propertyName] === true
} else {
// Blacklist mode: hide properties that are explicitly set to false
return !(
propertyName in visibleProperties &&
visibleProperties[propertyName] === false
)
}
})
// Map items to Descriptions 'items' prop format

View File

@ -39,6 +39,9 @@ import ObjectTypeDisplay from './ObjectTypeDisplay'
import CodeBlockEditor from './CodeBlockEditor'
import StateDisplay from './StateDisplay'
import AlertsDisplay from './AlertsDisplay'
import FileUpload from './FileUpload'
import DataTree from './DataTree'
import FileList from './FileList'
const { Text } = Typography
@ -76,6 +79,9 @@ const ObjectProperty = ({
initial = false,
height = 'auto',
minimal = false,
previewOpen = false,
showPreview = true,
showHyperlink,
...rest
}) => {
if (value && typeof value == 'function' && objectData) {
@ -370,7 +376,14 @@ const ObjectProperty = ({
}
case 'id': {
if (value) {
return <IdDisplay id={value} type={objectType} {...rest} />
return (
<IdDisplay
id={value}
type={objectType}
showHyperlink={showHyperlink}
{...rest}
/>
)
} else {
return (
<Text type='secondary' {...textParams}>
@ -426,6 +439,40 @@ const ObjectProperty = ({
case 'propertyChanges': {
return <PropertyChanges type={objectType} value={value} />
}
case 'data': {
return <DataTree data={value} />
}
case 'file': {
if (value == null || value?.length == 0 || value == undefined) {
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} else {
return (
<FileList
files={value}
multiple={false}
card={false}
defaultPreviewOpen={previewOpen}
showPreview={showPreview}
showInfo={showHyperlink}
/>
)
}
}
case 'fileList': {
return (
<FileList
files={value}
multiple={true}
defaultPreviewOpen={previewOpen}
showPreview={showPreview}
showInfo={showHyperlink}
/>
)
}
default: {
if (value) {
return <Text {...textParams}>{value}</Text>
@ -443,7 +490,7 @@ const ObjectProperty = ({
// Editable mode: wrap in Form.Item
// Merge required rule if needed
let mergedFormItemProps = { ...formItemProps, style: { flexGrow: 1 } }
if (required) {
if (required && disabled == false) {
let rules
if (mergedFormItemProps.rules) {
rules = [...mergedFormItemProps.rules]
@ -664,6 +711,30 @@ const ObjectProperty = ({
<TagsInput />
</Form.Item>
)
case 'file':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<FileUpload
value={value}
multiple={false}
defaultPreviewOpen={previewOpen}
showPreview={showPreview}
showInfo={showHyperlink}
/>
</Form.Item>
)
case 'fileList':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<FileUpload
value={value}
multiple={true}
defaultPreviewOpen={previewOpen}
showPreview={showPreview}
showInfo={showHyperlink}
/>
</Form.Item>
)
default:
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
@ -699,7 +770,10 @@ ObjectProperty.propTypes = {
empty: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
difference: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
objectData: PropTypes.object,
height: PropTypes.string
height: PropTypes.string,
previewOpen: PropTypes.bool,
showPreview: PropTypes.bool,
showHyperlink: PropTypes.bool
}
export default ObjectProperty

View File

@ -27,7 +27,7 @@ const ObjectSelect = ({
disabled = false,
...rest
}) => {
const { fetchObjectsByProperty } = useContext(ApiServerContext)
const { fetchObjectsByProperty, fetchObject } = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// --- State ---
const [treeData, setTreeData] = useState([])
@ -39,6 +39,36 @@ const ObjectSelect = ({
const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true)
// Refs to track value changes
const prevValueRef = useRef(value)
const isInternalChangeRef = useRef(false)
// Utility function to check if object only contains _id
const isMinimalObject = useCallback((obj) => {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return false
}
const keys = Object.keys(obj)
return keys.length === 1 && keys[0] === '_id' && obj._id
}, [])
// Function to fetch full object if only _id is present
const fetchFullObjectIfNeeded = useCallback(
async (obj) => {
if (isMinimalObject(obj)) {
try {
const fullObject = await fetchObject(obj._id, type)
return fullObject
} catch (err) {
console.error('Failed to fetch full object:', err)
return obj // Return original object if fetch fails
}
}
return obj
},
[isMinimalObject, fetchObject, type]
)
// Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback(
async (customFilter = filter) => {
@ -79,7 +109,7 @@ const ObjectSelect = ({
})
return {
title: (
<div style={{ paddingTop: '1px' }}>
<div style={{ paddingTop: '2px' }}>
<ObjectProperty
key={object._id}
type='object'
@ -178,6 +208,9 @@ const ObjectSelect = ({
}
const onTreeSelectChange = (value) => {
// Mark this as an internal change
isInternalChangeRef.current = true
// value can be a string (single) or array (multiple)
if (multiple) {
// Multiple selection
@ -211,12 +244,21 @@ const ObjectSelect = ({
}, [objectPropertiesTree, properties, buildTreeData])
useEffect(() => {
if (value && typeof value === 'object' && value !== null && !initialized) {
const handleValue = async () => {
if (
value &&
typeof value === 'object' &&
value !== null &&
!initialized
) {
// Check if value is a minimal object and fetch full object if needed
const fullValue = await fetchFullObjectIfNeeded(value)
// Build a new filter from value's properties that are in the properties list
const valueFilter = { ...filter }
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(value, prop)) {
const filterValue = value[prop]
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
const filterValue = fullValue[prop]
if (filterValue?.name) {
valueFilter[prop] = filterValue.name
} else if (Array.isArray(filterValue)) {
@ -228,7 +270,7 @@ const ObjectSelect = ({
})
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
setTreeSelectValue(value._id)
setTreeSelectValue(fullValue._id)
setInitialized(true)
return
}
@ -236,13 +278,17 @@ const ObjectSelect = ({
handleFetchObjectsProperties()
setInitialized(true)
}
}
handleValue()
}, [
value,
filter,
properties,
handleFetchObjectsProperties,
initialized,
token
token,
fetchFullObjectIfNeeded
])
const prevValuesRef = useRef({ type, masterFilter })
@ -263,6 +309,29 @@ const ObjectSelect = ({
}
}, [type, masterFilter])
useEffect(() => {
// Check if value has actually changed
const hasValueChanged =
JSON.stringify(prevValueRef.current) !== JSON.stringify(value)
if (hasValueChanged) {
const changeSource = isInternalChangeRef.current ? 'internal' : 'external'
if (changeSource == 'external') {
setObjectPropertiesTree({})
setTreeData([])
setInitialized(false)
prevValuesRef.current = { type, masterFilter }
}
// Reset the internal change flag
isInternalChangeRef.current = false
// Update the previous value reference
prevValueRef.current = value
}
}, [value])
// --- Error UI ---
if (error) {
return (

View File

@ -49,12 +49,13 @@ const ObjectTable = forwardRef(
{
type,
pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 270px)',
scrollHeight = 'calc(var(--unit-100vh) - 260px)',
onDataChange,
initialPage = 1,
cards = false,
visibleColumns = {},
masterFilter = {}
masterFilter = {},
size = 'middle'
},
ref
) => {
@ -103,6 +104,7 @@ const ObjectTable = forwardRef(
const unsubscribesRef = useRef([])
const updateEventHandlerRef = useRef()
const subscribeToObjectTypeUpdatesRef = useRef(null)
const prevValuesRef = useRef({ type, masterFilter })
const rowActions =
model.actions?.filter((action) => action.row == true) || []
@ -462,6 +464,27 @@ const ObjectTable = forwardRef(
}
}, [token, loadInitialPage, initialPage, pages, initialized])
// Watch for changes in type and masterFilter, reset component state when they change
useEffect(() => {
const prevValues = prevValuesRef.current
// Deep comparison for objects, simple comparison for primitives
const hasChanged =
prevValues.type !== type ||
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
if (hasChanged) {
setPages([])
setTableFilter({})
setTableSorter({})
setInitialized(false)
setLoading(true)
setLazyLoading(false)
setHasMore(true)
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter])
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
@ -523,7 +546,9 @@ const ObjectTable = forwardRef(
key: 'icon',
width: 45,
fixed: 'left',
render: () => createElement(model.icon)
render: () => {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
}
}
]
@ -771,7 +796,7 @@ const ObjectTable = forwardRef(
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={isElectron ? 'small' : 'middle'}
size={size}
/>
{cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
@ -795,7 +820,8 @@ ObjectTable.propTypes = {
cards: PropTypes.bool,
cardRenderer: PropTypes.func,
visibleColumns: PropTypes.object,
masterFilter: PropTypes.object
masterFilter: PropTypes.object,
size: PropTypes.string
}
export default ObjectTable

View File

@ -20,8 +20,6 @@ const ObjectTypeSelect = ({
label: <ObjectTypeDisplay objectType={model.name} />
}))
console.log('VALUE', value)
return (
<Select
showSearch={showSearch}

View File

@ -1,38 +0,0 @@
// PartSelect.js
import PropTypes from 'prop-types'
import { Badge } from 'antd'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = ['diameter', 'type', 'brand']
const PartSelect = ({ onChange, filter, useFilter }) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/parts`}
propertyOrder={propertyOrder}
getTitle={(item, isLeaf) =>
isLeaf ? <Badge color={item.color} text={item.name} /> : item
}
getValue={(item, isLeaf) => (isLeaf ? item._id : item)}
getKey={(item, isLeaf) => (isLeaf ? item._id : item)}
filter={filter}
useFilter={useFilter}
onChange={onChange}
placeholder='Select Part'
/>
)
}
PartSelect.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
PartSelect.defaultProps = {
filter: {},
useFilter: false
}
export default PartSelect

View File

@ -1,112 +0,0 @@
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Space, Tag, Typography } from 'antd'
import { green } from '@ant-design/colors'
import { useState, useEffect } from 'react'
const { Text } = Typography
const getProgressColor = (percent) => {
if (percent <= 50) {
return green[5]
} else if (percent <= 80) {
// Interpolate between green and yellow
const ratio = (percent - 50) / 30
return `rgb(${Math.round(255 * ratio)}, ${Math.round(255 * (1 - ratio))}, 0)`
} else {
// Interpolate between yellow and red
const ratio = (percent - 80) / 20
return `rgb(255, ${Math.round(255 * (1 - ratio))}, 0)`
}
}
const PartStockState = ({
partStock,
showProgress = true,
showStatus = true
}) => {
const [badgeStatus, setBadgeStatus] = useState('unknown')
const [badgeText, setBadgeText] = useState('Unknown')
const [currentState] = useState(
partStock?.state || {
type: 'unknown',
progress: 0
}
)
useEffect(() => {
switch (currentState.type) {
case 'unused':
setBadgeStatus('success')
setBadgeText('Unused')
break
case 'partiallyused':
setBadgeStatus('warning')
setBadgeText('Partial')
break
case 'fullyused':
setBadgeStatus('error')
setBadgeText('Used')
break
case 'error':
setBadgeStatus('error')
setBadgeText('Error')
break
default:
setBadgeStatus('default')
setBadgeText(currentState.type)
}
}, [currentState])
return (
<Flex gap='middle' align={'center'}>
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
<Flex gap={6}>
<Badge status={badgeStatus} />
{badgeText}
</Flex>
</Tag>
</Space>
)}
{showProgress && currentState.type === 'partiallyused' ? (
<Flex style={{ width: '150px' }} gap={'small'}>
<div style={{ flexGrow: '1' }}>
<Progress
percent={Math.round(currentState.percent * 100)}
style={{ marginBottom: '2px', width: '100%' }}
strokeColor={getProgressColor(
Math.round(currentState.percent * 100)
)}
showInfo={false}
/>
</div>
<Text style={{ marginTop: '1px' }}>
{Math.round(currentState.percent * 100) + '%'}
</Text>
</Flex>
) : null}
</Flex>
)
}
PartStockState.propTypes = {
partStock: PropTypes.shape({
_id: PropTypes.string,
name: PropTypes.string,
state: PropTypes.shape({
type: PropTypes.oneOf([
'unused',
'partiallyused',
'fullyused',
'error',
'unknown'
]),
progress: PropTypes.number
})
}),
showProgress: PropTypes.bool,
showStatus: PropTypes.bool
}
export default PartStockState

View File

@ -1,208 +0,0 @@
import { Transfer, Tree, Badge, Spin } from 'antd'
import { useEffect, useState, useContext, useRef } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import { AuthContext } from '../../Auth/AuthContext'
//
const propertyOrder = ['products']
const PartTransfer = ({
onChange,
filter,
useFilter,
selectedKeys: initialSelectedKeys
}) => {
const [partsTreeData, setPartsTreeData] = useState([])
const [targetKeys, setTargetKeys] = useState(initialSelectedKeys || [])
const { token } = useContext(AuthContext)
const tokenRef = useRef(token)
const [loading, setLoading] = useState(true)
const fetchPartsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/parts', {
params: {
...filter,
property
},
headers: {
Authorization: `Bearer ${tokenRef.current}`
}
})
setLoading(false)
return response.data
} catch (err) {
console.error(err)
console.error(err)
setLoading(false)
return []
}
}
const getFilter = (node) => {
let filter = {}
let currentId = node.id
while (currentId !== 0) {
const currentNode = partsTreeData.find(
(treeData) => treeData.id === currentId
)
if (currentNode) {
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
} else {
break
}
}
return filter
}
const generatePartTreeNodes = async (node = null, filter = null) => {
if (!node) return
const actualFilter = filter === null ? getFilter(node) : filter
const partData = await fetchPartsData(null, actualFilter)
const newNodeList = partData.map((part) => ({
id: part._id,
pId: node.id,
value: part._id,
key: part._id,
title: <Badge color={part.color} text={part.name} />,
isLeaf: true
}))
setPartsTreeData((prev) => [...prev, ...newNodeList])
}
const generatePartCategoryTreeNodes = async (node = null) => {
let filter = {}
let propertyId = 0
if (node) {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchPartsData(propertyName, filter)
const newNodeList = propertyData.map((data) => {
const property = data[propertyName]
const random = Math.random().toString(36).substring(2, 6)
return {
id: random,
pId: node ? node.id : '0',
value: `${property}-${random}`,
key: `${property}-${random}`,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
})
setPartsTreeData((prev) => [...prev, ...newNodeList])
}
const handleTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generatePartCategoryTreeNodes(node)
} else {
await generatePartTreeNodes(node)
}
} else {
await generatePartCategoryTreeNodes(null)
}
}
useEffect(() => {
setPartsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (partsTreeData.length === 0) {
if (useFilter) {
generatePartTreeNodes({ id: '0' }, filter)
} else {
handleTreeLoad(null)
}
}
}, [partsTreeData.length])
const transferDataSource = partsTreeData.filter((node) => node.isLeaf)
const renderTransferItem = (item) => item.title
const handleTransferChange = (newTargetKeys) => {
setTargetKeys(newTargetKeys)
onChange(newTargetKeys)
}
const renderSourceList = ({ onItemSelect }) => {
const treeData = partsTreeData
.map((node) => ({
...node,
children: partsTreeData
.filter((child) => child.pId === node.id)
.map((child) => ({
...child,
children: partsTreeData.filter(
(grandChild) => grandChild.pId === child.id
)
}))
}))
.filter((node) => !node.pId)
return (
<Tree
loadData={(node) => handleTreeLoad(node)}
treeData={treeData}
onSelect={(selectedKeys, { node }) => {
if (node.isLeaf) {
onItemSelect(node.key, !selectedKeys.includes(node.key))
}
}}
/>
)
}
if (loading && partsTreeData.length === 0) {
return <Spin />
}
return (
<Transfer
dataSource={transferDataSource}
targetKeys={targetKeys}
onChange={handleTransferChange}
render={renderTransferItem}
showSelectAll={true}
oneWay={false}
pagination
listStyle={{
width: 300,
height: 400
}}
>
{renderSourceList}
</Transfer>
)
}
PartTransfer.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool,
selectedKeys: PropTypes.arrayOf(PropTypes.string)
}
PartTransfer.defaultProps = {
filter: {},
useFilter: false,
selectedKeys: []
}
export default PartTransfer

View File

@ -1,53 +0,0 @@
import { Table } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import IdDisplay from './IdDisplay'
import PartIcon from '../../Icons/PartIcon'
import PropTypes from 'prop-types'
const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PartIcon />
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => (
<IdDisplay id={text} type={'part'} showHyperlink={true} />
)
}
]
return (
<Table
dataSource={data}
columns={columns}
pagination={false}
rowKey='_id'
showHeader={showHeader}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
)
}
PartsTable.propTypes = {
data: PropTypes.array,
loading: PropTypes.bool,
showHeader: PropTypes.bool
}
export default PartsTable

View File

@ -1,26 +0,0 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const PrinterSelect = ({ onChange, disabled }) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/printers`}
propertyOrder={['tags']}
onChange={onChange}
disabled={disabled}
placeholder='Select Printer'
type='printer'
/>
)
}
PrinterSelect.propTypes = {
onChange: PropTypes.func,
disabled: PropTypes.bool,
checkable: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array])
}
export default PrinterSelect

View File

@ -10,7 +10,7 @@ import {
InputNumber,
Button
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import styled from 'styled-components'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
@ -280,7 +280,7 @@ const PrinterTemperaturePanel = ({
size='small'
items={moreInfoItems}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? 90 : 0} />
<CaretRightOutlined rotate={isActive ? 90 : 0} />
)}
/>
)}

View File

@ -1,239 +0,0 @@
import { useEffect, useContext, useState } from 'react'
import { Table, Typography } from 'antd'
import PropTypes from 'prop-types'
import IdDisplay from './IdDisplay'
import { AuditOutlined } from '@ant-design/icons'
import { PrintServerContext } from '../context/PrintServerContext'
import moment from 'moment'
import TimeDisplay from '../common/TimeDisplay'
import PlusMinusIcon from '../../Icons/PlusMinusIcon'
import SubJobIcon from '../../Icons/SubJobIcon'
import PlayCircleIcon from '../../Icons/PlayCircleIcon'
const { Text } = Typography
const StockEventTable = ({ stockEvents }) => {
const { printServer } = useContext(PrintServerContext)
const [initialized, setInitialized] = useState(false)
const [stockEventsData, setStockEventsData] = useState(stockEvents)
useEffect(() => {
// Add WebSocket event listener for real-time updates
if (printServer && !initialized) {
setInitialized(true)
printServer.on('notify_stockevent_update', (updateData) => {
setStockEventsData((prevData) => {
return prevData.map((stockEvent) => {
if (stockEvent?._id) {
if (stockEvent._id === updateData._id) {
return {
...stockEvent,
...updateData
}
} else {
return stockEvent
}
}
})
})
})
}
return () => {
if (printServer && initialized) {
printServer.off('notify_stockevent_update')
}
}
}, [printServer, initialized])
useEffect(() => {
setStockEventsData(stockEvents)
}, [stockEvents])
const getTypeFilterProps = () => {
// Get unique types from the data
const uniqueTypes = [
...new Set(
stockEventsData.map((record) => {
const type = record.type.toLowerCase()
if (type === 'subjob') return 'Sub Job'
if (type === 'audit') return 'Audit Adjustment'
return type.charAt(0).toUpperCase() + type.slice(1)
})
)
]
return {
filters: uniqueTypes.map((type) => ({ text: type, value: type })),
onFilter: (value, record) => {
const recordType = record.type.toLowerCase()
if (recordType === 'subjob') {
return value === 'Sub Job'
} else if (recordType === 'audit') {
return value === 'Audit Adjustment'
}
return (
value === recordType.charAt(0).toUpperCase() + recordType.slice(1)
)
}
}
}
const columns = [
{
title: '',
key: 'icon',
width: 50,
render: (record) => {
switch (record.type.toLowerCase()) {
case 'subjob':
return <SubJobIcon />
case 'audit':
return <AuditOutlined />
case 'initial':
return <PlayCircleIcon />
default:
return null
}
}
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 200,
sorter: (a, b) => a.type.localeCompare(b.type),
...getTypeFilterProps(),
render: (type) => {
switch (type.toLowerCase()) {
case 'subjob':
return 'Sub Job'
case 'audit':
return 'Audit Adjustment'
default:
return type.charAt(0).toUpperCase() + type.slice(1)
}
}
},
{
title: <PlusMinusIcon />,
dataIndex: 'value',
key: 'value',
width: 100,
sorter: (a, b) => a.value - b.value,
render: (value, record) => {
const formattedValue = value.toFixed(2) + record.unit
return (
<Text type={value < 0 ? 'danger' : 'success'}>
{value > 0 ? '+' + formattedValue : formattedValue}
</Text>
)
}
},
{
title: 'Linked ID',
width: 100,
render: (record) => {
if (record.subJob) {
return (
<IdDisplay
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
)
}
if (record.stockAudit) {
return (
<IdDisplay
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
showHyperlink={true}
/>
)
}
return 'n/a'
}
},
{
title: 'Job ID',
width: 100,
render: (record) => {
if (record.subJob) {
return (
<IdDisplay
id={record.job._id}
longId={false}
type={'job'}
showHyperlink={true}
/>
)
}
return 'n/a'
}
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => moment(a.createdAt).unix() - moment(b.createdAt).unix(),
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
sorter: (a, b) => moment(a.updatedAt).unix() - moment(b.updatedAt).unix(),
render: (updatedAt) => {
if (updatedAt) {
return <TimeDisplay dateTime={updatedAt} />
} else {
return 'n/a'
}
}
}
]
return (
<Table
dataSource={stockEventsData}
columns={columns}
rowKey={(record) => record._id}
pagination={false}
scroll={{ x: 'max-content' }}
/>
)
}
StockEventTable.propTypes = {
stockEvents: PropTypes.arrayOf(
PropTypes.shape({
type: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
subJobId: PropTypes.shape({
$oid: PropTypes.string.isRequired
}),
jobId: PropTypes.shape({
$oid: PropTypes.string.isRequired
}),
timestamp: PropTypes.shape({
$date: PropTypes.string.isRequired
}),
_id: PropTypes.shape({
$oid: PropTypes.string.isRequired
}).isRequired
})
).isRequired
}
export default StockEventTable

View File

@ -1,11 +1,21 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import { Flex, Alert, Card, Spin, Splitter, Button, Modal } from 'antd'
import {
Flex,
Alert,
Card,
Spin,
Splitter,
Button,
Modal,
Segmented
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon.jsx'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import ObjectProperty from '../common/ObjectProperty.jsx'
import TemplatePreview from './TemplatePreview.jsx'
import DataTree from './DataTree.jsx'
const TemplateEditor = ({
objectData,
@ -15,6 +25,7 @@ const TemplateEditor = ({
style
}) => {
const [testObjectOpen, setTestObjectOpen] = useState(false)
const [testObjectViewMode, setTestObjectViewMode] = useState('Tree')
const [previewMessage, setPreviewMessage] = useState('No issues found.')
const [previewError, setPreviewError] = useState(false)
@ -101,12 +112,22 @@ const TemplateEditor = ({
</Button>
}
>
<Flex gap={'small'} vertical>
<div>
<Segmented
options={['Tree', 'Code']}
value={testObjectViewMode}
onChange={(value) => setTestObjectViewMode(value)}
size='small'
/>
</div>
<div
style={{
maxHeight: 'calc(var(--unit-100vh) - 280px)',
overflowY: 'scroll'
}}
>
{testObjectViewMode == 'Code' && (
<ObjectProperty
type={'codeBlock'}
name='testObject'
@ -114,7 +135,17 @@ const TemplateEditor = ({
objectData={objectData}
isEditing={true}
/>
)}
{testObjectViewMode == 'Tree' && (
<DataTree
data={objectData?.testObject}
defaultExpandAll={true}
showValueCopy={false}
showKeyCopy={true}
/>
)}
</div>
</Flex>
</Modal>
</>
)

View File

@ -42,11 +42,6 @@ const TemplatePreview = ({
const reloadPreview = useCallback(
(content, testObject = {}, scale = 1) => {
if (!objectData?._id) {
onPreviewMessage('No object data available for preview.', true)
return
}
setReloadLoading(true)
fetchTemplatePreview(
documentTemplate._id,
@ -70,8 +65,7 @@ const TemplatePreview = ({
// Move useEffect to component level and use state to track objectData changes
useEffect(() => {
if (objectData && documentTemplate?.content) {
console.log('PreviewScale', previewScale)
if (documentTemplate?.content) {
reloadPreview(documentTemplate.content, objectData, previewScale)
}
}, [objectData, documentTemplate, previewScale, reloadPreview])

View File

@ -86,7 +86,6 @@ function ThreeDPreview(props) {
// Load model from file using LoadModelFromFileList
await newViewer.LoadModelFromFileList([file])
console.log(src)
setIsLoading(false)
} catch (err) {
console.error('Failed to load 3D model from src', err)

View File

@ -1,29 +0,0 @@
import PropTypes from 'prop-types'
import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = ['country']
const VendorSelect = ({ onChange, filter = {}, useFilter = false, value }) => {
return (
<ObjectSelect
endpoint={`${config.backendUrl}/vendors`}
propertyOrder={propertyOrder}
filter={filter}
useFilter={useFilter}
value={value}
onChange={onChange}
placeholder='Select a vendor'
type={'vendor'}
/>
)
}
VendorSelect.propTypes = {
onChange: PropTypes.func,
value: PropTypes.object,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
export default VendorSelect

View File

@ -749,29 +749,36 @@ const ApiServerProvider = ({ children }) => {
}
// Download GCode file content
const fetchObjectContent = async (id, type, fileName) => {
const fetchFileContent = async (file, download = false) => {
try {
const response = await axios.get(
`${config.backendUrl}/${type.toLowerCase()}s/${id}/content`,
`${config.backendUrl}/files/${file._id}/content`,
{
headers: {
Accept: 'application/json',
Accept: '*/*',
Authorization: `Bearer ${token}`
}
},
responseType: 'blob'
}
)
const fileURL = window.URL.createObjectURL(new Blob([response.data]))
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
const fileURL = window.URL.createObjectURL(blob)
if (download == true) {
const fileLink = document.createElement('a')
fileLink.href = fileURL
fileLink.setAttribute('download', fileName)
fileLink.setAttribute('download', `${file.name}${file.extension}`)
document.body.appendChild(fileLink)
fileLink.click()
fileLink.parentNode.removeChild(fileLink)
return
}
return fileURL
} catch (err) {
console.error(err)
showError(err, () => {
fetchObjectContent(id, type, fileName)
fetchFileContent(file, download)
})
}
}
@ -874,6 +881,67 @@ const ApiServerProvider = ({ children }) => {
}
}
// Upload file to the API
const uploadFile = async (file, additionalData = {}) => {
const uploadUrl = `${config.backendUrl}/files`
logger.debug('Uploading file:', file.name, 'to:', uploadUrl)
try {
const formData = new FormData()
formData.append('file', file)
// Add any additional data to the form
Object.keys(additionalData).forEach((key) => {
formData.append(key, additionalData[key])
})
const response = await axios.post(uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
logger.debug(`Upload progress: ${percentCompleted}%`)
}
})
logger.debug('File uploaded successfully:', response.data)
return response.data
} catch (err) {
console.error('File upload error:', err)
showError(err, () => {
uploadFile(file, additionalData)
})
return null
}
}
const flushFile = async (id) => {
logger.debug('Flushing file...')
try {
const response = await axios.delete(
`${config.backendUrl}/files/${id}/flush`,
{
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
logger.debug('Flushed file:', response.data)
return true
} catch (err) {
console.error(err)
showError(err, () => {
flushFile(id)
})
}
}
return (
<ApiServerContext.Provider
value={{
@ -897,11 +965,13 @@ const ApiServerProvider = ({ children }) => {
fetchSpotlightData,
fetchLoading,
showError,
fetchObjectContent,
fetchFileContent,
fetchTemplatePreview,
fetchNotes,
fetchHostOTP,
sendObjectAction
sendObjectAction,
uploadFile,
flushFile
}}
>
{contextHolder}

View File

@ -1,3 +1,4 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import FileIcon from '../../components/Icons/FileIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
@ -32,6 +33,14 @@ export const File = {
icon: EditIcon,
url: (_id) => `/dashboard/management/files/info?fileId=${_id}&action=edit`
},
{
name: 'download',
label: 'Download',
row: true,
icon: DownloadIcon,
url: (_id) =>
`/dashboard/management/files/info?fileId=${_id}&action=download`
},
{ type: 'divider' },
{
name: 'delete',

View File

@ -89,6 +89,24 @@ export const GCodeFile = {
value: null,
readOnly: true
},
{
name: 'file',
label: 'File',
type: 'file',
value: null,
required: true,
showPreview: false,
showHyperlink: false,
filter: ['.gcode', '.g']
},
{
name: 'file._id',
label: 'File ID',
type: 'id',
value: null,
objectType: 'file',
showHyperlink: true
},
{
name: 'filament',
label: 'Filament',
@ -105,21 +123,6 @@ export const GCodeFile = {
objectType: 'filament',
showHyperlink: true
},
{
name: 'file',
label: 'File',
type: 'file',
value: null,
required: true
},
{
name: 'file._id',
label: 'File ID',
type: 'id',
value: null,
objectType: 'file',
showHyperlink: true
},
{
name: 'cost',
label: 'Cost',
@ -133,41 +136,41 @@ export const GCodeFile = {
prefix: '£'
},
{
name: 'gcodeFileInfo.estimatedPrintingTimeNormalMode',
name: 'file.metaData.filamentUsedG',
label: 'Est Print Time',
value: null,
type: 'text',
readOnly: true
},
{
name: 'gcodeFileInfo.sparseInfillDensity',
name: 'file.metaData.sparseInfillDensity',
label: 'Infill Density',
columnWidth: 150,
type: 'text',
readOnly: true
},
{
name: 'gcodeFileInfo.sparseInfillPattern',
name: 'file.metaData.sparseInfillPattern',
label: 'Infill Pattern',
type: 'text',
readOnly: true
},
{
name: 'gcodeFileInfo.filamentUsedMm',
name: 'file.metaData.filamentUsedMm',
label: 'Filament Used (mm)',
type: 'number',
readOnly: true,
suffix: 'mm'
},
{
name: 'gcodeFileInfo.filamentUsedG',
name: 'file.metaData.filamentUsedG',
label: 'Filament Used (g)',
type: 'number',
suffix: 'g',
readOnly: true
},
{
name: 'gcodeFileInfo.nozzleTemperature',
name: 'file.metaData.nozzleTemperature',
label: 'Hotend Temp',
columnWidth: 150,
type: 'number',
@ -175,7 +178,7 @@ export const GCodeFile = {
readOnly: true
},
{
name: 'gcodeFileInfo.hotPlateTemp',
name: 'file.metaData.hotPlateTemp',
label: 'Bed Temp',
columnWidth: 150,
type: 'number',
@ -183,13 +186,13 @@ export const GCodeFile = {
readOnly: true
},
{
name: 'gcodeFileInfo.filamentSettingsId',
name: 'file.metaData.filamentSettingsId',
label: 'Filament Profile',
type: 'text',
readOnly: true
},
{
name: 'gcodeFileInfo.printSettingsId',
name: 'file.metaData.printSettingsId',
label: 'Print Profile',
type: 'text',
readOnly: true

View File

@ -1,4 +1,3 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import PartIcon from '../../components/Icons/PartIcon'
@ -25,14 +24,6 @@ export const Part = {
url: (_id) =>
`/dashboard/management/parts/info?partId=${_id}&action=reload`
},
{
name: 'download',
label: 'Download',
row: true,
icon: DownloadIcon,
url: (_id) =>
`/dashboard/management/parts/info?partId=${_id}&action=download`
},
{
name: 'edit',
label: 'Edit',
@ -96,6 +87,29 @@ export const Part = {
showHyperlink: true,
objectType: 'product'
},
{
name: 'vendor',
label: 'Vendor',
required: true,
type: 'object',
objectType: 'vendor',
value: (objectData) => {
console.log(objectData?.vendor, objectData?.product?.vendor)
if (!objectData?.vendor && objectData?.product?.vendor) {
return objectData?.product?.vendor
} else {
return objectData?.vendor
}
}
},
{
name: 'vendor._id',
label: 'Vendor ID',
readOnly: true,
type: 'id',
showHyperlink: true,
objectType: 'vendor'
},
{
name: 'globalPricing',
label: 'Global Price',
@ -106,6 +120,7 @@ export const Part = {
{
name: 'priceMode',
label: 'Price Mode',
required: true,
type: 'priceMode',
disabled: (objectData) => {
return objectData.globalPricing == true
@ -114,6 +129,7 @@ export const Part = {
{
name: 'margin',
label: 'Margin',
required: true,
type: 'number',
disabled: (objectData) => {
return (
@ -128,6 +144,7 @@ export const Part = {
{
name: 'amount',
label: 'Amount',
required: true,
disabled: (objectData) => {
return (
objectData.globalPricing == true || objectData.priceMode == 'margin'
@ -143,7 +160,7 @@ export const Part = {
label: 'File',
type: 'file',
value: null,
required: true
required: false
},
{
name: 'file._id',

View File

@ -90,21 +90,25 @@ export const Product = {
{
name: 'version',
label: 'Version',
required: false,
type: 'text'
},
{
name: 'tags',
label: 'Tags',
required: false,
type: 'tags'
},
{
name: 'priceMode',
label: 'Price Mode',
required: true,
type: 'priceMode'
},
{
name: 'margin',
label: 'Margin',
required: true,
type: 'number',
disabled: (objectData) => {
return objectData.priceMode == 'amount'
@ -121,6 +125,7 @@ export const Product = {
return objectData.priceMode == 'margin'
},
type: 'number',
required: true,
prefix: '£',
min: 0,
step: 0.1

View File

@ -16,6 +16,7 @@ export const SubJob = {
type: 'id',
columnFixed: 'left',
objectType: 'subJob',
columnWidth: 140,
showCopy: true
},
{
@ -29,6 +30,7 @@ export const SubJob = {
name: 'printer._id',
label: 'Printer ID',
type: 'id',
columnWidth: 140,
columnFixed: 'left',
showHyperlink: true,
objectType: 'printer'
@ -37,6 +39,7 @@ export const SubJob = {
name: 'job._id',
label: 'Job ID',
type: 'id',
columnWidth: 140,
showHyperlink: true,
objectType: 'job'
},
@ -49,14 +52,15 @@ export const SubJob = {
showProgress: true,
showId: false,
showQuantity: false,
columnWidth: 150,
columnWidth: 125,
readOnly: true
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true
readOnly: true,
columnWidth: 175
}
]
}

309
src/utils/cookies.js Normal file
View File

@ -0,0 +1,309 @@
// Cookie utility functions for authentication
const COOKIE_OPTIONS = {
path: '/',
secure: window.location.protocol === 'https:',
sameSite: 'Lax',
maxAge: 7 * 24 * 60 * 60 // 7 days in seconds
}
/**
* Set a cookie with authentication data
* @param {string} name - Cookie name
* @param {string} value - Cookie value
* @param {Object} options - Cookie options
*/
export const setCookie = (name, value, options = {}) => {
try {
if (!name || value === undefined || value === null) {
console.warn('Invalid cookie parameters:', { name, value })
return false
}
const cookieOptions = { ...COOKIE_OPTIONS, ...options }
console.log('VALUE', value)
let cookieString = `${name}=${encodeURIComponent(value)}`
if (cookieOptions.maxAge) {
cookieString += `; Max-Age=${cookieOptions.maxAge}`
}
if (cookieOptions.path) {
cookieString += `; Path=${cookieOptions.path}`
}
if (cookieOptions.domain) {
cookieString += `; Domain=${cookieOptions.domain}`
}
if (cookieOptions.secure) {
cookieString += '; Secure'
}
if (cookieOptions.sameSite) {
cookieString += `; SameSite=${cookieOptions.sameSite}`
}
if (cookieOptions.httpOnly) {
cookieString += '; HttpOnly'
}
document.cookie = cookieString
return true
} catch (error) {
console.error('Error setting cookie:', error)
return false
}
}
/**
* Get a cookie value by name
* @param {string} name - Cookie name
* @returns {string|null} - Cookie value or null if not found
*/
export const getCookie = (name) => {
try {
if (!name) {
console.warn('Cookie name is required')
return null
}
const nameEQ = name + '='
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i]
console.log(cookie)
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length)
}
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(
cookie.substring(nameEQ.length, cookie.length)
)
}
}
return null
} catch (error) {
console.error('Error getting cookie:', error)
return null
}
}
/**
* Remove a cookie by setting it to expire in the past
* @param {string} name - Cookie name
* @param {Object} options - Cookie options (path and domain must match when setting)
*/
export const removeCookie = (name, options = {}) => {
try {
if (!name) {
console.warn('Cookie name is required for removal')
return false
}
const cookieOptions = { ...COOKIE_OPTIONS, ...options }
setCookie(name, '', { ...cookieOptions, maxAge: -1 })
return true
} catch (error) {
console.error('Error removing cookie:', error)
return false
}
}
/**
* Check if cookies are enabled in the browser
* @returns {boolean} - True if cookies are enabled
*/
export const areCookiesEnabled = () => {
try {
setCookie('test', 'test')
const enabled = getCookie('test') === 'test'
removeCookie('test')
return enabled
} catch (e) {
console.log(e)
return false
}
}
/**
* Check if authentication cookies are expired and clean them up if needed
* @returns {boolean} - True if cookies are valid and not expired
*/
export const validateAuthCookies = () => {
try {
const { token, expiresAt, user } = getAuthCookies()
if (!token || !expiresAt || !user) {
return false
}
const now = new Date()
const expirationDate = new Date(expiresAt)
if (expirationDate <= now) {
// Cookies are expired, clean them up
clearAuthCookies()
return false
}
return true
} catch (error) {
console.error('Error validating auth cookies:', error)
clearAuthCookies()
return false
}
}
/**
* Check if authentication cookies are about to expire (within specified minutes)
* @param {number} minutesBeforeExpiry - Minutes before expiry to consider "about to expire"
* @returns {Object} - Object with isExpiringSoon flag and timeRemaining in milliseconds
*/
export const checkAuthCookiesExpiry = (minutesBeforeExpiry = 5) => {
try {
const { token, expiresAt, user } = getAuthCookies()
if (!token || !expiresAt || !user) {
return { isExpiringSoon: false, timeRemaining: 0 }
}
const now = new Date()
const expirationDate = new Date(expiresAt)
const timeRemaining = expirationDate - now
const minutesRemaining = timeRemaining / (1000 * 60)
return {
isExpiringSoon: minutesRemaining <= minutesBeforeExpiry,
timeRemaining,
minutesRemaining: Math.floor(minutesRemaining)
}
} catch (error) {
console.error('Error checking auth cookies expiry:', error)
return { isExpiringSoon: false, timeRemaining: 0 }
}
}
/**
* Set up a listener for cookie changes to sync authentication state between tabs
* @param {Function} onAuthChange - Callback function when auth state changes
* @returns {Function} - Function to remove the listener
*/
export const setupCookieSync = (onAuthChange) => {
const handleStorageChange = (event) => {
// Check if auth-related cookies changed
if (
event.key === 'authToken' ||
event.key === 'authExpiresAt' ||
event.key === 'user'
) {
// Small delay to ensure cookies are updated
setTimeout(() => {
onAuthChange()
}, 100)
}
}
// Listen for storage events (for cross-tab communication)
window.addEventListener('storage', handleStorageChange)
// Also listen for cookie changes using a polling mechanism
let lastCookieState = document.cookie
const cookieCheckInterval = setInterval(() => {
const currentCookieState = document.cookie
if (currentCookieState !== lastCookieState) {
lastCookieState = currentCookieState
// Check if auth cookies changed
const authCookies = getAuthCookies()
if (authCookies.token || authCookies.expiresAt || authCookies.user) {
onAuthChange()
}
}
}, 1000)
// Return cleanup function
return () => {
window.removeEventListener('storage', handleStorageChange)
clearInterval(cookieCheckInterval)
}
}
/**
* Set authentication cookies
* @param {Object} authData - Authentication data object
* @returns {boolean} - True if all cookies were set successfully
*/
export const setAuthCookies = (authData) => {
try {
if (!authData) {
console.warn('Auth data is required')
return false
}
let success = true
if (authData.access_token) {
success =
success &&
setCookie('authToken', authData.access_token, {
maxAge: 7 * 24 * 60 * 60
}) // 7 days
}
if (authData.expires_at) {
success =
success &&
setCookie('authExpiresAt', authData.expires_at, {
maxAge: 7 * 24 * 60 * 60
})
}
if (authData.user) {
const userObject = {
...authData.user,
access_token: undefined,
refresh_token: undefined,
id_token: undefined
}
success =
success &&
setCookie('user', JSON.stringify(userObject), {
maxAge: 7 * 24 * 60 * 60
})
}
if (!success) {
console.warn('Some cookies failed to set, clearing all auth cookies')
clearAuthCookies()
}
return success
} catch (error) {
console.error('Error setting auth cookies:', error)
clearAuthCookies()
return false
}
}
/**
* Get authentication cookies
* @returns {Object} - Object containing auth data from cookies
*/
export const getAuthCookies = () => {
return {
token: getCookie('authToken'),
expiresAt: getCookie('authExpiresAt'),
user: getCookie('user') ? JSON.parse(getCookie('user')) : null
}
}
/**
* Clear authentication cookies
*/
export const clearAuthCookies = () => {
removeCookie('authToken')
removeCookie('authExpiresAt')
removeCookie('user')
}