/ client / src / pages / spools / create.tsx
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;