create.tsx
1 import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; 2 import { Create, useForm } from "@refinedev/antd"; 3 import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core"; 4 import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; 5 import TextArea from "antd/es/input/TextArea"; 6 import dayjs from "dayjs"; 7 import utc from "dayjs/plugin/utc"; 8 import { useEffect, useMemo, useState } from "react"; 9 import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; 10 import { useSpoolmanLocations } from "../../components/otherModels"; 11 import { searchMatches } from "../../utils/filtering"; 12 import { useLocations } from "../locations/functions"; 13 import "../../utils/overrides.css"; 14 import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; 15 import { EntityType, useGetFields } from "../../utils/queryFields"; 16 import { getCurrencySymbol, useCurrency } from "../../utils/settings"; 17 import { createFilamentFromExternal } from "../filaments/functions"; 18 import { useGetFilamentSelectOptions } from "./functions"; 19 import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model"; 20 21 dayjs.extend(utc); 22 23 interface CreateOrCloneProps { 24 mode: "create" | "clone"; 25 } 26 27 type ISpoolRequest = Omit<ISpoolParsedExtras, "id" | "registered"> & { 28 filament_id: number | string; 29 }; 30 31 export const SpoolCreate = (props: IResourceComponentsProps & CreateOrCloneProps) => { 32 const t = useTranslate(); 33 const extraFields = useGetFields(EntityType.spool); 34 const currency = useCurrency(); 35 36 const { form, formProps, formLoading, onFinish, redirect } = useForm< 37 ISpool, 38 HttpError, 39 ISpoolRequest, 40 ISpoolParsedExtras 41 >({ 42 redirect: false, 43 warnWhenUnsavedChanges: false, 44 }); 45 if (!formProps.initialValues) { 46 formProps.initialValues = {}; 47 } 48 49 const initialWeightValue = Form.useWatch("initial_weight", form); 50 const spoolWeightValue = Form.useWatch("spool_weight", form); 51 52 if (props.mode === "clone") { 53 // Clear out the values that we don't want to clone 54 formProps.initialValues.first_used = null; 55 formProps.initialValues.last_used = null; 56 formProps.initialValues.used_weight = 0; 57 58 // Fix the filament_id 59 if (formProps.initialValues.filament) { 60 formProps.initialValues.filament_id = formProps.initialValues.filament.id; 61 } 62 63 // Parse the extra fields from string values into real types 64 formProps.initialValues = ParsedExtras(formProps.initialValues); 65 } 66 67 // If the query variable filament_id is set, set the filament_id field to that value 68 const query = new URLSearchParams(window.location.search); 69 const filament_id = query.get("filament_id"); 70 if (filament_id) { 71 formProps.initialValues.filament_id = parseInt(filament_id); 72 } 73 74 // 75 // Set up the filament selection options 76 // 77 const { 78 options: filamentOptions, 79 internalSelectOptions, 80 externalSelectOptions, 81 allExternalFilaments, 82 } = useGetFilamentSelectOptions(); 83 84 const selectedFilamentID = Form.useWatch("filament_id", form); 85 const selectedFilament = useMemo(() => { 86 // id is a number of it's an internal filament, and a string of it's an external filament. 87 if (typeof selectedFilamentID === "number") { 88 return ( 89 internalSelectOptions?.find((obj) => { 90 return obj.value === selectedFilamentID; 91 }) ?? null 92 ); 93 } else if (typeof selectedFilamentID === "string") { 94 return ( 95 externalSelectOptions?.find((obj) => { 96 return obj.value === selectedFilamentID; 97 }) ?? null 98 ); 99 } else { 100 return null; 101 } 102 }, [selectedFilamentID, internalSelectOptions, externalSelectOptions]); 103 104 // 105 // Submit handler 106 // 107 108 const handleSubmit = async (redirectTo: "list" | "edit" | "create") => { 109 const values = StringifiedExtras(await form.validateFields()); 110 if (selectedFilament?.is_internal === false) { 111 // Filament ID being a string indicates its an external filament. 112 // If so, we should first create the internal filament version, then create the spool(s) 113 const externalFilament = allExternalFilaments?.find((f) => f.id === values.filament_id); 114 if (!externalFilament) { 115 throw new Error("Unknown external filament"); 116 } 117 const internalFilament = await createFilamentFromExternal(externalFilament); 118 values.filament_id = internalFilament.id; 119 } 120 121 if (quantity > 1) { 122 const submit = Array(quantity).fill(values); 123 // queue multiple creates this way for now Refine doesn't seem to map Arrays to createMany or multiple creates like it says it does 124 submit.forEach(async (r) => await onFinish(r)); 125 } else { 126 await onFinish(values); 127 } 128 129 redirect(redirectTo); 130 }; 131 132 // Use useEffect to update the form's initialValues when the extra fields are loaded 133 // This is necessary because the form is rendered before the extra fields are loaded 134 useEffect(() => { 135 extraFields.data?.forEach((field) => { 136 if (formProps.initialValues && field.default_value) { 137 const parsedValue = JSON.parse(field.default_value as string); 138 form.setFieldsValue({ extra: { [field.key]: parsedValue } }); 139 } 140 }); 141 }, [form, extraFields.data, formProps.initialValues]); 142 143 // 144 // Weight calculations 145 // 146 147 const [weightToEnter, setWeightToEnter] = useState(1); 148 const [usedWeight, setUsedWeight] = useState(0); 149 150 useEffect(() => { 151 const newFilamentWeight = selectedFilament?.weight || 0; 152 const newSpoolWeight = selectedFilament?.spool_weight || 0; 153 if (newFilamentWeight > 0) { 154 form.setFieldValue("initial_weight", newFilamentWeight); 155 } 156 if (newSpoolWeight > 0) { 157 form.setFieldValue("spool_weight", newSpoolWeight); 158 } 159 }, [selectedFilament]); 160 161 const weightChange = (weight: number) => { 162 setUsedWeight(weight); 163 form.setFieldsValue({ 164 used_weight: weight, 165 }); 166 }; 167 168 const locations = useSpoolmanLocations(true); 169 const settingsLocation = useLocations(); 170 const [newLocation, setNewLocation] = useState(""); 171 172 const allLocations = [...(settingsLocation || [])]; 173 locations?.data?.forEach((loc) => { 174 if (!allLocations.includes(loc)) { 175 allLocations.push(loc); 176 } 177 }); 178 if (newLocation.trim() && !allLocations.includes(newLocation)) { 179 allLocations.push(newLocation.trim()); 180 } 181 182 const [quantity, setQuantity] = useState(1); 183 const incrementQty = () => { 184 setQuantity(quantity + 1); 185 }; 186 187 const decrementQty = () => { 188 setQuantity(quantity - 1); 189 }; 190 191 const getSpoolWeight = (): number => { 192 return spoolWeightValue ?? selectedFilament?.spool_weight ?? 0; 193 }; 194 195 const getFilamentWeight = (): number => { 196 return initialWeightValue ?? selectedFilament?.weight ?? 0; 197 }; 198 199 const getGrossWeight = (): number => { 200 const net_weight = getFilamentWeight(); 201 const spool_weight = getSpoolWeight(); 202 return net_weight + spool_weight; 203 }; 204 205 const getMeasuredWeight = (): number => { 206 const grossWeight = getGrossWeight(); 207 208 return grossWeight - usedWeight; 209 }; 210 211 const getRemainingWeight = (): number => { 212 const initial_weight = getFilamentWeight(); 213 214 return initial_weight - usedWeight; 215 }; 216 217 const isMeasuredWeightEnabled = (): boolean => { 218 if (!isRemainingWeightEnabled()) { 219 return false; 220 } 221 222 const spool_weight = spoolWeightValue; 223 224 return spool_weight || selectedFilament?.spool_weight ? true : false; 225 }; 226 227 const isRemainingWeightEnabled = (): boolean => { 228 const initial_weight = initialWeightValue; 229 230 if (initial_weight) { 231 return true; 232 } 233 234 return selectedFilament?.weight ? true : false; 235 }; 236 237 useEffect(() => { 238 if (weightToEnter >= WeightToEnter.measured_weight) { 239 if (!isMeasuredWeightEnabled()) { 240 setWeightToEnter(WeightToEnter.remaining_weight); 241 return; 242 } 243 } 244 if (weightToEnter >= WeightToEnter.remaining_weight) { 245 if (!isRemainingWeightEnabled()) { 246 setWeightToEnter(WeightToEnter.used_weight); 247 return; 248 } 249 } 250 }, [selectedFilament]); 251 252 return ( 253 <Create 254 title={props.mode === "create" ? t("spool.titles.create") : t("spool.titles.clone")} 255 isLoading={formLoading} 256 footerButtons={() => ( 257 <> 258 <div 259 style={{ display: "flex", backgroundColor: "#141414", border: "1px solid #424242", borderRadius: "6px" }} 260 > 261 <Button type="text" style={{ padding: 0, width: 32, height: 32 }} onClick={decrementQty}> 262 <MinusOutlined /> 263 </Button> 264 <InputNumber name="Quantity" min={1} id="qty-input" controls={false} value={quantity}></InputNumber> 265 <Button type="text" style={{ padding: 0, width: 32, height: 32 }} onClick={incrementQty}> 266 <PlusOutlined /> 267 </Button> 268 </div> 269 <Button type="primary" onClick={() => handleSubmit("list")}> 270 {t("buttons.save")} 271 </Button> 272 <Button type="primary" onClick={() => handleSubmit("create")}> 273 {t("buttons.saveAndAdd")} 274 </Button> 275 </> 276 )} 277 > 278 <Form {...formProps} layout="vertical"> 279 <Form.Item 280 label={t("spool.fields.first_used")} 281 name={["first_used"]} 282 rules={[ 283 { 284 required: false, 285 }, 286 ]} 287 getValueProps={(value) => ({ 288 value: value ? dayjs(value) : undefined, 289 })} 290 > 291 <DatePicker showTime format="YYYY-MM-DD HH:mm:ss" /> 292 </Form.Item> 293 <Form.Item 294 label={t("spool.fields.last_used")} 295 name={["last_used"]} 296 rules={[ 297 { 298 required: false, 299 }, 300 ]} 301 getValueProps={(value) => ({ 302 value: value ? dayjs(value) : undefined, 303 })} 304 > 305 <DatePicker showTime format="YYYY-MM-DD HH:mm:ss" /> 306 </Form.Item> 307 <Form.Item 308 label={t("spool.fields.filament")} 309 name={["filament_id"]} 310 rules={[ 311 { 312 required: true, 313 }, 314 ]} 315 > 316 <Select 317 options={filamentOptions} 318 showSearch 319 filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)} 320 /> 321 </Form.Item> 322 {selectedFilament?.is_internal === false && ( 323 <Alert message={t("spool.fields_help.external_filament")} type="info" /> 324 )} 325 <Form.Item 326 label={t("spool.fields.price")} 327 help={t("spool.fields_help.price")} 328 name={["price"]} 329 rules={[ 330 { 331 required: false, 332 type: "number", 333 min: 0, 334 }, 335 ]} 336 > 337 <InputNumber 338 addonAfter={getCurrencySymbol(undefined, currency)} 339 precision={2} 340 formatter={formatNumberOnUserInput} 341 parser={numberParserAllowEmpty} 342 /> 343 </Form.Item> 344 <Form.Item 345 label={t("spool.fields.initial_weight")} 346 help={t("spool.fields_help.initial_weight")} 347 name={["initial_weight"]} 348 rules={[ 349 { 350 required: false, 351 type: "number", 352 min: 0, 353 }, 354 ]} 355 > 356 <InputNumber addonAfter="g" precision={1} /> 357 </Form.Item> 358 359 <Form.Item 360 label={t("spool.fields.spool_weight")} 361 help={t("spool.fields_help.spool_weight")} 362 name={["spool_weight"]} 363 rules={[ 364 { 365 required: false, 366 type: "number", 367 min: 0, 368 }, 369 ]} 370 > 371 <InputNumber addonAfter="g" precision={1} /> 372 </Form.Item> 373 374 <Form.Item hidden={true} name={["used_weight"]} initialValue={0}> 375 <InputNumber value={usedWeight} /> 376 </Form.Item> 377 378 <Form.Item label={t("spool.fields.weight_to_use")} help={t("spool.fields_help.weight_to_use")}> 379 <Radio.Group 380 onChange={(value) => { 381 setWeightToEnter(value.target.value); 382 }} 383 defaultValue={WeightToEnter.used_weight} 384 value={weightToEnter} 385 > 386 <Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button> 387 <Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}> 388 {t("spool.fields.remaining_weight")} 389 </Radio.Button> 390 <Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}> 391 {t("spool.fields.measured_weight")} 392 </Radio.Button> 393 </Radio.Group> 394 </Form.Item> 395 396 <Form.Item label={t("spool.fields.used_weight")} help={t("spool.fields_help.used_weight")} initialValue={0}> 397 <InputNumber 398 min={0} 399 addonAfter="g" 400 precision={1} 401 formatter={formatNumberOnUserInput} 402 parser={numberParser} 403 disabled={weightToEnter != WeightToEnter.used_weight} 404 value={usedWeight} 405 onChange={(value) => { 406 weightChange(value ?? 0); 407 }} 408 /> 409 </Form.Item> 410 <Form.Item 411 label={t("spool.fields.remaining_weight")} 412 help={t("spool.fields_help.remaining_weight")} 413 initialValue={0} 414 > 415 <InputNumber 416 min={0} 417 addonAfter="g" 418 precision={1} 419 formatter={formatNumberOnUserInput} 420 parser={numberParser} 421 disabled={weightToEnter != WeightToEnter.remaining_weight} 422 value={getRemainingWeight()} 423 onChange={(value) => { 424 weightChange(getFilamentWeight() - (value ?? 0)); 425 }} 426 /> 427 </Form.Item> 428 <Form.Item 429 label={t("spool.fields.measured_weight")} 430 help={t("spool.fields_help.measured_weight")} 431 initialValue={0} 432 > 433 <InputNumber 434 min={0} 435 addonAfter="g" 436 precision={1} 437 formatter={formatNumberOnUserInput} 438 parser={numberParser} 439 disabled={weightToEnter != WeightToEnter.measured_weight} 440 value={getMeasuredWeight()} 441 onChange={(value) => { 442 const totalWeight = getGrossWeight(); 443 weightChange(totalWeight - (value ?? 0)); 444 }} 445 /> 446 </Form.Item> 447 <Form.Item 448 label={t("spool.fields.location")} 449 help={t("spool.fields_help.location")} 450 name={["location"]} 451 rules={[ 452 { 453 required: false, 454 }, 455 ]} 456 > 457 <Select 458 dropdownRender={(menu) => ( 459 <> 460 {menu} 461 <Divider style={{ margin: "8px 0" }} /> 462 <Input 463 placeholder={t("spool.form.new_location_prompt")} 464 value={newLocation} 465 onChange={(event) => setNewLocation(event.target.value)} 466 /> 467 </> 468 )} 469 loading={locations.isLoading} 470 options={allLocations.map((item) => ({ label: item, value: item }))} 471 /> 472 </Form.Item> 473 <Form.Item 474 label={t("spool.fields.lot_nr")} 475 help={t("spool.fields_help.lot_nr")} 476 name={["lot_nr"]} 477 rules={[ 478 { 479 required: false, 480 }, 481 ]} 482 > 483 <Input maxLength={64} /> 484 </Form.Item> 485 <Form.Item 486 label={t("spool.fields.comment")} 487 name={["comment"]} 488 rules={[ 489 { 490 required: false, 491 }, 492 ]} 493 > 494 <TextArea maxLength={1024} /> 495 </Form.Item> 496 <Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title> 497 {extraFields.data?.map((field, index) => ( 498 <ExtraFieldFormItem key={index} field={field} /> 499 ))} 500 </Form> 501 </Create> 502 ); 503 }; 504 505 export default SpoolCreate;