Compare commits

...

16 Commits

89 changed files with 23985 additions and 4433 deletions

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?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"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.822506,0,0,0.822506,6.66348,3)"> <g transform="matrix(0.870499,0,0,0.870499,4.81535,1)">
<path d="M21.602,59.936C22.919,59.936 23.767,59.102 23.736,57.873L22.779,25.111C22.742,23.893 21.882,23.084 20.622,23.084C19.316,23.084 18.473,23.919 18.504,25.142L19.436,57.89C19.473,59.133 20.339,59.936 21.602,59.936ZM30.807,59.936C32.101,59.936 33.013,59.113 33.013,57.896L33.013,25.131C33.013,23.913 32.101,23.084 30.807,23.084C29.507,23.084 28.621,23.913 28.621,25.131L28.621,57.896C28.621,59.113 29.507,59.936 30.807,59.936ZM40.032,59.936C41.275,59.936 42.135,59.133 42.172,57.89L43.104,25.142C43.135,23.919 42.292,23.084 40.986,23.084C39.726,23.084 38.866,23.893 38.829,25.136L37.898,57.873C37.866,59.102 38.715,59.936 40.032,59.936ZM16.61,14.377L22.378,14.377L22.378,7.886C22.378,6.268 23.509,5.23 25.249,5.23L36.308,5.23C38.048,5.23 39.179,6.268 39.179,7.886L39.179,14.377L44.947,14.377L44.947,7.596C44.947,2.818 41.897,0 36.719,0L24.838,0C19.66,0 16.61,2.818 16.61,7.596L16.61,14.377ZM2.766,17.777L58.868,17.777C60.408,17.777 61.608,16.554 61.608,15.014C61.608,13.485 60.408,12.288 58.868,12.288L2.766,12.288C1.251,12.288 0,13.485 0,15.014C0,16.579 1.251,17.777 2.766,17.777ZM16.455,70.516L45.179,70.516C49.963,70.516 53.069,67.588 53.294,62.798L55.442,17.177L49.646,17.177L47.591,61.868C47.529,63.743 46.292,64.987 44.51,64.987L17.073,64.987C15.342,64.987 14.105,63.711 14.017,61.868L11.86,17.177L6.166,17.177L8.34,62.824C8.57,67.613 11.62,70.516 16.455,70.516Z" style="fill-rule:nonzero;"/> <path d="M22.063,60.247C23.479,60.247 24.38,59.348 24.349,58.015L23.4,25.77C23.359,24.458 22.438,23.58 21.075,23.58C19.679,23.58 18.788,24.479 18.819,25.802L19.747,58.045C19.788,59.379 20.718,60.247 22.063,60.247ZM31.234,60.247C32.61,60.247 33.573,59.368 33.573,58.055L33.573,25.782C33.573,24.469 32.61,23.58 31.234,23.58C29.848,23.58 28.906,24.469 28.906,25.782L28.906,58.055C28.906,59.368 29.848,60.247 31.234,60.247ZM40.416,60.247C41.749,60.247 42.67,59.379 42.711,58.045L43.639,25.802C43.67,24.479 42.779,23.58 41.383,23.58C40.02,23.58 39.099,24.458 39.058,25.792L38.13,58.015C38.099,59.348 39,60.247 40.416,60.247ZM16.67,14.45L23.05,14.45L23.05,8.11C23.05,6.617 24.092,5.67 25.728,5.67L36.687,5.67C38.324,5.67 39.365,6.617 39.365,8.11L39.365,14.45L45.745,14.45L45.745,7.838C45.745,2.869 42.608,0 37.15,0L25.265,0C19.807,0 16.67,2.869 16.67,7.838L16.67,14.45ZM3.089,18.54L59.39,18.54C61.128,18.54 62.458,17.218 62.458,15.48C62.458,13.761 61.128,12.46 59.39,12.46L3.089,12.46C1.372,12.46 0,13.761 0,15.48C0,17.239 1.372,18.54 3.089,18.54ZM16.852,71.224L45.627,71.224C50.653,71.224 53.841,68.278 54.07,63.242L56.188,17.937L49.805,17.937L47.781,62.006C47.719,63.881 46.564,65.074 44.829,65.074L17.607,65.074C15.915,65.074 14.761,63.849 14.677,62.006L12.567,17.937L6.27,17.937L8.409,63.264C8.648,68.299 11.784,71.224 16.852,71.224Z" style="fill-rule:nonzero;"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,8 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?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"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.946467,0,0,0.946467,5.27386,-5.84858)"> <g transform="matrix(0.887983,0,0,0.887983,8.03271,-3.10981)">
<path d="M56.476,33.038L56.476,63.651C56.476,70.319 52.969,73.799 46.224,73.799L10.251,73.799C3.506,73.799 0,70.344 0,63.651L0,33.038C0,26.338 3.532,22.889 10.251,22.889L19.448,22.889L19.448,29.011L10.632,29.011C7.726,29.011 6.122,30.51 6.122,33.546L6.122,63.136C6.122,66.178 7.701,67.677 10.606,67.677L45.844,67.677C48.724,67.677 50.353,66.178 50.353,63.136L50.353,33.546C50.353,30.51 48.724,29.011 45.844,29.011L37.002,29.011L37.002,22.889L46.224,22.889C52.969,22.889 56.476,26.364 56.476,33.038Z" style="fill-rule:nonzero;"/> <g transform="matrix(1.05656,0,0,1.05656,-3.25204,-2.99489)">
<path d="M28.251,6.179C26.665,6.179 25.346,7.476 25.346,9.002L25.346,40.367L25.578,45.021L23.935,43.036L19.457,38.274C18.958,37.712 18.234,37.417 17.502,37.417C16.071,37.417 14.943,38.441 14.943,39.903C14.943,40.674 15.252,41.236 15.771,41.756L26.061,51.659C26.804,52.391 27.476,52.629 28.251,52.629C28.999,52.629 29.677,52.391 30.414,51.659L40.704,41.756C41.229,41.236 41.533,40.674 41.533,39.903C41.533,38.441 40.353,37.417 38.948,37.417C38.216,37.417 37.518,37.712 37.019,38.274L32.541,43.036L30.898,45.021L31.13,40.367L31.13,9.002C31.13,7.476 29.836,6.179 28.251,6.179Z" style="fill-rule:nonzero;"/> <path d="M57.248,33.426L57.248,63.862C57.248,70.707 53.569,74.364 46.66,74.364L10.588,74.364C3.679,74.364 0,70.728 0,63.862L0,33.426C0,26.549 3.7,22.923 10.588,22.923L19.504,22.923L19.504,29.882L11.188,29.882C8.451,29.882 6.959,31.26 6.959,34.133L6.959,63.145C6.959,66.027 8.429,67.405 11.167,67.405L46.059,67.405C48.776,67.405 50.288,66.027 50.288,63.145L50.288,34.133C50.288,31.26 48.776,29.882 46.059,29.882L37.722,29.882L37.722,22.923L46.66,22.923C53.569,22.923 57.248,26.571 57.248,33.426Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.05656,0,0,1.05656,-3.25204,-2.99489)">
<path d="M28.634,6.149C26.863,6.149 25.419,7.601 25.419,9.291L25.419,40.337L25.686,44.965L24.125,42.872L19.927,38.412C19.38,37.802 18.605,37.473 17.791,37.473C16.27,37.473 14.994,38.588 14.994,40.187C14.994,41.024 15.325,41.633 15.883,42.191L26.156,52.069C26.99,52.883 27.77,53.16 28.634,53.16C29.478,53.16 30.268,52.883 31.091,52.069L41.364,42.191C41.932,41.633 42.253,41.024 42.253,40.187C42.253,38.588 40.935,37.473 39.435,37.473C38.622,37.473 37.867,37.802 37.321,38.412L33.123,42.872L31.562,44.965L31.829,40.337L31.829,9.291C31.829,7.601 30.406,6.149 28.634,6.149Z" style="fill-rule:nonzero;"/>
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.786027,0,0,0.786027,-0.159276,6.23774)">
<path d="M55.68,37.394C58.27,37.394 60.374,35.29 60.374,32.726C60.374,30.136 58.27,28.026 55.68,28.026C53.09,28.026 50.986,30.136 50.986,32.726C50.986,35.29 53.09,37.394 55.68,37.394Z" style="fill-rule:nonzero;"/>
<path d="M41.007,37.394C43.597,37.394 45.707,35.29 45.707,32.726C45.707,30.136 43.597,28.026 41.007,28.026C38.443,28.026 36.339,30.136 36.339,32.726C36.339,35.29 38.443,37.394 41.007,37.394Z" style="fill-rule:nonzero;"/>
<path d="M26.366,37.394C28.956,37.394 31.061,35.29 31.061,32.726C31.061,30.136 28.931,28.026 26.366,28.026C23.776,28.026 21.672,30.136 21.672,32.726C21.672,35.29 23.776,37.394 26.366,37.394Z" style="fill-rule:nonzero;"/>
<g transform="matrix(1.3198,0,0,1.3198,-2.17489,-9.45821)">
<g transform="matrix(1,0,0,1,0,0.0751136)">
<path d="M19.323,6.766L10.395,6.766C9.015,6.766 7.895,7.885 7.895,9.266L7.895,54.584C7.895,55.965 9.015,57.084 10.395,57.084L20.82,57.084C22.2,57.084 23.32,55.964 23.32,54.584C23.32,53.204 22.2,52.084 20.82,52.084L12.895,52.084C12.895,52.084 12.895,11.766 12.895,11.766C12.895,11.766 19.323,11.766 19.323,11.766C20.703,11.766 21.823,10.646 21.823,9.266C21.823,7.886 20.703,6.766 19.323,6.766Z"/>
</g>
<g transform="matrix(-1,0,0,1,65.2958,0.0751136)">
<path d="M19.323,11.766L12.895,11.766C12.895,11.766 12.895,52.084 12.895,52.084C12.895,52.084 20.82,52.084 20.82,52.084C22.2,52.084 23.32,53.204 23.32,54.584C23.32,55.964 22.2,57.084 20.82,57.084L10.395,57.084C9.015,57.084 7.895,55.965 7.895,54.584L7.895,9.266C7.895,7.885 9.015,6.766 10.395,6.766L19.323,6.766C20.703,6.766 21.823,7.886 21.823,9.266C21.823,10.646 20.703,11.766 19.323,11.766Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.537732,0,0,0.537732,3.45345,3.98971)">
<rect x="0" y="0" width="57.875" height="56.688" style="fill-opacity:0;"/>
<g transform="matrix(0.90281,0,0,0.90281,53.0869,52.9925)">
<path d="M22.484,56.688C24.609,56.688 26.281,55.859 27.422,54.141L56.516,9.609C57.344,8.359 57.688,7.188 57.688,6.109C57.688,3.188 55.484,1.063 52.5,1.063C50.438,1.063 49.172,1.781 47.906,3.75L22.359,44.094L9.375,28C8.234,26.594 6.969,25.969 5.219,25.969C2.188,25.969 0,28.141 0,31.078C0,32.375 0.406,33.531 1.516,34.828L17.656,54.375C18.984,55.953 20.5,56.688 22.484,56.688Z" style="fill-rule:nonzero;"/>
</g>
</g>
<g transform="matrix(1,0,0,1,6.05665,8.7167)">
<rect x="0" y="0" width="51.887" height="51.762" style="fill-opacity:0;"/>
<g transform="matrix(0.450214,0,0,0.450214,-2.06221,-4.74859)">
<path d="M1.342,50.412C3.17,52.209 6.311,52.162 8.014,50.459L25.857,32.615L43.67,50.443C45.436,52.209 48.514,52.209 50.326,50.397C52.139,48.568 52.154,45.522 50.389,43.74L32.576,25.897L50.389,8.084C52.154,6.318 52.154,3.256 50.326,1.443C48.498,-0.385 45.436,-0.4 43.67,1.381L25.857,19.193L8.014,1.365C6.311,-0.338 3.154,-0.416 1.342,1.428C-0.455,3.256 -0.408,6.365 1.295,8.068L19.139,25.897L1.295,43.772C-0.408,45.459 -0.471,48.6 1.342,50.412Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.962144,0.0378562,0.0378562,0.962144,-4.91275,-9.82292)">
<path d="M4.936,62.562L60.127,7.371C61.183,6.315 61.248,4.666 60.272,3.69C59.296,2.715 57.647,2.78 56.592,3.835L1.401,59.026C0.345,60.082 0.28,61.731 1.256,62.706C2.232,63.682 3.881,63.617 4.936,62.562Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.948653,0,0,0.948653,5.29485,2.93181)">
<path d="M11.694,61.211C13.582,61.211 14.878,60.219 15.269,58.266L26.147,4.125C26.221,3.87 26.289,3.374 26.289,2.939C26.289,1.144 25.017,0.072 23.169,0.072C21.07,0.072 20.004,1.198 19.639,3.1L8.761,57.189C8.688,57.553 8.625,58.023 8.625,58.375C8.625,60.171 9.851,61.211 11.694,61.211ZM32.046,61.211C33.965,61.211 35.236,60.219 35.633,58.266L46.53,4.125C46.573,3.87 46.641,3.374 46.641,2.939C46.641,1.144 45.369,0.072 43.552,0.072C41.422,0.072 40.393,1.209 40.022,3.1L29.119,57.189C29.051,57.553 29.008,58.023 29.008,58.375C29.008,60.171 30.228,61.211 32.046,61.211ZM7.086,22.466L52.675,22.466C54.861,22.466 56.301,20.979 56.301,18.887C56.301,17.203 55.15,15.931 53.356,15.931L7.772,15.931C5.606,15.931 4.14,17.458 4.14,19.576C4.14,21.286 5.322,22.466 7.086,22.466ZM2.946,43.97L48.518,43.97C50.689,43.97 52.15,42.512 52.15,40.419C52.15,38.735 50.973,37.453 49.21,37.453L3.626,37.453C1.435,37.453 0,38.973 0,41.091C0,42.807 1.146,43.97 2.946,43.97Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.782134,0,0,0.782134,-7.10543e-15,6.36533)">
<path d="M0,32.731C0,34.898 1.251,36.421 3.609,36.861C7.591,37.561 9.134,39.524 9.134,44.432L9.134,52.409C9.134,61.409 13.296,65.494 22.245,65.494C22.886,65.494 23.529,65.394 24.011,65.258C25.464,64.784 26.175,63.761 26.175,62.466C26.175,60.994 25.52,60.2 24.184,59.851C23.844,59.772 23.492,59.704 23.083,59.667C18.081,59.281 16.276,57.109 16.276,51.581L16.276,41.926C16.276,36.561 13.224,33.471 8.079,32.935C7.873,32.918 7.862,32.647 8.079,32.624C13.224,32.094 16.276,29.004 16.276,23.639L16.276,13.975C16.276,8.421 18.081,6.301 23.083,5.915C23.6,5.872 24.06,5.799 24.406,5.674C25.582,5.294 26.175,4.449 26.175,3.085C26.175,1.761 25.466,0.786 24.021,0.321C23.434,0.136 22.782,0.057 21.977,0.057C13.27,0.057 9.134,4.198 9.134,13.148L9.134,21.133C9.134,25.995 7.569,27.984 3.609,28.679C1.251,29.129 0,30.596 0,32.731ZM81.827,32.731C81.827,30.596 80.551,29.129 78.192,28.679C74.253,27.984 72.662,25.995 72.662,21.133L72.662,13.148C72.662,4.198 68.532,0.057 59.819,0.057C59.019,0.057 58.393,0.136 57.806,0.321C56.356,0.786 55.627,1.761 55.627,3.085C55.627,4.449 56.245,5.294 57.421,5.674C57.767,5.799 58.202,5.872 58.719,5.915C63.716,6.301 65.552,8.421 65.552,13.975L65.552,23.639C65.552,29.004 68.577,32.094 73.722,32.624C73.94,32.647 73.929,32.918 73.722,32.935C68.577,33.471 65.552,36.561 65.552,41.926L65.552,51.581C65.552,57.109 63.716,59.281 58.719,59.667C58.31,59.704 57.984,59.772 57.643,59.851C56.307,60.2 55.627,60.994 55.627,62.466C55.627,63.761 56.364,64.784 57.816,65.258C58.298,65.394 58.916,65.494 59.557,65.494C68.501,65.494 72.662,61.409 72.662,52.409L72.662,44.432C72.662,39.524 74.236,37.561 78.192,36.861C80.551,36.421 81.827,34.898 81.827,32.731Z" style="fill-rule:nonzero;"/>
<path d="M55.68,37.394C58.27,37.394 60.374,35.29 60.374,32.726C60.374,30.136 58.27,28.026 55.68,28.026C53.09,28.026 50.986,30.136 50.986,32.726C50.986,35.29 53.09,37.394 55.68,37.394Z" style="fill-rule:nonzero;"/>
<path d="M41.007,37.394C43.597,37.394 45.707,35.29 45.707,32.726C45.707,30.136 43.597,28.026 41.007,28.026C38.443,28.026 36.339,30.136 36.339,32.726C36.339,35.29 38.443,37.394 41.007,37.394Z" style="fill-rule:nonzero;"/>
<path d="M26.366,37.394C28.956,37.394 31.061,35.29 31.061,32.726C31.061,30.136 28.931,28.026 26.366,28.026C23.776,28.026 21.672,30.136 21.672,32.726C21.672,35.29 23.776,37.394 26.366,37.394Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.23625,0,0,1.23625,7.42576,3)">
<rect x="0" y="0" width="39.756" height="46.916" style="fill-opacity:0;"/>
<g transform="matrix(0.93194,0,0,0.93194,1.44027,1.6178)">
<path d="M3.457,46.871C5.558,46.871 6.641,46.021 7.319,43.804L10.799,34.089L28.646,34.089L32.131,43.804C32.804,46.021 33.886,46.871 35.967,46.871C38.157,46.871 39.569,45.556 39.569,43.515C39.569,42.764 39.447,42.112 39.141,41.271L25.305,3.91C24.362,1.289 22.591,0 19.748,0C17.034,0 15.249,1.269 14.325,3.884L0.417,41.431C0.127,42.234 0,42.914 0,43.58C0,45.615 1.321,46.871 3.457,46.871ZM12.589,28.281L19.659,7.976L19.838,7.976L26.903,28.281L12.589,28.281Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.7717,0,0,0.7717,3.33978,3)">
<path d="M61.524,26.5L72.199,39.549C73.869,41.518 74.278,43.126 74.278,46.544L74.278,65.01C74.278,71.678 70.798,75.159 64.021,75.159L10.251,75.159C3.506,75.159 0,71.678 0,65.01L0,46.544C0,43.126 0.434,41.518 2.074,39.549L12.762,26.511C16.453,22.039 18.384,20.608 23.688,20.608L28.359,20.608L28.359,25.746L23.436,25.746C21.307,25.746 19.709,26.484 18.301,28.253L6.932,42.459C6.401,43.116 6.62,43.985 7.64,43.985L27.879,43.985C29.527,43.985 30.28,45.156 30.28,46.387L30.28,46.506C30.28,49.901 32.927,53.522 37.136,53.522C41.377,53.522 44.024,49.901 44.024,46.506L44.024,46.387C44.024,45.156 44.746,43.985 46.399,43.985L66.658,43.985C67.684,43.985 67.871,43.116 67.366,42.459L55.971,28.253C54.569,26.484 52.971,25.746 50.842,25.746L45.919,25.746L45.919,20.608L50.591,20.608C55.889,20.608 57.84,22.028 61.524,26.5ZM6.044,49.411L6.044,64.47C6.044,67.512 7.622,69.036 10.528,69.036L63.745,69.036C66.631,69.036 68.234,67.512 68.234,64.47L68.234,49.411L49.288,49.411C48.319,54.987 43.452,59.157 37.136,59.157C30.826,59.157 25.959,55.013 24.984,49.411L6.044,49.411Z" style="fill-rule:nonzero;"/>
<path d="M37.136,42.333C38.722,42.333 40.041,41.042 40.041,39.51L40.041,12.262L39.789,7.634L41.452,9.619L45.904,14.381C46.404,14.938 47.133,15.233 47.834,15.233C49.27,15.233 50.424,14.188 50.424,12.727C50.424,11.981 50.141,11.419 49.616,10.894L39.326,0.996C38.588,0.264 37.911,0 37.136,0C36.393,0 35.69,0.264 34.947,0.996L24.663,10.894C24.138,11.419 23.854,11.981 23.854,12.727C23.854,14.188 24.983,15.233 26.419,15.233C27.119,15.233 27.874,14.938 28.368,14.381L32.821,9.619L34.489,7.609L34.263,12.262L34.263,39.51C34.263,41.042 35.551,42.333 37.136,42.333Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

19101
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"keycloak-js": "^26.2.0", "keycloak-js": "^26.2.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"moment": "^2.30.1", "moment": "^2.30.1",
"online-3d-viewer": "^0.16.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-eslint": "^16.4.2", "prettier-eslint": "^16.4.2",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",

View File

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

View File

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

View File

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

View File

@ -1,148 +1,29 @@
// src/partStocks.js // src/partStocks.js
import { useState, useContext, useRef } from 'react' import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext' import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import NewPartStock from './PartStocks/NewPartStock' 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 PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import PartStockState from '../common/PartStockState' import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay'
import ObjectTable from '../common/ObjectTable' import ObjectTable from '../common/ObjectTable'
import ListIcon from '../../Icons/ListIcon'
import config from '../../../config' import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
const { Text } = Typography import ColumnViewButton from '../common/ColumnViewButton'
const PartStocks = () => { const PartStocks = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const tableRef = useRef() const tableRef = useRef()
const [newPartStockOpen, setNewPartStockOpen] = useState(false) const [newPartStockOpen, setNewPartStockOpen] = useState(false)
const { authenticated } = useContext(AuthContext) const [viewMode, setViewMode] = useViewMode('partStocks')
const getPartStockActionItems = (id) => { const [columnVisibility, setColumnVisibility] =
return { useColumnVisibility('partStocks')
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 actionItems = { const actionItems = {
items: [ items: [
@ -171,23 +52,40 @@ const PartStocks = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Space> <Flex justify={'space-between'}>
<Dropdown menu={actionItems}> <Space size='small'>
<Button>Actions</Button> <Dropdown menu={actionItems}>
</Dropdown> <Button>Actions</Button>
</Space> </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 <ObjectTable
ref={tableRef} ref={tableRef}
columns={columns} visibleColumns={columnVisibility}
url={`${config.backendUrl}/partstocks`} type='partStock'
authenticated={authenticated} cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal
open={newPartStockOpen} open={newPartStockOpen}
styles={{ content: { paddingBottom: '24px' } }} styles={{ content: { paddingBottom: '24px' } }}
footer={null} footer={null}
width={700} width={800}
onCancel={() => { onCancel={() => {
setNewPartStockOpen(false) 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 { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import loglevel from 'loglevel' import loglevel from 'loglevel'
@ -19,6 +19,10 @@ import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' 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') const log = loglevel.getLogger('FileInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -38,9 +42,12 @@ const FileInfo = () => {
editLoading: false, editLoading: false,
formValid: false, formValid: false,
lock: null, lock: null,
loading: false, loading: true,
objectData: {} objectData: {
_id: fileId
}
}) })
const { fetchFileContent } = useContext(ApiServerContext)
const actions = { const actions = {
reload: () => { reload: () => {
@ -59,6 +66,10 @@ const FileInfo = () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true return true
}, },
download: () => {
fetchFileContent(objectFormState?.objectData, true)
return true
},
delete: () => { delete: () => {
objectFormRef?.current?.handleDelete?.() objectFormRef?.current?.handleDelete?.()
return true return true
@ -85,6 +96,7 @@ const FileInfo = () => {
disabled={objectFormState.loading} disabled={objectFormState.loading}
items={[ items={[
{ key: 'info', label: 'File Information' }, { key: 'info', label: 'File Information' },
{ key: 'preview', label: 'Preview' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' } { key: 'auditLogs', label: 'Audit Logs' }
]} ]}
@ -138,6 +150,7 @@ const FileInfo = () => {
style={{ height: '100%' }} style={{ height: '100%' }}
ref={objectFormRef} ref={objectFormRef}
onStateChange={(state) => { onStateChange={(state) => {
console.log(state)
setEditFormState((prev) => ({ ...prev, ...state })) setEditFormState((prev) => ({ ...prev, ...state }))
}} }}
> >
@ -152,6 +165,24 @@ const FileInfo = () => {
</ObjectForm> </ObjectForm>
</InfoCollapse> </InfoCollapse>
</ActionHandler> </ActionHandler>
<InfoCollapse
title='Preview'
icon={<FileIcon />}
active={collapseState.preview}
onToggle={(expanded) => updateCollapseState('preview', expanded)}
collapseKey='preview'
>
{objectFormState?.objectData?._id ? (
<Card>
<FilePreview
file={objectFormState?.objectData}
style={{ width: '100%', height: '100%' }}
/>
</Card>
) : (
<MissingPlaceholder message={'No file.'} />
)}
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Notes' title='Notes'
icon={<NoteIcon />} icon={<NoteIcon />}

View File

@ -55,7 +55,7 @@ const HostInfo = () => {
}, },
hostOTP: () => { hostOTP: () => {
setHostOTPOpen(true) setHostOTPOpen(true)
return true return false
}, },
edit: () => { edit: () => {
objectFormRef?.current.startEditing() objectFormRef?.current.startEditing()
@ -229,6 +229,7 @@ const HostInfo = () => {
destroyOnHidden={true} destroyOnHidden={true}
width={650} width={650}
onCancel={() => { onCancel={() => {
actionHandlerRef.current.clearAction()
setHostOTPOpen(false) setHostOTPOpen(false)
}} }}
footer={false} footer={false}

View File

@ -4,7 +4,7 @@ import { useState, useRef } from 'react'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd' import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import ObjectTable from '../common/ObjectTable' import ObjectTable from '../common/ObjectTable'
import NewProduct from './Products/NewProduct' import NewPart from './Parts/NewPart'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
@ -20,7 +20,7 @@ import ColumnViewButton from '../common/ColumnViewButton'
const Parts = (filter) => { const Parts = (filter) => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [newProductOpen, setNewProductOpen] = useState(false) const [newPartOpen, setNewPartOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('part') const [viewMode, setViewMode] = useViewMode('part')
const [columnVisibility, setColumnVisibility] = useColumnVisibility('part') const [columnVisibility, setColumnVisibility] = useColumnVisibility('part')
@ -28,8 +28,8 @@ const Parts = (filter) => {
const actionItems = { const actionItems = {
items: [ items: [
{ {
label: 'New Product', label: 'New Part',
key: 'newProduct', key: 'newPart',
icon: <PlusIcon /> icon: <PlusIcon />
}, },
{ type: 'divider' }, { type: 'divider' },
@ -42,8 +42,8 @@ const Parts = (filter) => {
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
tableRef.current?.reload() tableRef.current?.reload()
} else if (key === 'newProduct') { } else if (key === 'newPart') {
setNewProductOpen(true) setNewPartOpen(true)
} }
} }
} }
@ -82,21 +82,21 @@ const Parts = (filter) => {
/> />
</Flex> </Flex>
<Modal <Modal
open={newProductOpen} open={newPartOpen}
footer={null} footer={null}
width={700} width={700}
onCancel={() => { onCancel={() => {
setNewProductOpen(false) setNewPartOpen(false)
}} }}
destroyOnHidden={true} destroyOnHidden={true}
> >
<NewProduct <NewPart
onOk={() => { onOk={() => {
setNewProductOpen(false) setNewPartOpen(false)
messageApi.success('Product created successfully!') messageApi.success('Part created successfully!')
tableRef.current?.reload() tableRef.current?.reload()
}} }}
reset={newProductOpen} reset={newPartOpen}
/> />
</Modal> </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 { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
@ -17,14 +17,12 @@ import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
const PartInfo = () => { const PartInfo = () => {
const location = useLocation() const location = useLocation()
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)
const partId = new URLSearchParams(location.search).get('partId') const partId = new URLSearchParams(location.search).get('partId')
const { fetchObjectContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', { const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
info: true, info: true,
parts: true, parts: true,
@ -55,13 +53,6 @@ const PartInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current?.handleUpdate?.() objectFormRef?.current?.handleUpdate?.()
return true 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 PropTypes from 'prop-types'
import { useState, useContext, useEffect, useRef } from 'react' import ObjectInfo from '../../common/ObjectInfo'
import axios from 'axios' import NewObjectForm from '../../common/NewObjectForm'
import { useMediaQuery } from 'react-responsive' import WizardView from '../../common/WizardView'
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 }
]
const NewProduct = ({ onOk }) => {
return ( return (
<Flex gap='middle'> <NewObjectForm type={'product'} defaultValues={{ priceMode: 'margin' }}>
{contextHolder} {({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{!isMobile && ( {
<div style={{ minWidth: '160px' }}> title: 'Required',
<Steps key: 'required',
current={currentStep} content: (
items={steps} <ObjectInfo
direction='vertical' type='product'
style={{ width: 'fit-content' }} column={1}
/> bordered={false}
</div> isEditing={true}
)} required={true}
objectData={objectData}
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />} visibleProperties={{
priceMode: false,
<Flex vertical gap='middle' style={{ flexGrow: 1 }}> margin: false,
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> amount: false
New Product }}
</Title> />
)
<Form },
name='basic' {
autoComplete='off' title: 'Pricing',
form={newProductForm} key: 'pricing',
onFinish={handleNewProduct} content: (
onValuesChange={(changedValues) => <ObjectInfo
setNewProductFormValues((prevValues) => ({ type='product'
...prevValues, column={1}
...changedValues 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} ]
> return (
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div> <WizardView
</Form> steps={steps}
loading={submitLoading}
<Flex justify='end'> formValid={formValid}
<Button title='New Product'
style={{ margin: '0 8px' }} onSubmit={() => {
onClick={() => { handleSubmit()
setCurrentStep((prev) => prev - 1) onOk()
setNextEnabled(true)
}} }}
disabled={currentStep === 0} />
> )
Previous }}
</Button> </NewObjectForm>
{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>
) )
} }
NewProduct.propTypes = { NewProduct.propTypes = {
reset: PropTypes.bool.isRequired, onOk: PropTypes.func.isRequired,
onOk: PropTypes.func.isRequired reset: PropTypes.bool
} }
export default NewProduct export default NewProduct

View File

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

View File

@ -1,554 +1,89 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useState, useContext, useEffect } from 'react' import ObjectInfo from '../../common/ObjectInfo'
import axios from 'axios' import NewObjectForm from '../../common/NewObjectForm'
import { useMediaQuery } from 'react-responsive' import WizardView from '../../common/WizardView'
import {
capitalizeFirstLetter,
timeStringToMinutes
} from '../../utils/Utils.js'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Upload,
Descriptions,
Checkbox,
Spin,
InputNumber,
Badge
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../../context/AuthContext.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import FilamentSelect from '../../common/FilamentSelect'
import config from '../../../../config.js'
const { Dragger } = Upload
const { Title } = Typography
const initialNewGCodeFileForm = {
gcodeFileInfo: {},
name: '',
printTimeMins: 0,
cost: 0,
file: null,
filament: null
}
//const chunkSize = 5000
const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi] = message.useMessage()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [gcodeParsing, setGcodeParsing] = useState(false)
const [filamentSelectFilter, setFilamentSelectFilter] = useState(null)
const [useFilamentSelectFilter, setUseFilamentSelectFilter] = useState(true)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [nextLoading, setNextLoading] = useState(false)
const [newGCodeFileForm] = Form.useForm()
const [newGCodeFileFormValues, setNewGCodeFileFormValues] = useState(
initialNewGCodeFileForm
)
const [gcodeFile, setGCodeFile] = useState(null)
const newGCodeFileFormUpdateValues = Form.useWatch([], newGCodeFileForm)
const { token, authenticated } = useContext(AuthContext)
// eslint-disable-next-line
const fetchFilamentInfo = async () => {
if (!authenticated) {
return
}
if (
newGCodeFileFormValues.filament &&
newGCodeFileFormValues.gcodeFileInfo
) {
try {
setNextLoading(true)
const response = await axios.get(
`${config.backendUrl}/filaments/${newGCodeFileFormValues.filament}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setNextLoading(false)
const price =
(response.data.price / 1000) *
newGCodeFileFormValues.gcodeFileInfo.filament_used_g // convert kg to g and multiply
const printTimeMins = timeStringToMinutes(
newGCodeFileFormValues.gcodeFileInfo
.estimated_printing_time_normal_mode
)
setNewGCodeFileFormValues({
...newGCodeFileFormValues,
price,
printTimeMins
})
} catch (error) {
if (error.response) {
messageApi.error(
'Error fetching filament data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
}
useEffect(() => {
newGCodeFileForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newGCodeFileForm, newGCodeFileFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newGCodeFileFormValues?.name
},
{
key: 'filament',
label: 'Filament',
children:
newGCodeFileFormValues?.filament != null ? (
<>
{newGCodeFileFormValues.filament}
<Badge
text={newGCodeFileFormValues.filament.name}
color={newGCodeFileFormValues.filament.color}
/>
</>
) : (
'n/a'
)
},
{
key: 'cost',
label: 'Cost',
children: '£' + newGCodeFileFormValues?.cost
},
{
key: 'sparse_infill_density',
label: 'Infill Density',
children: newGCodeFileFormValues?.gcodeFileInfo?.sparseInfillDensity
},
{
key: 'sparse_infill_pattern',
label: 'Infill Pattern',
children: capitalizeFirstLetter(
newGCodeFileFormValues?.gcodeFileInfo?.sparseInfillPattern
)
},
{
key: 'layer_height',
label: 'Layer Height',
children: newGCodeFileFormValues?.gcodeFileInfo?.layerHeight + 'mm'
},
{
key: 'filamentType',
label: 'Filament Material',
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentType
},
{
key: 'filamentUsedG',
label: 'Filament Used (g)',
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG + 'g'
},
{
key: 'filamentVendor',
label: 'Filament Brand',
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentVendor
},
{
key: 'hotendTemperature',
label: 'Hotend Temperature',
children: newGCodeFileFormValues?.gcodeFileInfo?.nozzleTemperature + '°'
},
{
key: 'bedTemperature',
label: 'Bed Temperature',
children: newGCodeFileFormValues?.gcodeFileInfo?.hotPlateTemp + '°'
},
{
key: 'estimated_printing_time_normal_mode',
label: 'Est. Print Time',
children:
newGCodeFileFormValues?.gcodeFileInfo?.estimatedPrintingTimeNormalMode
}
]
useEffect(() => {
if (reset) {
setCurrentStep(0)
newGCodeFileForm.resetFields()
}
}, [reset, newGCodeFileForm])
useEffect(() => {
const filamentCost = newGCodeFileFormValues?.filament?.cost
const gcodeFilamentUsed =
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
if (filamentCost && gcodeFilamentUsed) {
const cost = (filamentCost / 1000) * gcodeFilamentUsed
setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) }))
newGCodeFileForm.setFieldValue('cost', cost.toFixed(2))
}
}, [
newGCodeFileForm,
newGCodeFileFormValues?.filament?.cost,
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
])
const handleNewGCodeFileUpload = async (id) => {
setNewGCodeFileLoading(true)
const formData = new FormData()
formData.append('gcodeFile', gcodeFile)
try {
await axios.post(
`${config.backendUrl}/gcodefiles/${id}/content`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
}
}
)
resetForm()
onOk()
} catch (error) {
messageApi.error('Error creating new gcode file: ' + error.message)
} finally {
setNewGCodeFileLoading(false)
}
}
const handleNewGCodeFile = async () => {
setNewGCodeFileLoading(true)
try {
const request = await axios.post(
`${config.backendUrl}/gcodefiles`,
newGCodeFileFormValues,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
messageApi.info('New G Code file created successfully. Uploading...')
handleNewGCodeFileUpload(request.data._id)
} catch (error) {
messageApi.error('Error creating new gcode file: ' + error.message)
} finally {
setNewGCodeFileLoading(false)
}
}
const handleGetGCodeFileInfo = async (file) => {
try {
setGcodeParsing(true)
// Create a FormData object to send the file
const formData = new FormData()
formData.append('gcodeFile', file)
// Call the API to extract and parse the config block
const request = await axios.post(
`${config.backendUrl}/gcodefiles/content`,
formData,
{
withCredentials: true // Important for including cookies
},
{
headers: {
Accept: 'application/json'
}
}
)
// Parse the API response
const parsedConfig = await request.data
// Update state with the parsed config from API
setNewGCodeFileFormValues({
...newGCodeFileFormValues,
gcodeFileInfo: parsedConfig
})
// Update filter settings if filament info is available
if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
setFilamentSelectFilter({
type: parsedConfig.filament_type,
diameter: parsedConfig.filament_diameter
})
}
const fileName = file.name.replace(/\.[^/.]+$/, '')
newGCodeFileForm.setFieldValue('name', fileName)
setNewGCodeFileFormValues((prev) => ({
...prev,
name: fileName
}))
setGCodeFile(file)
setGcodeParsing(false)
setCurrentStep(currentStep + 1)
} catch (error) {
console.error('Error getting G-code file info:', error)
}
}
const resetForm = () => {
newGCodeFileForm.setFieldsValue(initialNewGCodeFileForm)
setNewGCodeFileFormValues(initialNewGCodeFileForm)
setGCodeFile(null)
setGcodeParsing(false)
setCurrentStep(0)
}
const steps = [
{
title: 'Upload',
key: 'upload',
content: (
<>
<Form.Item
rules={[
{
required: true,
message: 'Please upload a gcode file.'
}
]}
name='file'
style={{ height: '100%' }}
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
>
<Dragger
name='G Code File'
maxCount={1}
showUploadList={false}
customRequest={({ file, onSuccess }) => {
handleGetGCodeFileInfo(file)
setTimeout(() => {
onSuccess('ok')
}, 0)
}}
>
<Flex style={{ height: '100%' }} vertical>
{gcodeParsing == true ? (
<Spin
indicator={
<LoadingOutlined style={{ fontSize: 24 }} spin />
}
/>
) : (
<>
<p className='ant-upload-drag-icon'>
<GCodeFileIcon />
</p>
<p className='ant-upload-text'>
Click or drag gcode file here.
</p>
<p className='ant-upload-hint'>
Supported file extentions: .gcode, .gco, .g
</p>
</>
)}
</Flex>
</Dragger>
</Form.Item>
</>
)
},
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Flex gap={'middle'}>
<Form.Item
label='Filament'
name='filament'
style={{ width: '100%' }}
rules={[
{
required: true,
message: 'Please provide a materal.'
}
]}
>
<FilamentSelect
filter={filamentSelectFilter}
useFilter={useFilamentSelectFilter}
/>
</Form.Item>
<Form.Item>
<Checkbox
checked={useFilamentSelectFilter}
onChange={(e) => {
setUseFilamentSelectFilter(e.target.checked)
}}
>
Filter
</Checkbox>
</Form.Item>
</Flex>
<Form.Item
label='Cost'
name='cost'
rules={[
{
required: true,
message: 'Please enter a cost.'
}
]}
>
<InputNumber
controls={false}
addonBefore='£'
step={0.01}
style={{ width: '100%' }}
readOnly
/>
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'done',
content: (
<>
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
</>
)
}
]
const NewGCodeFile = ({ onOk }) => {
return ( return (
<Flex gap={'middle'}> <NewObjectForm
{!isMobile && ( type={'gcodeFile'}
<div style={{ minWidth: '160px' }}> defaultValues={{
<Steps state: { type: 'draft' }
current={currentStep} }}
items={steps} >
direction='vertical' {({ handleSubmit, submitLoading, objectData, formValid }) => {
style={{ width: 'fit-content' }} const steps = [
/> {
</div> title: 'Upload',
)} key: 'uplaod',
content: (
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />} <ObjectInfo
type='gcodeFile'
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'> column={1}
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> bordered={false}
New G Code File isEditing={true}
</Title> required={true}
<Form objectData={objectData}
name='basic' visibleProperties={{ name: false, filament: false }}
autoComplete='off' showLabels={false}
form={newGCodeFileForm} />
onFinish={handleNewGCodeFile} )
onValuesChange={(changedValues) => },
setNewGCodeFileFormValues((prevValues) => ({ {
...prevValues, title: 'Required',
...changedValues key: 'required',
})) content: (
} <ObjectInfo
initialValues={initialNewGCodeFileForm} type='gcodeFile'
> column={1}
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div> bordered={false}
isEditing={true}
<Flex justify={'end'}> required={true}
<Button objectData={objectData}
style={{ visibleProperties={{ file: false }}
margin: '0 8px' />
}} )
onClick={() => { },
setCurrentStep(currentStep - 1) {
setNextEnabled(true) title: 'Summary',
}} key: 'summary',
disabled={!(currentStep > 0)} content: (
> <ObjectInfo
Previous type='gcodeFile'
</Button> column={1}
{currentStep < steps.length - 1 && ( bordered={false}
<Button visibleProperties={{
type='primary' _id: false,
disabled={!nextEnabled} createdAt: false,
loading={nextLoading} updatedAt: false,
onClick={() => { startedAt: false
setCurrentStep(currentStep + 1)
setNextEnabled(false)
}} }}
> isEditing={false}
Next objectData={objectData}
</Button> />
)} )
{currentStep === steps.length - 1 && ( }
<Button ]
type='primary' return (
htmlType='submit' <WizardView
loading={newGCodeFileLoading} steps={steps}
> loading={submitLoading}
Done formValid={formValid}
</Button> title='New GCode File'
)} onSubmit={() => {
</Flex> handleSubmit()
</Form> onOk()
</Flex> }}
</Flex> />
)
}}
</NewObjectForm>
) )
} }
NewGCodeFile.propTypes = { NewGCodeFile.propTypes = {
reset: PropTypes.bool.isRequired, onOk: PropTypes.func.isRequired,
onOk: PropTypes.func.isRequired reset: PropTypes.bool
} }
export default NewGCodeFile export default NewGCodeFile

View File

@ -0,0 +1,117 @@
import { useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import WizardView from '../../common/WizardView'
import { ApiServerContext } from '../../context/ApiServerContext'
import { Flex, Typography } from 'antd'
import ObjectTable from '../../common/ObjectTable'
import ObjectSelect from '../../common/ObjectSelect'
const { Text } = Typography
const DeployJob = ({ onOk, objectData = undefined }) => {
const [deployLoading, setDeployLoading] = useState(false)
const [job, setJob] = useState(objectData)
const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0)
const [subJobsCount, setSubJobsCount] = useState(999)
const { sendObjectAction, fetchObjects } = useContext(ApiServerContext)
const handleDeploy = async () => {
setDeployLoading(true)
var hasMore = true
var currentPage = 1
var subJobs = []
while (hasMore == true) {
const subJobsPage = await fetchObjects('subJob', {
page: currentPage,
filter: { 'job._id': job._id }
})
subJobs = [...subJobs, ...subJobsPage.data]
if (subJobsPage.data.length >= 25) {
currentPage = currentPage + 1
} else {
hasMore = false
}
}
setSubJobsCount(subJobs.length)
subJobs.forEach((subJob) => {
console.log(
'Deploying subjob:',
subJob._id,
'to printer:',
subJob.printer._id
)
sendObjectAction(
subJob.printer._id,
'printer',
{ type: 'deploy', data: subJob },
(result) => {
console.log('result', result)
setDeployedSubJobsCount((prev) => prev + 1)
}
)
})
}
useEffect(() => {
if (deployedSubJobsCount == subJobsCount && deployLoading == true) {
onOk()
}
}, [deployedSubJobsCount, subJobsCount, deployLoading])
const steps = [
{
title: 'Confirm',
key: 'confirm',
content: (
<Flex vertical gap={'middle'} style={{ width: '100%' }}>
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
<Text type='secondary'>Job:</Text>
<ObjectSelect
type={'job'}
style={{ flexGrow: 1 }}
value={objectData}
onChange={(newJob) => {
setJob(newJob)
}}
/>
</Flex>
<ObjectTable
type={'subJob'}
scrollHeight={'200px'}
visibleColumns={{
printer: false,
'job._id': false,
'printer._id': false
}}
masterFilter={{ 'job._id': job?._id }}
size={'small'}
/>
</Flex>
)
}
]
return (
<WizardView
steps={steps}
loading={deployLoading}
formValid={objectData != undefined}
title='Deploy Job'
showSteps={false}
submitText='Deploy'
progress={(deployedSubJobsCount / subJobsCount) * 100}
onSubmit={() => {
handleDeploy()
}}
/>
)
}
DeployJob.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object,
reset: PropTypes.bool
}
export default DeployJob

View File

@ -1,6 +1,6 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd' import { Space, Flex, Card, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config.js' import config from '../../../../config.js'
@ -21,6 +21,7 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import JobIcon from '../../../Icons/JobIcon.jsx' import JobIcon from '../../../Icons/JobIcon.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import DeployJob from './DeployJob.jsx'
const log = loglevel.getLogger('JobInfo') const log = loglevel.getLogger('JobInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -30,6 +31,8 @@ const JobInfo = () => {
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)
const jobId = new URLSearchParams(location.search).get('jobId') const jobId = new URLSearchParams(location.search).get('jobId')
const [deployJobOpen, setDeployJobOpen] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', { const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true, info: true,
subJobs: true, subJobs: true,
@ -43,7 +46,9 @@ const JobInfo = () => {
formValid: false, formValid: false,
locked: false, locked: false,
loading: false, loading: false,
objectData: {} objectData: {
_id: jobId
}
}) })
const actions = { const actions = {
@ -62,6 +67,10 @@ const JobInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current.handleUpdate() objectFormRef?.current.handleUpdate()
return true return true
},
deploy: () => {
setDeployJobOpen(true)
return false
} }
} }
@ -207,6 +216,24 @@ const JobInfo = () => {
</Flex> </Flex>
</div> </div>
</Flex> </Flex>
<Modal
destroyOnHidden
footer={null}
open={deployJobOpen}
width={700}
onCancel={() => {
actionHandlerRef.current.clearAction()
setDeployJobOpen(false)
}}
>
<DeployJob
objectData={{ _id: jobId }}
onOk={() => {
actionHandlerRef.current.clearAction()
setDeployJobOpen(false)
}}
/>
</Modal>
</> </>
) )
} }

View File

@ -1,18 +1,9 @@
import { useState } from 'react'
import PropTypes from 'prop-types' 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 ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const { Title } = Typography
const NewJob = ({ onOk }) => { const NewJob = ({ onOk }) => {
const [currentStep, setCurrentStep] = useState(0)
const isMobile = useMediaQuery({ maxWidth: 768 })
return ( return (
<NewObjectForm <NewObjectForm
type={'job'} type={'job'}
@ -57,43 +48,16 @@ const NewJob = ({ onOk }) => {
} }
] ]
return ( return (
<Flex gap='middle'> <WizardView
{!isMobile && ( steps={steps}
<div style={{ minWidth: '160px' }}> loading={submitLoading}
<Steps formValid={formValid}
current={currentStep} title='New Job'
items={steps} onSubmit={() => {
direction='vertical' handleSubmit()
style={{ width: 'fit-content' }} onOk()
/> }}
</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)}
onSubmit={() => {
handleSubmit()
onOk()
}}
formValid={formValid}
submitLoading={submitLoading}
/>
</Flex>
</Flex>
) )
}} }}
</NewObjectForm> </NewObjectForm>

View File

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

View File

@ -30,10 +30,19 @@ const ActionHandler = forwardRef(
navigate(newPath, { replace: true }) navigate(newPath, { replace: true })
} }
// Method to clear current action from URL
const clearAction = () => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete(actionParam)
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
}
// Execute action and clear from URL // Execute action and clear from URL
useEffect(() => { useEffect(() => {
if ( if (
!loading && loading == false &&
action && action &&
actions[action] && actions[action] &&
lastExecutedAction.current !== action lastExecutedAction.current !== action
@ -72,7 +81,8 @@ const ActionHandler = forwardRef(
]) ])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
callAction callAction,
clearAction
})) }))
// Return null as this is a utility component // Return null as this is a utility component

View File

@ -123,7 +123,6 @@ const DashboardNavigation = () => {
} else { } else {
setApiServerState('disconnected') setApiServerState('disconnected')
} }
console.log('Connecting/connected', connecting, connected)
}, [connecting, connected]) }, [connecting, connected])
const handleMainMenuClick = ({ key }) => { const handleMainMenuClick = ({ key }) => {
@ -221,7 +220,11 @@ const DashboardNavigation = () => {
} }
/> />
{isMobile && <div style={{ flexGrow: 1 }} />} {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> <Space>
<KeyboardShortcut <KeyboardShortcut
shortcut='alt+q' shortcut='alt+q'

View File

@ -0,0 +1,238 @@
import PropTypes from 'prop-types'
import { Tree, Typography, Space, Tag } from 'antd'
import { useState, useMemo } from 'react'
import XMarkIcon from '../../Icons/XMarkIcon'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import JsonStringIcon from '../../Icons/JsonStringIcon'
import JsonArrayIcon from '../../Icons/JsonArrayIcon'
import JsonObjectIcon from '../../Icons/JsonObjectIcon'
import JsonBoolIcon from '../../Icons/JsonBoolIcon'
import JsonNumberIcon from '../../Icons/JsonNumberIcon'
import CopyButton from './CopyButton'
const { Text } = Typography
const DataTree = ({
data,
showLine = true,
showValueCopy = true,
showKeyCopy = false,
defaultExpandAll = false,
onNodeSelect,
style = {}
}) => {
const [expandedKeys, setExpandedKeys] = useState([])
const [selectedKeys, setSelectedKeys] = useState([])
// Function to get data type and format value
const getDataTypeInfo = (value) => {
if (value === null)
return { type: 'null', color: 'default', icon: <XMarkIcon /> }
if (value === undefined)
return {
type: 'undefined',
color: 'default',
icon: <QuestionCircleIcon />
}
if (typeof value === 'boolean')
return { type: 'boolean', color: 'blue', icon: <JsonBoolIcon /> }
if (typeof value === 'number')
return { type: 'number', color: 'green', icon: <JsonNumberIcon /> }
if (typeof value === 'string')
return { type: 'string', color: 'orange', icon: <JsonStringIcon /> }
if (Array.isArray(value))
return { type: 'array', color: 'purple', icon: <JsonArrayIcon /> }
if (typeof value === 'object')
return { type: 'object', color: 'cyan', icon: <JsonObjectIcon /> }
return { type: 'unknown', color: 'default', icon: <QuestionCircleIcon /> }
}
// Function to format value for display
const formatValue = (value) => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'boolean') return value.toString()
if (typeof value === 'number') return value.toString()
if (typeof value === 'string') {
// Truncate long strings
return value.length > 50 ? `${value.substring(0, 50)}...` : value
}
if (Array.isArray(value)) return `Array (${value.length} items)`
if (typeof value === 'object') {
const keys = Object.keys(value)
return `Object (${keys.length} properties)`
}
return String(value)
}
// Function to get raw value for copying
const getCopyValue = (value) => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'boolean') return value.toString()
if (typeof value === 'number') return value.toString()
if (typeof value === 'string') return value
if (Array.isArray(value)) return JSON.stringify(value, null, 2)
if (typeof value === 'object') return JSON.stringify(value, null, 2)
return String(value)
}
// Recursive function to convert JSON to tree data
const convertToTreeData = (obj, key = 'root', path = '') => {
const currentPath = path ? `${path}.${key}` : key
const dataInfo = getDataTypeInfo(obj)
const node = {
title: (
<Space size='small'>
<Tag color={dataInfo.color} size='small' style={{ margin: 0 }}>
{dataInfo.icon}
</Tag>
<Text strong>{key}</Text>
{showKeyCopy && (
<CopyButton text={key} tooltip={`Copy key: ${key}`} />
)}
<Text type='secondary'>({dataInfo.type})</Text>
{dataInfo.type !== 'object' && dataInfo.type !== 'array' && (
<>
<Text code>{formatValue(obj)}</Text>
{showValueCopy && (
<CopyButton
text={getCopyValue(obj)}
tooltip={`Copy ${dataInfo.type} value`}
/>
)}
</>
)}
{(dataInfo.type === 'object' || dataInfo.type === 'array') &&
showValueCopy && (
<CopyButton
text={getCopyValue(obj)}
tooltip={`Copy ${dataInfo.type} as JSON`}
/>
)}
</Space>
),
key: currentPath,
value: obj,
path: currentPath
}
// Add children for objects and arrays
if (typeof obj === 'object' && obj !== null) {
if (Array.isArray(obj)) {
node.children = obj.map((item, index) =>
convertToTreeData(item, `[${index}]`, currentPath)
)
} else {
node.children = Object.entries(obj).map(([childKey, childValue]) =>
convertToTreeData(childValue, childKey, currentPath)
)
}
}
return node
}
// Convert data to tree structure
const treeData = useMemo(() => {
if (!data) return []
if (typeof data === 'object' && data !== null) {
if (Array.isArray(data)) {
return [convertToTreeData(data, 'root')]
} else {
return [convertToTreeData(data, 'root')]
}
} else {
// Handle primitive values
const dataInfo = getDataTypeInfo(data)
return [
{
title: (
<Space size='small'>
<Tag color={dataInfo.color} size='small' style={{ margin: 0 }}>
{dataInfo.icon}
</Tag>
<Text strong>Value</Text>
<Text type='secondary'>({dataInfo.type})</Text>
<Text code>{formatValue(data)}</Text>
{showValueCopy && (
<CopyButton
text={getCopyValue(data)}
tooltip={`Copy ${dataInfo.type} value`}
/>
)}
</Space>
),
key: 'root',
value: data,
path: 'root'
}
]
}
}, [data])
// Handle node selection
const handleSelect = (selectedKeys, { selected, selectedNodes }) => {
setSelectedKeys(selectedKeys)
if (onNodeSelect && selected && selectedNodes.length > 0) {
const node = selectedNodes[0]
onNodeSelect({
key: node.key,
value: node.value,
path: node.path
})
}
}
// Handle expand/collapse
const handleExpand = (keys) => {
setExpandedKeys(keys)
}
// Auto-expand all if requested
const getExpandedKeys = () => {
if (defaultExpandAll) {
return treeData.length > 0 ? getAllKeys(treeData[0]) : []
}
return expandedKeys
}
// Helper function to get all keys for auto-expand
const getAllKeys = (node) => {
let keys = [node.key]
if (node.children) {
node.children.forEach((child) => {
keys = keys.concat(getAllKeys(child))
})
}
return keys
}
return (
<Tree
rootStyle={{ background: 'transparent', ...style }}
treeData={treeData}
expandedKeys={getExpandedKeys()}
selectedKeys={selectedKeys}
onExpand={handleExpand}
onSelect={handleSelect}
showLine={showLine}
showIcon={false}
blockNode
/>
)
}
DataTree.propTypes = {
data: PropTypes.any.isRequired,
showLine: PropTypes.bool,
showValueCopy: PropTypes.bool,
showKeyCopy: PropTypes.bool,
defaultExpandAll: PropTypes.bool,
onNodeSelect: PropTypes.func,
style: PropTypes.object
}
export default DataTree

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

@ -0,0 +1,177 @@
import { Card, Flex, Typography, Button, Tag, Divider } from 'antd'
import PropTypes from 'prop-types'
import FileIcon from '../../Icons/FileIcon'
import BinIcon from '../../Icons/BinIcon'
import EyeIcon from '../../Icons/EyeIcon'
import DownloadIcon from '../../Icons/DownloadIcon'
import { useContext, useState } from 'react'
import { ApiServerContext } from '../context/ApiServerContext'
import FilePreview from './FilePreview'
import EyeSlashIcon from '../../Icons/EyeSlashIcon'
import { getModelByName } from '../../../database/ObjectModels'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { useNavigate } from 'react-router-dom'
const { Text } = Typography
const FileList = ({
files,
onChange,
multiple = true,
editing = false,
showPreview = true,
showInfo = true,
showDownload = true,
defaultPreviewOpen = false,
card = true
}) => {
const { fetchFileContent, flushFile } = useContext(ApiServerContext)
const navigate = useNavigate()
const [previewOpen, setPreviewOpen] = useState(defaultPreviewOpen)
const infoAction = getModelByName('file').actions.filter(
(action) => action.name == 'info'
)[0]
// Check if there are no items in the list
const hasNoItems = multiple
? !files || !Array.isArray(files) || files.length === 0
: !files
if (hasNoItems) {
return null
}
const handleRemove = (fileToRemove) => {
flushFile(fileToRemove._id)
if (multiple) {
const currentFiles = Array.isArray(files) ? files : []
const updatedFiles = currentFiles.filter((file) => {
const fileUid = file._id || file.id
const removeUid = fileToRemove._id || fileToRemove.id
return fileUid !== removeUid
})
onChange(updatedFiles)
} else {
onChange(null)
}
}
const handleDownload = async (file) => {
await fetchFileContent(file, true)
}
const filesToRender = multiple ? files : [files]
const renderFileContent = (file) => (
<Flex vertical gap='10px'>
<Flex justify={card ? 'space-between' : 'start'}>
<Flex gap={'small'} align='center'>
<FileIcon
style={{ margin: 0, fontSize: card == true ? '24px' : '16px' }}
/>
<Text style={{ marginTop: '1px' }}>
{file.name || file.filename || 'Unknown file'}
</Text>
<Tag>{file.extension}</Tag>
</Flex>
<Flex gap={'small'} align='center'>
{showDownload && (
<Button
icon={<DownloadIcon />}
size='small'
type='text'
onClick={() => handleDownload(file)}
/>
)}
{showPreview && (
<Button
icon={previewOpen ? <EyeSlashIcon /> : <EyeIcon />}
size='small'
type='text'
onClick={() => {
if (previewOpen == true) {
setPreviewOpen(false)
} else {
setPreviewOpen(true)
}
}}
/>
)}
{showInfo && (
<Button
icon={<InfoCircleIcon />}
size='small'
type='text'
onClick={() => {
navigate(infoAction.url(file._id))
}}
/>
)}
{editing && (
<Button
icon={<BinIcon />}
size='small'
type='text'
onClick={() => handleRemove(file)}
/>
)}
</Flex>
</Flex>
{previewOpen ? (
<>
<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>
)
}
FileList.propTypes = {
files: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
onChange: PropTypes.func,
multiple: PropTypes.bool,
editing: PropTypes.bool,
showPreview: PropTypes.string,
showInfo: PropTypes.bool,
showDownload: PropTypes.bool,
defaultPreviewOpen: PropTypes.bool,
card: PropTypes.bool
}
export default FileList

View File

@ -0,0 +1,86 @@
import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
import { useCallback, useContext, useEffect, useState, memo } from 'react'
import LoadingPlaceholder from './LoadingPlaceholder'
import GCodePreview from './GCodePreview'
import ThreeDPreview from './ThreeDPreview'
import { AuthContext } from '../context/AuthContext'
const FilePreview = ({ file, style = {} }) => {
const { token } = useContext(AuthContext)
const { fetchFileContent } = useContext(ApiServerContext)
const [fileObjectUrl, setFileObjectUrl] = useState(null)
const [loading, setLoading] = useState(true)
const fetchPreview = useCallback(async () => {
setLoading(true)
const objectUrl = await fetchFileContent(file, false)
setFileObjectUrl(objectUrl)
setLoading(false)
}, [file._id, fetchFileContent])
useEffect(() => {
if (file?.type && token != null) {
fetchPreview()
}
}, [file._id, file?.type, fetchPreview, token])
if (loading == true || !file?.type) {
return <LoadingPlaceholder message={'Loading file preview...'} />
}
const isGcode = ['.g', '.gcode'].includes(
(file?.extension || '').toLowerCase()
)
const is3DModel = ['.stl', '.3mf'].includes(
(file?.extension || '').toLowerCase()
)
const isImage = file?.type.startsWith('image/')
if (isGcode && fileObjectUrl) {
return (
<GCodePreview
src={fileObjectUrl}
topLayerColor={'#ff9800'}
lastSegmentColor={'#e91e63'}
startLayer={0}
endLayer={undefined}
lineWidth={1}
style={style}
/>
)
}
if (is3DModel && fileObjectUrl) {
return (
<ThreeDPreview
src={fileObjectUrl}
style={style}
extension={file.extension}
/>
)
}
if (isImage && fileObjectUrl) {
return <img src={fileObjectUrl} style={style}></img>
}
return null
}
FilePreview.propTypes = {
file: PropTypes.object.isRequired,
style: PropTypes.object
}
// Custom comparison function to only re-render when file._id changes
const areEqual = (prevProps, nextProps) => {
return (
prevProps.file?._id === nextProps.file?._id &&
JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)
)
}
export default memo(FilePreview, areEqual)

View File

@ -0,0 +1,150 @@
import { Upload, Button, Flex, Typography, Space } from 'antd'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
import UploadIcon from '../../Icons/UploadIcon'
import { useContext, useState, useEffect } from 'react'
import ObjectSelect from './ObjectSelect'
import FileList from './FileList'
import PlusIcon from '../../Icons/PlusIcon'
const { Text } = Typography
const FileUpload = ({
value,
onChange,
multiple = true,
defaultPreviewOpen = false,
showPreview = true,
showInfo
}) => {
const { uploadFile } = useContext(ApiServerContext)
// Track current files using useState
const [currentFiles, setCurrentFiles] = useState(() => {
if (multiple) {
return Array.isArray(value) ? value : []
} else {
return value || null
}
})
// Update currentFiles when value prop changes
useEffect(() => {
if (multiple) {
setCurrentFiles(Array.isArray(value) ? value : [])
} else {
setCurrentFiles(value || null)
}
}, [value, multiple])
// 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
? !currentFiles ||
!Array.isArray(currentFiles) ||
currentFiles.length === 0
: !currentFiles
setHasNoItems(noItems)
console.log('No items', noItems)
}, [currentFiles, multiple])
const handleFileUpload = async (file) => {
try {
const uploadedFile = await uploadFile(file)
if (uploadedFile) {
if (multiple) {
// For multiple files, add to existing array
const newFiles = [...currentFiles, uploadedFile]
setCurrentFiles(newFiles)
onChange(newFiles)
} else {
// For single file, replace the value
setCurrentFiles(uploadedFile)
onChange(uploadedFile)
}
}
} catch (error) {
console.error('File upload failed:', error)
}
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'}
value={selectedFile}
onChange={setSelectedFile}
/>
<Button
icon={<PlusIcon />}
onClick={handleAddSelectedFile}
disabled={!selectedFile}
/>
</Space.Compact>
<Text style={{ whiteSpace: 'nowrap' }} type='secondary'>
or
</Text>
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
multiple={multiple}
>
<Button style={{ width: '100%' }} icon={<UploadIcon />}>
Upload
</Button>
</Upload>
</Flex>
) : null}
<FileList
files={currentFiles}
multiple={multiple}
editing={true}
showInfo={showInfo || false}
showPreview={showPreview}
defaultPreviewOpen={defaultPreviewOpen}
onChange={(updatedFiles) => {
setCurrentFiles(updatedFiles)
}}
/>
</Flex>
)
}
FileUpload.propTypes = {
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
onChange: PropTypes.func,
multiple: PropTypes.bool,
showPreview: PropTypes.bool,
showInfo: PropTypes.bool,
defaultPreviewOpen: PropTypes.bool
}
export default FileUpload

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,36 +1,26 @@
import * as GCodePreview from 'gcode-preview' import * as GCodePreview from 'gcode-preview'
import { import PropTypes from 'prop-types'
forwardRef, import { useCallback, useEffect, useRef, useState } from 'react'
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react'
import * as THREE from 'three' import * as THREE from 'three'
function GCodePreviewUI(props, ref) { function GCodePreviewUI(props) {
const { const {
src,
topLayerColor = '', topLayerColor = '',
lastSegmentColor = '', lastSegmentColor = '',
startLayer, startLayer,
endLayer, endLayer,
lineWidth lineWidth,
style = {}
} = props } = props
const canvasRef = useRef(null) const canvasRef = useRef(null)
const [preview, setPreview] = useState() const [preview, setPreview] = useState()
const resizePreview = () => { const resizePreview = useCallback(() => {
preview?.resize() preview?.resize()
} }, [preview])
useImperativeHandle(ref, () => ({ // Ex-ref methods removed; this component is now a regular functional component
getLayerCount() {
return preview?.layers.length
},
processGCode(gcode) {
preview?.processGCode(gcode)
}
}))
useEffect(() => { useEffect(() => {
setPreview( setPreview(
@ -52,21 +42,33 @@ function GCodePreviewUI(props, ref) {
return () => { return () => {
window.removeEventListener('resize', resizePreview) window.removeEventListener('resize', resizePreview)
} }
}, []) }, [endLayer, lastSegmentColor, lineWidth, startLayer, topLayerColor])
return ( useEffect(() => {
<div className='gcode-preview'> const loadFromSrc = async () => {
<canvas ref={canvasRef}></canvas> if (!src || !preview) return
try {
const response = await fetch(src)
const text = await response.text()
preview.processGCode(text)
} catch (e) {
console.error('Failed to load G-code from src', e)
}
}
loadFromSrc()
}, [src, preview])
<div> return <canvas ref={canvasRef} style={style}></canvas>
<div>topLayerColor: {topLayerColor}</div>
<div>lastSegmentColor: {lastSegmentColor}</div>
<div>startLayer: {startLayer}</div>
<div>endLayer: {endLayer}</div>
<div>lineWidth: {lineWidth}</div>
</div>
</div>
)
} }
export default forwardRef(GCodePreviewUI) GCodePreviewUI.propTypes = {
src: PropTypes.string,
topLayerColor: PropTypes.string,
lastSegmentColor: PropTypes.string,
startLayer: PropTypes.number,
endLayer: PropTypes.number,
lineWidth: PropTypes.number,
style: PropTypes.object
}
export default GCodePreviewUI

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

@ -0,0 +1,38 @@
import { Card, Flex, Typography } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
const { Text } = Typography
const LoadingPlaceholder = ({ message, hasBackground = true }) => {
return (
<Card
size='small'
style={{
background: hasBackground == false ? 'transparent' : undefined,
border: hasBackground == false ? '1px solid rgb(0 0 0 / 7%)' : undefined
}}
>
<Flex
justify='center'
gap={'small'}
style={{
height: '100%'
}}
align='center'
>
<Text type='secondary'>
<LoadingOutlined />
</Text>
<Text type='secondary'>{message}</Text>
</Flex>
</Card>
)
}
LoadingPlaceholder.propTypes = {
message: PropTypes.string.isRequired,
hasBackground: PropTypes.bool
}
export default LoadingPlaceholder

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

@ -4,13 +4,21 @@ import PropTypes from 'prop-types'
const { Text } = Typography const { Text } = Typography
const MissingPlaceholder = ({ message }) => { const MissingPlaceholder = ({ message, hasBackground = true }) => {
return ( return (
<Card size='small'> <Card
size='small'
style={{
background: hasBackground == false ? 'transparent' : undefined,
border: hasBackground == false ? '1px solid rgb(0 0 0 / 7%)' : undefined
}}
>
<Flex <Flex
justify='center' justify='center'
gap={'small'} gap={'small'}
style={{ height: '100%' }} style={{
height: '100%'
}}
align='center' align='center'
> >
<Text type='secondary'> <Text type='secondary'>
@ -23,7 +31,8 @@ const MissingPlaceholder = ({ message }) => {
} }
MissingPlaceholder.propTypes = { MissingPlaceholder.propTypes = {
message: PropTypes.string.isRequired message: PropTypes.string.isRequired,
hasBackground: PropTypes.bool
} }
export default MissingPlaceholder export default MissingPlaceholder

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 { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import merge from 'lodash/merge' import merge from 'lodash/merge'
import { getModelByName } from '../../../database/ObjectModels'
/** /**
* NewObjectForm is a reusable form component for creating new objects. * 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 [messageApi, contextHolder] = message.useMessage()
const { createObject, showError } = useContext(ApiServerContext) 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 // Set initial form values when defaultValues change
useEffect(() => { useEffect(() => {
if (Object.keys(defaultValues).length > 0) { 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 // Validate form on change
useEffect(() => { useEffect(() => {
@ -67,8 +102,20 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
layout='vertical' layout='vertical'
style={style} style={style}
onValuesChange={(values) => { 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) => { setObjectData((prev) => {
return merge({}, prev, values) return merge({}, prev, allValues)
}) })
}} }}
> >

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Typography, Flex, Badge } from 'antd' import { Typography, Flex, Badge, Tag } from 'antd'
import { useState, useEffect, useContext, useCallback } from 'react' import { useState, useEffect, useContext, useCallback } from 'react'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -53,19 +53,25 @@ const ObjectDisplay = ({ object, objectType }) => {
const model = getModelByName(objectType) const model = getModelByName(objectType)
const Icon = model.icon const Icon = model.icon
return ( return (
<Flex gap={'small'} align='center'> <Tag style={{ margin: 0, border: 'none' }}>
<Icon /> <Flex gap={objectData?.color ? 'small' : '3px'} align='center'>
{objectData?.color ? <Badge color={objectData?.color} /> : null} <Icon />
{objectData?.name ? <Text ellipsis>{objectData.name}</Text> : null} <Flex gap={'small'} align='center'>
{objectData?._id && !objectData?.name ? ( {objectData?.color ? <Badge color={objectData?.color} /> : null}
<IdDisplay <div style={{ paddingTop: '1.5px' }}>
id={objectData?._id} {objectData?.name ? <Text ellipsis>{objectData.name}</Text> : null}
type={objectType} {objectData?._id && !objectData?.name ? (
longId={false} <IdDisplay
showCopy={false} id={objectData?._id}
/> type={objectType}
) : null} longId={false}
</Flex> showCopy={false}
/>
) : null}
</div>
</Flex>
</Flex>
</Tag>
) )
} }

View File

@ -4,7 +4,8 @@ import {
useContext, useContext,
useCallback, useCallback,
forwardRef, forwardRef,
useImperativeHandle useImperativeHandle,
useRef
} from 'react' } from 'react'
import { Form, message } from 'antd' import { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -12,6 +13,7 @@ import { AuthContext } from '../context/AuthContext'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import DeleteObjectModal from './DeleteObjectModal' import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge' import merge from 'lodash/merge'
import { getModelByName } from '../../../database/ObjectModels'
/** /**
* ObjectForm is a reusable form component for editing any object type. * ObjectForm is a reusable form component for editing any object type.
@ -50,30 +52,130 @@ const ObjectForm = forwardRef(
showError, showError,
connected, connected,
subscribeToObjectUpdates, subscribeToObjectUpdates,
subscribeToObjectLock subscribeToObjectLock,
flushFile
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) 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 // Validate form on change
useEffect(() => { useEffect(() => {
form form
.validateFields({ validateOnly: true }) .validateFields({ validateOnly: true })
.then(() => { .then(() => {
setFormValid(true) setFormValid(true)
onStateChange({ formValid: true, objectData: form.getFieldsValue() }) onStateChange({
formValid: true,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
})
}) })
.catch(() => { .catch(() => {
onStateChange({ formValid: true, objectData: form.getFieldsValue() }) onStateChange({
formValid: true,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
})
}) })
}, [form, formUpdateValues]) }, [form, formUpdateValues])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (id) { if (currentIdRef.current) {
unlockObject(id, type) 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 () => { const handleFetchObject = useCallback(async () => {
try { try {
@ -85,7 +187,12 @@ const ObjectForm = forwardRef(
onStateChange({ lock: lockEvent }) onStateChange({ lock: lockEvent })
setObjectData(data) setObjectData(data)
setServerObjectData(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) setFetchLoading(false)
onStateChange({ loading: false }) onStateChange({ loading: false })
} catch (err) { } catch (err) {
@ -157,8 +264,12 @@ const ObjectForm = forwardRef(
const cancelEditing = () => { const cancelEditing = () => {
if (serverObjectData) { if (serverObjectData) {
form.setFieldsValue(serverObjectData) // Recalculate computed values when canceling
setObjectData(serverObjectData) const computedValues = calculateComputedValues(serverObjectData, model)
const resetFormData = { ...serverObjectData, ...computedValues }
form.setFieldsValue(resetFormData)
setObjectData(resetFormData)
} }
setIsEditing(false) setIsEditing(false)
onStateChange({ isEditing: false }) onStateChange({ isEditing: false })
@ -247,8 +358,24 @@ const ObjectForm = forwardRef(
if (onEdit != undefined) { if (onEdit != undefined) {
onEdit(values) 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) => { setObjectData((prev) => {
return { ...prev, ...values } return { ...prev, ...allValues }
}) })
}} }}
> >

View File

@ -46,13 +46,24 @@ const ObjectInfo = ({
objectPropertyProps = { ...objectPropertyProps, showHyperlink } objectPropertyProps = { ...objectPropertyProps, showHyperlink }
} }
// Filter items based on visibleProperties // 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) => { items = items.filter((item) => {
const propertyName = item.name const propertyName = item.name
return !( if (isWhitelistMode) {
propertyName in visibleProperties && // Whitelist mode: only show properties that are explicitly set to true
visibleProperties[propertyName] === false 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 // Map items to Descriptions 'items' prop format

View File

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

View File

@ -27,7 +27,7 @@ const ObjectSelect = ({
disabled = false, disabled = false,
...rest ...rest
}) => { }) => {
const { fetchObjectsByProperty } = useContext(ApiServerContext) const { fetchObjectsByProperty, fetchObject } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
// --- State --- // --- State ---
const [treeData, setTreeData] = useState([]) const [treeData, setTreeData] = useState([])
@ -39,6 +39,36 @@ const ObjectSelect = ({
const [treeSelectValue, setTreeSelectValue] = useState(null) const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true) 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 // Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback( const handleFetchObjectsProperties = useCallback(
async (customFilter = filter) => { async (customFilter = filter) => {
@ -79,7 +109,7 @@ const ObjectSelect = ({
}) })
return { return {
title: ( title: (
<div style={{ paddingTop: '1px' }}> <div style={{ paddingTop: '2px' }}>
<ObjectProperty <ObjectProperty
key={object._id} key={object._id}
type='object' type='object'
@ -178,6 +208,9 @@ const ObjectSelect = ({
} }
const onTreeSelectChange = (value) => { const onTreeSelectChange = (value) => {
// Mark this as an internal change
isInternalChangeRef.current = true
// value can be a string (single) or array (multiple) // value can be a string (single) or array (multiple)
if (multiple) { if (multiple) {
// Multiple selection // Multiple selection
@ -211,38 +244,51 @@ const ObjectSelect = ({
}, [objectPropertiesTree, properties, buildTreeData]) }, [objectPropertiesTree, properties, buildTreeData])
useEffect(() => { useEffect(() => {
if (value && typeof value === 'object' && value !== null && !initialized) { const handleValue = async () => {
// Build a new filter from value's properties that are in the properties list if (
const valueFilter = { ...filter } value &&
properties.forEach((prop) => { typeof value === 'object' &&
if (Object.prototype.hasOwnProperty.call(value, prop)) { value !== null &&
const filterValue = value[prop] !initialized
if (filterValue?.name) { ) {
valueFilter[prop] = filterValue.name // Check if value is a minimal object and fetch full object if needed
} else if (Array.isArray(filterValue)) { const fullValue = await fetchFullObjectIfNeeded(value)
valueFilter[prop] = filterValue.join(',')
} else { // Build a new filter from value's properties that are in the properties list
valueFilter[prop] = filterValue const valueFilter = { ...filter }
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
const filterValue = fullValue[prop]
if (filterValue?.name) {
valueFilter[prop] = filterValue.name
} else if (Array.isArray(filterValue)) {
valueFilter[prop] = filterValue.join(',')
} else {
valueFilter[prop] = filterValue
}
} }
} })
}) // Fetch with the new filter
// Fetch with the new filter handleFetchObjectsProperties(valueFilter)
handleFetchObjectsProperties(valueFilter) setTreeSelectValue(fullValue._id)
setTreeSelectValue(value._id) setInitialized(true)
setInitialized(true) return
return }
} if (!initialized && token != null) {
if (!initialized && token != null) { handleFetchObjectsProperties()
handleFetchObjectsProperties() setInitialized(true)
setInitialized(true) }
} }
handleValue()
}, [ }, [
value, value,
filter, filter,
properties, properties,
handleFetchObjectsProperties, handleFetchObjectsProperties,
initialized, initialized,
token token,
fetchFullObjectIfNeeded
]) ])
const prevValuesRef = useRef({ type, masterFilter }) const prevValuesRef = useRef({ type, masterFilter })
@ -263,6 +309,29 @@ const ObjectSelect = ({
} }
}, [type, masterFilter]) }, [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 --- // --- Error UI ---
if (error) { if (error) {
return ( return (

View File

@ -49,12 +49,13 @@ const ObjectTable = forwardRef(
{ {
type, type,
pageSize = 25, pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 270px)', scrollHeight = 'calc(var(--unit-100vh) - 260px)',
onDataChange, onDataChange,
initialPage = 1, initialPage = 1,
cards = false, cards = false,
visibleColumns = {}, visibleColumns = {},
masterFilter = {} masterFilter = {},
size = 'middle'
}, },
ref ref
) => { ) => {
@ -103,6 +104,7 @@ const ObjectTable = forwardRef(
const unsubscribesRef = useRef([]) const unsubscribesRef = useRef([])
const updateEventHandlerRef = useRef() const updateEventHandlerRef = useRef()
const subscribeToObjectTypeUpdatesRef = useRef(null) const subscribeToObjectTypeUpdatesRef = useRef(null)
const prevValuesRef = useRef({ type, masterFilter })
const rowActions = const rowActions =
model.actions?.filter((action) => action.row == true) || [] model.actions?.filter((action) => action.row == true) || []
@ -462,6 +464,27 @@ const ObjectTable = forwardRef(
} }
}, [token, loadInitialPage, initialPage, pages, initialized]) }, [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 = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -523,7 +546,9 @@ const ObjectTable = forwardRef(
key: 'icon', key: 'icon',
width: 45, width: 45,
fixed: 'left', fixed: 'left',
render: () => createElement(model.icon) render: () => {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
}
} }
] ]
@ -771,7 +796,7 @@ const ObjectTable = forwardRef(
onChange={handleTableChange} onChange={handleTableChange}
showSorterTooltip={false} showSorterTooltip={false}
style={{ height: '100%' }} style={{ height: '100%' }}
size={isElectron ? 'small' : 'middle'} size={size}
/> />
{cards ? ( {cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}> <Spin indicator={<LoadingOutlined />} spinning={loading}>
@ -795,7 +820,8 @@ ObjectTable.propTypes = {
cards: PropTypes.bool, cards: PropTypes.bool,
cardRenderer: PropTypes.func, cardRenderer: PropTypes.func,
visibleColumns: PropTypes.object, visibleColumns: PropTypes.object,
masterFilter: PropTypes.object masterFilter: PropTypes.object,
size: PropTypes.string
} }
export default ObjectTable export default ObjectTable

View File

@ -20,8 +20,6 @@ const ObjectTypeSelect = ({
label: <ObjectTypeDisplay objectType={model.name} /> label: <ObjectTypeDisplay objectType={model.name} />
})) }))
console.log('VALUE', value)
return ( return (
<Select <Select
showSearch={showSearch} 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, InputNumber,
Button Button
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import styled from 'styled-components' import styled from 'styled-components'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -280,7 +280,7 @@ const PrinterTemperaturePanel = ({
size='small' size='small'
items={moreInfoItems} items={moreInfoItems}
expandIcon={({ isActive }) => ( 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 { useState } from 'react'
import PropTypes from 'prop-types' 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 { LoadingOutlined } from '@ant-design/icons'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon.jsx' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon.jsx'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx' import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import ObjectProperty from '../common/ObjectProperty.jsx' import ObjectProperty from '../common/ObjectProperty.jsx'
import TemplatePreview from './TemplatePreview.jsx' import TemplatePreview from './TemplatePreview.jsx'
import DataTree from './DataTree.jsx'
const TemplateEditor = ({ const TemplateEditor = ({
objectData, objectData,
@ -15,6 +25,7 @@ const TemplateEditor = ({
style style
}) => { }) => {
const [testObjectOpen, setTestObjectOpen] = useState(false) const [testObjectOpen, setTestObjectOpen] = useState(false)
const [testObjectViewMode, setTestObjectViewMode] = useState('Tree')
const [previewMessage, setPreviewMessage] = useState('No issues found.') const [previewMessage, setPreviewMessage] = useState('No issues found.')
const [previewError, setPreviewError] = useState(false) const [previewError, setPreviewError] = useState(false)
@ -101,20 +112,40 @@ const TemplateEditor = ({
</Button> </Button>
} }
> >
<div <Flex gap={'small'} vertical>
style={{ <div>
maxHeight: 'calc(var(--unit-100vh) - 280px)', <Segmented
overflowY: 'scroll' options={['Tree', 'Code']}
}} value={testObjectViewMode}
> onChange={(value) => setTestObjectViewMode(value)}
<ObjectProperty size='small'
type={'codeBlock'} />
name='testObject' </div>
language='json' <div
objectData={objectData} style={{
isEditing={true} maxHeight: 'calc(var(--unit-100vh) - 280px)',
/> overflowY: 'scroll'
</div> }}
>
{testObjectViewMode == 'Code' && (
<ObjectProperty
type={'codeBlock'}
name='testObject'
language='json'
objectData={objectData}
isEditing={true}
/>
)}
{testObjectViewMode == 'Tree' && (
<DataTree
data={objectData?.testObject}
defaultExpandAll={true}
showValueCopy={false}
showKeyCopy={true}
/>
)}
</div>
</Flex>
</Modal> </Modal>
</> </>
) )

View File

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

View File

@ -0,0 +1,167 @@
import PropTypes from 'prop-types'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as OV from 'online-3d-viewer'
import LoadingPlaceholder from './LoadingPlaceholder'
function ThreeDPreview(props) {
const {
src,
extension = '.stl',
width = 500,
height = 500,
style = {},
backgroundColor = '#ffffff',
showGrid = true,
showAxes = true,
enableControls = true
} = props
const containerRef = useRef(null)
const viewer = useRef(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const resizeViewer = useCallback(() => {
if (viewer.current && containerRef.current) {
// Resize the viewer container
const container = containerRef.current
if (container.style.width !== width + 'px') {
container.style.width = width + 'px'
}
if (container.style.height !== height + 'px') {
container.style.height = height + 'px'
}
}
}, [viewer, width, height])
useEffect(() => {
const initializeViewer = async () => {
if (!containerRef.current) return
try {
setIsLoading(true)
setError(null)
// Clear any existing viewer
if (viewer) {
containerRef.current.innerHTML = ''
}
// Wait for the OV global to be available
if (typeof OV === 'undefined') {
// If OV is not available, try to load it dynamically
console.warn(
'OV (Online 3D Viewer) is not available. Make sure the library is loaded.'
)
setError('3D Viewer library not loaded')
setIsLoading(false)
return
}
// Initialize the online-3d-viewer using OV.EmbeddedViewer
const newViewer = new OV.EmbeddedViewer(containerRef.current, {
camera: new OV.Camera(
new OV.Coord3D(-1.5, 2.0, 3.0),
new OV.Coord3D(0.0, 0.0, 0.0),
new OV.Coord3D(0.0, 1.0, 0.0),
45.0
),
backgroundColor: new OV.RGBAColor(255, 255, 255, 255),
defaultColor: new OV.RGBColor(200, 200, 200),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
environmentSettings: new OV.EnvironmentSettings([], false)
})
try {
setIsLoading(true)
setError(null)
const response = await fetch(src)
const arrayBuffer = await response.arrayBuffer()
// Create a file-like object from the array buffer
const fileName = `model${extension}`
const file = new File([arrayBuffer], fileName, {
type: 'application/octet-stream'
})
// Load model from file using LoadModelFromFileList
await newViewer.LoadModelFromFileList([file])
setIsLoading(false)
} catch (err) {
console.error('Failed to load 3D model from src', err)
setError('Failed to load 3D model')
setIsLoading(false)
}
} catch (err) {
console.error('Failed to initialize 3D viewer', err)
setError('Failed to initialize 3D viewer')
setIsLoading(false)
}
}
initializeViewer()
window.addEventListener('resize', resizeViewer)
return () => {
window.removeEventListener('resize', resizeViewer)
if (viewer.current && viewer.current.dispose) {
viewer.current.dispose()
}
}
}, [width, height, backgroundColor, showGrid, showAxes, enableControls, src])
const containerStyle = {
width: width + 'px',
height: height + 'px',
backgroundColor,
position: 'relative',
...style
}
return (
<div style={containerStyle}>
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative'
}}
/>
{isLoading && <LoadingPlaceholder message={'Loading 3D preview...'} />}
{error && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(255, 0, 0, 0.1)',
color: '#d32f2f',
padding: '10px',
borderRadius: '4px',
fontSize: '14px',
textAlign: 'center'
}}
>
{error}
</div>
)}
</div>
)
}
ThreeDPreview.propTypes = {
src: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
style: PropTypes.object,
backgroundColor: PropTypes.string,
showGrid: PropTypes.bool,
showAxes: PropTypes.bool,
enableControls: PropTypes.bool,
extension: PropTypes.string.isRequired
}
export default ThreeDPreview

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

@ -1,7 +1,7 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useState } from 'react' import { useState } from 'react'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { Typography, Flex, Steps, Divider } from 'antd' import { Typography, Flex, Steps, Divider, Progress } from 'antd'
import NewObjectButtons from './NewObjectButtons' import NewObjectButtons from './NewObjectButtons'
const { Title } = Typography const { Title } = Typography
@ -14,13 +14,14 @@ const WizardView = ({
formValid, formValid,
loading, loading,
sideBar = null, sideBar = null,
submitText = 'Done' submitText = 'Done',
progress = 0
}) => { }) => {
const [currentStep, setCurrentStep] = useState(0) const [currentStep, setCurrentStep] = useState(0)
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
return ( return (
<Flex gap='middle'> <Flex gap='middle' style={{ width: '100%' }}>
{!isMobile && showSteps == true ? ( {!isMobile && showSteps == true ? (
sideBar != null ? ( sideBar != null ? (
sideBar sideBar
@ -40,25 +41,40 @@ const WizardView = ({
<Divider type='vertical' style={{ height: 'unset' }} /> <Divider type='vertical' style={{ height: 'unset' }} />
) : null} ) : null}
<Flex vertical justify='space-between' gap={'middle'}> <Flex
<Flex vertical gap='middle' style={{ flexGrow: 1 }}> vertical
justify='space-between'
gap={'middle'}
style={{ width: '100%' }}
>
<Flex vertical gap='middle' style={{ flexGrow: 1, width: '100%' }}>
<Title level={2} style={{ margin: 0 }}> <Title level={2} style={{ margin: 0 }}>
{title} {title}
</Title> </Title>
<div style={{ minHeight: '260px', marginBottom: 4 }}> <div style={{ minHeight: '260px', marginBottom: 4, width: '100%' }}>
{steps[currentStep].content} {steps[currentStep].content}
</div> </div>
</Flex> </Flex>
<NewObjectButtons <Flex gap={'middle'} align='center' justify='end'>
currentStep={currentStep} {progress > 0 ? (
totalSteps={steps.length} <Progress
onPrevious={() => setCurrentStep((prev) => prev - 1)} style={{ maxWidth: '160px' }}
onNext={() => setCurrentStep((prev) => prev + 1)} showInfo={false}
onSubmit={onSubmit} percent={progress}
formValid={formValid} />
submitLoading={loading} ) : null}
submitText={submitText}
/> <NewObjectButtons
currentStep={currentStep}
totalSteps={steps.length}
onPrevious={() => setCurrentStep((prev) => prev - 1)}
onNext={() => setCurrentStep((prev) => prev + 1)}
onSubmit={onSubmit}
formValid={formValid}
submitLoading={loading}
submitText={submitText}
/>
</Flex>
</Flex> </Flex>
</Flex> </Flex>
) )
@ -72,7 +88,8 @@ WizardView.propTypes = {
title: PropTypes.string, title: PropTypes.string,
loading: PropTypes.bool, loading: PropTypes.bool,
sideBar: PropTypes.node, sideBar: PropTypes.node,
submitText: PropTypes.string submitText: PropTypes.string,
progress: PropTypes.number
} }
export default WizardView export default WizardView

View File

@ -749,29 +749,36 @@ const ApiServerProvider = ({ children }) => {
} }
// Download GCode file content // Download GCode file content
const fetchObjectContent = async (id, type, fileName) => { const fetchFileContent = async (file, download = false) => {
try { try {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/${type.toLowerCase()}s/${id}/content`, `${config.backendUrl}/files/${file._id}/content`,
{ {
headers: { headers: {
Accept: 'application/json', Accept: '*/*',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} },
responseType: 'blob'
} }
) )
const blob = new Blob([response.data], {
const fileURL = window.URL.createObjectURL(new Blob([response.data])) type: response.headers['content-type']
const fileLink = document.createElement('a') })
fileLink.href = fileURL const fileURL = window.URL.createObjectURL(blob)
fileLink.setAttribute('download', fileName) if (download == true) {
document.body.appendChild(fileLink) const fileLink = document.createElement('a')
fileLink.click() fileLink.href = fileURL
fileLink.parentNode.removeChild(fileLink) fileLink.setAttribute('download', `${file.name}${file.extension}`)
document.body.appendChild(fileLink)
fileLink.click()
fileLink.parentNode.removeChild(fileLink)
return
}
return fileURL
} catch (err) { } catch (err) {
console.error(err) console.error(err)
showError(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 ( return (
<ApiServerContext.Provider <ApiServerContext.Provider
value={{ value={{
@ -897,11 +965,13 @@ const ApiServerProvider = ({ children }) => {
fetchSpotlightData, fetchSpotlightData,
fetchLoading, fetchLoading,
showError, showError,
fetchObjectContent, fetchFileContent,
fetchTemplatePreview, fetchTemplatePreview,
fetchNotes, fetchNotes,
fetchHostOTP, fetchHostOTP,
sendObjectAction sendObjectAction,
uploadFile,
flushFile
}} }}
> >
{contextHolder} {contextHolder}

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/jsonarrayicon.svg?react'
const JsonArrayIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default JsonArrayIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/jsonboolicon.svg?react'
const JsonBoolIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default JsonBoolIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/jsonnumbericon.svg?react'
const JsonNumberIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default JsonNumberIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/jsonobjecticon.svg?react'
const JsonObjectIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default JsonObjectIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/jsonstringicon.svg?react'
const JsonStringIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default JsonStringIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/uploadicon.svg?react'
const UploadIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default UploadIcon

View File

@ -1,3 +1,4 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import FileIcon from '../../components/Icons/FileIcon' import FileIcon from '../../components/Icons/FileIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon' import EditIcon from '../../components/Icons/EditIcon'
@ -7,7 +8,7 @@ import BinIcon from '../../components/Icons/BinIcon'
export const File = { export const File = {
name: 'file', name: 'file',
label: 'File', label: 'File',
prefix: 'VEN', prefix: 'FLE',
icon: FileIcon, icon: FileIcon,
actions: [ actions: [
{ {
@ -32,6 +33,14 @@ export const File = {
icon: EditIcon, icon: EditIcon,
url: (_id) => `/dashboard/management/files/info?fileId=${_id}&action=edit` 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' }, { type: 'divider' },
{ {
name: 'delete', name: 'delete',
@ -88,31 +97,31 @@ export const File = {
type: 'number', type: 'number',
readOnly: true, readOnly: true,
required: true, required: true,
suffix: () => { suffix: (objectData) => {
return 'gb' const size = objectData?.size || 0
if (size === 0) return ' B'
if (size < 1024) return ' B'
if (size < 1024 * 1024) return ' KB'
if (size < 1024 * 1024 * 1024) return ' MB'
if (size < 1024 * 1024 * 1024 * 1024) return ' GB'
return ' TB'
},
value: (objectData) => {
const size = objectData?.size || 0
if (size === 0) return 0
if (size < 1024) return size
if (size < 1024 * 1024) return size / 1024
if (size < 1024 * 1024 * 1024) return size / (1024 * 1024)
if (size < 1024 * 1024 * 1024 * 1024) return size / (1024 * 1024 * 1024)
return size / (1024 * 1024 * 1024 * 1024)
} }
}, },
{ {
name: 'email', name: 'metaData',
label: 'Email', label: 'Meta Data',
columnWidth: 300, columnWidth: 300,
type: 'email', type: 'data',
readOnly: false, readOnly: true,
required: false
},
{
name: 'phone',
label: 'Phone',
type: 'phone',
readOnly: false,
required: false
},
{
name: 'website',
label: 'Website',
columnWidth: 300,
type: 'url',
readOnly: false,
required: false required: false
} }
] ]

View File

@ -89,6 +89,24 @@ export const GCodeFile = {
value: null, value: null,
readOnly: true 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', name: 'filament',
label: 'Filament', label: 'Filament',
@ -97,50 +115,62 @@ export const GCodeFile = {
objectType: 'filament', objectType: 'filament',
required: true required: true
}, },
{
name: 'filament._id',
label: 'Filament ID',
type: 'id',
value: null,
objectType: 'filament',
showHyperlink: true
},
{ {
name: 'cost', name: 'cost',
label: 'Cost', label: 'Cost',
type: 'number', type: 'number',
value: null, value: (objectData) => {
return (
objectData?.file?.metaData?.filamentUsedG * objectData?.filament?.cost
)
},
readOnly: true, readOnly: true,
prefix: '£' prefix: '£'
}, },
{ {
name: 'gcodeFileInfo.estimatedPrintingTimeNormalMode', name: 'file.metaData.filamentUsedG',
label: 'Est Print Time', label: 'Est Print Time',
value: null, value: null,
type: 'text', type: 'text',
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.sparseInfillDensity', name: 'file.metaData.sparseInfillDensity',
label: 'Infill Density', label: 'Infill Density',
columnWidth: 150, columnWidth: 150,
type: 'text', type: 'text',
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.sparseInfillPattern', name: 'file.metaData.sparseInfillPattern',
label: 'Infill Pattern', label: 'Infill Pattern',
type: 'text', type: 'text',
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.filamentUsedMm', name: 'file.metaData.filamentUsedMm',
label: 'Filament Used (mm)', label: 'Filament Used (mm)',
type: 'number', type: 'number',
readOnly: true, readOnly: true,
suffix: 'mm' suffix: 'mm'
}, },
{ {
name: 'gcodeFileInfo.filamentUsedG', name: 'file.metaData.filamentUsedG',
label: 'Filament Used (g)', label: 'Filament Used (g)',
type: 'number', type: 'number',
suffix: 'g', suffix: 'g',
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.nozzleTemperature', name: 'file.metaData.nozzleTemperature',
label: 'Hotend Temp', label: 'Hotend Temp',
columnWidth: 150, columnWidth: 150,
type: 'number', type: 'number',
@ -148,7 +178,7 @@ export const GCodeFile = {
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.hotPlateTemp', name: 'file.metaData.hotPlateTemp',
label: 'Bed Temp', label: 'Bed Temp',
columnWidth: 150, columnWidth: 150,
type: 'number', type: 'number',
@ -156,13 +186,13 @@ export const GCodeFile = {
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.filamentSettingsId', name: 'file.metaData.filamentSettingsId',
label: 'Filament Profile', label: 'Filament Profile',
type: 'text', type: 'text',
readOnly: true readOnly: true
}, },
{ {
name: 'gcodeFileInfo.printSettingsId', name: 'file.metaData.printSettingsId',
label: 'Print Profile', label: 'Print Profile',
type: 'text', type: 'text',
readOnly: true readOnly: true

View File

@ -24,7 +24,7 @@ export const Job = {
row: true, row: true,
icon: CheckIcon, icon: CheckIcon,
url: (_id) => url: (_id) =>
`/dashboard/production/jobs/info?jobId=${_id}?action=deploy`, `/dashboard/production/jobs/info?jobId=${_id}&action=deploy`,
disabled: (objectData) => { disabled: (objectData) => {
console.log('Should be disabled', objectData?.state?.type != 'draft') console.log('Should be disabled', objectData?.state?.type != 'draft')
return objectData?.state?.type != 'draft' return objectData?.state?.type != 'draft'
@ -107,7 +107,8 @@ export const Job = {
label: 'Printers', label: 'Printers',
type: 'objectList', type: 'objectList',
objectType: 'printer', objectType: 'printer',
required: true required: true,
span: 2
} }
] ]
} }

View File

@ -1,4 +1,3 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import EditIcon from '../../components/Icons/EditIcon' import EditIcon from '../../components/Icons/EditIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import PartIcon from '../../components/Icons/PartIcon' import PartIcon from '../../components/Icons/PartIcon'
@ -25,14 +24,6 @@ export const Part = {
url: (_id) => url: (_id) =>
`/dashboard/management/parts/info?partId=${_id}&action=reload` `/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', name: 'edit',
label: 'Edit', label: 'Edit',
@ -96,6 +87,29 @@ export const Part = {
showHyperlink: true, showHyperlink: true,
objectType: 'product' 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', name: 'globalPricing',
label: 'Global Price', label: 'Global Price',
@ -106,6 +120,7 @@ export const Part = {
{ {
name: 'priceMode', name: 'priceMode',
label: 'Price Mode', label: 'Price Mode',
required: true,
type: 'priceMode', type: 'priceMode',
disabled: (objectData) => { disabled: (objectData) => {
return objectData.globalPricing == true return objectData.globalPricing == true
@ -114,6 +129,7 @@ export const Part = {
{ {
name: 'margin', name: 'margin',
label: 'Margin', label: 'Margin',
required: true,
type: 'number', type: 'number',
disabled: (objectData) => { disabled: (objectData) => {
return ( return (
@ -128,6 +144,7 @@ export const Part = {
{ {
name: 'amount', name: 'amount',
label: 'Amount', label: 'Amount',
required: true,
disabled: (objectData) => { disabled: (objectData) => {
return ( return (
objectData.globalPricing == true || objectData.priceMode == 'margin' objectData.globalPricing == true || objectData.priceMode == 'margin'
@ -137,6 +154,21 @@ export const Part = {
prefix: '£', prefix: '£',
min: 0, min: 0,
step: 0.1 step: 0.1
},
{
name: 'file',
label: 'File',
type: 'file',
value: null,
required: false
},
{
name: 'file._id',
label: 'File ID',
type: 'id',
value: null,
objectType: 'file',
showHyperlink: true
} }
] ]
} }

View File

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

View File

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

View File

@ -77,6 +77,14 @@ export const User = {
label: 'Email', label: 'Email',
columnWidth: 300, columnWidth: 300,
type: 'email' type: 'email'
},
{
name: 'profileImage',
label: 'Profile Image',
type: 'file',
fileType: 'image',
previewOpen: true,
showPreview: false
} }
] ]
} }

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')
}

3913
yarn.lock

File diff suppressed because it is too large Load Diff