Merge remote-tracking branch 'upstream/master'

This commit is contained in:
samturner3
2024-08-24 16:18:17 +10:00
159 changed files with 9157 additions and 6235 deletions

View File

@@ -1,16 +1,20 @@
import React from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, Select, InputNumber, ColorPicker, Button, Typography } from "antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentImportModal } from "../../components/filamentImportModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { ExternalFilament } from "../../utils/queryExternalDB";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
dayjs.extend(utc);
@@ -18,15 +22,22 @@ interface CreateOrCloneProps {
mode: "create" | "clone";
}
type IFilamentRequest = Omit<IFilamentParsedExtras, "id" | "registered"> & {
vendor_id: number;
};
export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [isImportExtOpen, setIsImportExtOpen] = useState(false);
const invalidate = useInvalidate();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const { form, formProps, formLoading, onFinish, redirect } = useForm<
IFilament,
HttpError,
IFilamentParsedExtras,
IFilamentRequest,
IFilamentParsedExtras
>();
@@ -39,22 +50,50 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
if (formProps.initialValues.vendor) {
formProps.initialValues.vendor_id = formProps.initialValues.vendor.id;
}
// Parse the extra fields from string values into real types
formProps.initialValues = ParsedExtras(formProps.initialValues);
}
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const handleSubmit = async (redirectTo: "list" | "create") => {
const values = StringifiedExtras(await form.validateFields());
await onFinish(values);
redirect(redirectTo, (values as IFilament).id);
redirect(redirectTo);
};
const { selectProps } = useSelect<IVendor>({
const { selectProps: vendorSelect } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",
});
const importFilament = async (filament: ExternalFilament) => {
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
await invalidate({
resource: "vendor",
invalidates: ["list", "detail"],
});
setColorType(filament.color_hexes ? "multi" : "single")
form.setFieldsValue({
name: filament.name,
vendor_id: vendor.id,
material: filament.material,
density: filament.density,
diameter: filament.diameter,
weight: filament.weight,
spool_weight: filament.spool_weight || undefined,
color_hex: filament.color_hex,
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
multi_color_direction: filament.multi_color_direction,
settings_extruder_temp: filament.extruder_temp || undefined,
settings_bed_temp: filament.bed_temp || undefined,
});
};
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
@@ -67,6 +106,13 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
<Create
title={props.mode === "create" ? t("filament.titles.create") : t("filament.titles.clone")}
isLoading={formLoading}
headerButtons={() => (
<>
<Button type="primary" onClick={() => setIsImportExtOpen(true)}>
{t("filament.form.import_external")}
</Button>
</>
)}
footerButtons={() => (
<>
<Button type="primary" onClick={() => handleSubmit("list")}>
@@ -78,6 +124,14 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</>
)}
>
<FilamentImportModal
isOpen={isImportExtOpen}
onImport={(value) => {
setIsImportExtOpen(false);
importFilament(value);
}}
onClose={() => setIsImportExtOpen(false)}
/>
<Form {...formProps} layout="vertical">
<Form.Item
label={t("filament.fields.name")}
@@ -101,7 +155,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
]}
>
<Select
{...selectProps}
{...vendorSelect}
allowClear
filterSort={(a, b) => {
return a?.label && b?.label
@@ -113,20 +167,62 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.color_hex")}
name={["color_hex"]}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
<Form.Item label={t("filament.fields.color_hex")}>
<Radio.Group
onChange={(value) => {
setColorType(value.target.value);
}}
defaultValue={colorType}
value={colorType}
>
<Radio.Button value={"single"}>{t("filament.fields.single_color")}</Radio.Button>
<Radio.Button value={"multi"}>{t("filament.fields.multi_color")}</Radio.Button>
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_direction"}
help={t("filament.fields_help.multi_color_direction")}
rules={[
{
required: true,
},
]}
initialValue={"coaxial"}
>
<Radio.Group>
<Radio.Button value={"coaxial"}>{t("filament.fields.coaxial")}</Radio.Button>
<Radio.Button value={"longitudinal"}>{t("filament.fields.longitudinal")}</Radio.Button>
</Radio.Group>
</Form.Item>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_hexes"}
rules={[
{
required: false,
},
]}
>
<MultiColorPicker min={2} max={14} />
</Form.Item>
)}
<Form.Item
label={t("filament.fields.material")}
help={t("filament.fields_help.material")}

View File

@@ -1,16 +1,16 @@
import React, { useState } from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
@@ -25,6 +25,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual",
@@ -49,6 +50,16 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
formProps.initialValues = ParsedExtras(formProps.initialValues);
}
// Update colorType state
useEffect(() => {
console.log(formProps.initialValues?.multi_color_hexes);
if (formProps.initialValues?.multi_color_hexes) {
setColorType("multi");
} else {
setColorType("single");
}
}, [formProps.initialValues?.multi_color_hexes]);
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: IFilamentParsedExtras) => {
@@ -132,20 +143,62 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.color_hex")}
name={["color_hex"]}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
<Form.Item label={t("filament.fields.color_hex")}>
<Radio.Group
onChange={(value) => {
setColorType(value.target.value);
}}
defaultValue={colorType}
value={colorType}
>
<Radio.Button value={"single"}>{t("filament.fields.single_color")}</Radio.Button>
<Radio.Button value={"multi"}>{t("filament.fields.multi_color")}</Radio.Button>
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_direction"}
help={t("filament.fields_help.multi_color_direction")}
rules={[
{
required: true,
},
]}
initialValue={"coaxial"}
>
<Radio.Group>
<Radio.Button value={"coaxial"}>{t("filament.fields.coaxial")}</Radio.Button>
<Radio.Button value={"longitudinal"}>{t("filament.fields.longitudinal")}</Radio.Button>
</Radio.Group>
</Form.Item>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_hexes"}
rules={[
{
required: false,
},
]}
>
<MultiColorPicker min={2} max={14} />
</Form.Item>
)}
<Form.Item
label={t("filament.fields.material")}
help={t("filament.fields_help.material")}

View File

@@ -0,0 +1,50 @@
import { ExternalFilament } from "../../utils/queryExternalDB";
import { getAPIURL } from "../../utils/url";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IFilament } from "./model";
/**
* Create a new internal filament given an external filament object.
* Returns the created internal filament.
*/
export async function createFilamentFromExternal(externalFilament: ExternalFilament): Promise<IFilament> {
const vendor = await getOrCreateVendorFromExternal(externalFilament.manufacturer);
let color_hex: string | undefined = undefined;
let multi_color_hexes: string | undefined = undefined;
let multi_color_direction: string | undefined = undefined;
if (externalFilament.color_hex) {
color_hex = externalFilament.color_hex;
} else if (externalFilament.color_hexes && externalFilament.color_hexes.length > 0) {
multi_color_hexes = externalFilament.color_hexes.join(",")
multi_color_direction = externalFilament.multi_color_direction
}
const body: Omit<IFilament, "id" | "registered" | "extra"> & { vendor_id: number } = {
name: externalFilament.name,
material: externalFilament.material,
vendor_id: vendor.id,
density: externalFilament.density,
diameter: externalFilament.diameter,
weight: externalFilament.weight,
spool_weight: externalFilament.spool_weight || undefined,
color_hex: color_hex,
multi_color_hexes: multi_color_hexes,
multi_color_direction: multi_color_direction,
settings_extruder_temp: externalFilament.extruder_temp || undefined,
settings_bed_temp: externalFilament.bed_temp || undefined,
external_id: externalFilament.id,
};
const response = await fetch(getAPIURL() + "/filament", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
}

View File

@@ -1,33 +1,33 @@
import React from "react";
import { IResourceComponentsProps, useTranslate, useInvalidate, useNavigation } from "@refinedev/core";
import { useTable, List } from "@refinedev/antd";
import { Table, Button, Dropdown } from "antd";
import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IFilament } from "./model";
import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionsColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
ActionsColumn,
CustomFieldColumn,
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanArticleNumbers,
useSpoolmanFilamentNames,
useSpoolmanMaterials,
useSpoolmanVendors,
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { useCurrency } from "../../utils/settings";
import { IFilament } from "./model";
dayjs.extend(utc);
@@ -125,7 +125,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
});
// Create state for the columns to show
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns);
const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? defaultColumns);
// Store state in local storage
const tableState: TableState = {
@@ -137,7 +137,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list
const queryDataSource: IFilamentCollapsed[] = React.useMemo(
const queryDataSource: IFilamentCollapsed[] = useMemo(
() => (tableProps.dataSource || []).map((record) => ({ ...record })),
[tableProps.dataSource]
);
@@ -240,7 +240,13 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "name",
i18ncat: "filament",
color: (record: IFilamentCollapsed) => record.color_hex,
color: (record: IFilamentCollapsed) =>
record.multi_color_hexes
? {
colors: record.multi_color_hexes.split(","),
vertical: record.multi_color_direction === "longitudinal",
}
: record.color_hex,
filterValueQuery: useSpoolmanFilamentNames(),
}),
FilteredQueryColumn({
@@ -254,6 +260,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "price",
i18ncat: "filament",
align: "right",
width: 80,
render: (_, obj: IFilamentCollapsed) => {
return obj.price?.toLocaleString(undefined, {
@@ -285,7 +292,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
id: "weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 1,
maxDecimals: 0,
width: 100,
}),
NumberColumn({
@@ -293,7 +300,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
id: "spool_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 1,
maxDecimals: 0,
width: 100,
}),
FilteredQueryColumn({
@@ -336,7 +343,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
i18ncat: "filament",
width: 150,
}),
ActionsColumn(actions),
ActionsColumn(t("table.actions"), actions),
])}
/>
</List>

View File

@@ -16,6 +16,9 @@ export interface IFilament {
settings_extruder_temp?: number;
settings_bed_temp?: number;
color_hex?: string;
multi_color_hexes?: string;
multi_color_direction?: string;
external_id?: string;
extra: { [key: string]: string };
}

View File

@@ -1,16 +1,17 @@
import React from "react";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
import { Show, NumberField, DateField, TextField } from "@refinedev/antd";
import { Button, Typography } from "antd";
import { NumberFieldUnit } from "../../components/numberField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IFilament } from "./model";
import { enrichText } from "../../utils/parsing";
import React from "react";
import { useNavigate } from "react-router-dom";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldDisplay } from "../../components/extraFields";
import { NumberFieldUnit } from "../../components/numberField";
import SpoolIcon from "../../components/spoolIcon";
import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrency } from "../../utils/settings";
import { IFilament } from "./model";
dayjs.extend(utc);
const { Title } = Typography;
@@ -49,6 +50,13 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
navigate(URL);
};
const colorObj = record?.multi_color_hexes
? {
colors: record.multi_color_hexes.split(","),
vertical: record.multi_color_direction === "longitudinal",
}
: record?.color_hex;
return (
<Show
isLoading={isLoading}
@@ -80,7 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} />
<Title level={5}>{t("filament.fields.color_hex")}</Title>
<TextField value={record?.color_hex} />
{colorObj && <SpoolIcon color={colorObj} size="large" />}
<Title level={5}>{t("filament.fields.material")}</Title>
<TextField value={record?.material} />
<Title level={5}>{t("filament.fields.price")}</Title>
@@ -143,6 +151,8 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
)}
<Title level={5}>{t("filament.fields.article_number")}</Title>
<TextField value={record?.article_number} />
<Title level={5}>{t("filament.fields.external_id")}</Title>
<TextField value={record?.external_id} />
<Title level={5}>{t("filament.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} />
<Title level={4}>{t("settings.extra_fields.tab")}</Title>

View File

@@ -1,13 +1,13 @@
import React from "react";
import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { List, theme } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Content } from "antd/es/layout/layout";
import { List, theme } from "antd";
import Title from "antd/es/typography/Title";
import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons";
import { Link } from "react-router-dom";
import React from "react";
import { Trans } from "react-i18next";
import { Link } from "react-router-dom";
dayjs.extend(utc);

View File

@@ -1,14 +1,14 @@
import React, { ReactNode } from "react";
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Content } from "antd/es/layout/layout";
import { Card, Col, Row, Statistic, theme } from "antd";
import Title from "antd/es/typography/Title";
import Logo from "../../icon.svg?react";
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
import { ISpool } from "../spools/model";
import React, { ReactNode } from "react";
import { Link } from "react-router-dom";
import Logo from "../../icon.svg?react";
import { ISpool } from "../spools/model";
dayjs.extend(utc);

View File

@@ -0,0 +1,72 @@
import { PageHeader } from "@refinedev/antd";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { theme } from "antd";
import { Content } from "antd/es/layout/layout";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import SpoolQRCodePrintingDialog from "./spoolQrCodePrintingDialog";
import SpoolSelectModal from "./spoolSelectModal";
dayjs.extend(utc);
const { useToken } = theme;
export const Printing: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const spoolIds = searchParams.getAll("spools").map(Number);
const step = spoolIds.length > 0 ? 1 : 0;
return (
<>
<PageHeader
title={t("printing.qrcode.button")}
onBack={() => {
const returnUrl = searchParams.get("return");
if (returnUrl) {
navigate(returnUrl, { relative: "path" });
} else {
navigate("/spool");
}
}}
>
<Content
style={{
padding: 20,
minHeight: 280,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
color: token.colorText,
fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}}
>
{step === 0 && (
<SpoolSelectModal
description={t("printing.spoolSelect.description")}
onContinue={(spools) => {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete("spools");
spools.forEach((spool) => newParams.append("spools", spool.id.toString()));
newParams.set("return", "/spool/print");
return newParams;
});
}}
/>
)}
{step === 1 && <SpoolQRCodePrintingDialog spoolIds={spoolIds} />}
</Content>
</PageHeader>
</>
);
};
export default Printing;

View File

@@ -0,0 +1,106 @@
import { v4 as uuidv4 } from "uuid";
import { useGetSetting, useSetSetting } from "../../utils/querySettings";
import { ISpool } from "../spools/model";
export interface PrintSettings {
id: string;
name?: string;
margin?: { top: number; bottom: number; left: number; right: number };
printerMargin?: { top: number; bottom: number; left: number; right: number };
spacing?: { horizontal: number; vertical: number };
columns?: number;
rows?: number;
skipItems?: number;
itemCopies?: number;
paperSize?: string;
customPaperSize?: { width: number; height: number };
borderShowMode?: "none" | "border" | "grid";
}
export interface QRCodePrintSettings {
showContent?: boolean;
showQRCodeMode?: "no" | "simple" | "withIcon";
textSize?: number;
printSettings: PrintSettings;
}
export interface SpoolQRCodePrintSettings {
template?: string;
labelSettings: QRCodePrintSettings;
}
export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined {
const { data } = useGetSetting("print_presets");
if (!data) return;
const parsed: SpoolQRCodePrintSettings[] =
data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]);
// Loop through all parsed and generate a new ID field if it's not set
return parsed.map((settings) => {
if (!settings.labelSettings.printSettings.id) {
settings.labelSettings.printSettings.id = uuidv4();
}
return settings;
});
}
export function useSetPrintSettings(): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void {
const mut = useSetSetting("print_presets");
return (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => {
mut.mutate(spoolQRCodePrintSettings);
};
}
interface GenericObject {
[key: string]: any;
extra: { [key: string]: string };
}
function getTagValue(tag: string, obj: GenericObject): any {
// Split tag by .
const tagParts = tag.split(".");
if (tagParts[0] === "extra") {
const extraValue = obj.extra[tagParts[1]];
if (extraValue === undefined) {
return "?";
}
return JSON.parse(extraValue);
}
const value = obj[tagParts[0]] ?? "?";
// check if value is itself an object. If so, recursively call this and remove the first part of the tag
if (typeof value === "object") {
return getTagValue(tagParts.slice(1).join("."), value);
}
return value;
}
function applyNewline(text: string): JSX.Element[] {
return text.split("\n").map((line, idx, arr) => (
<span key={idx}>
{line}
{idx < arr.length - 1 && <br />}
</span>
));
}
function applyTextFormatting(text: string): JSX.Element[] {
const regex = /\*\*([\w\W]*?)\*\*/g;
const parts = text.split(regex);
// Map over the parts and wrap matched text with <b> tags
const elements = parts.map((part, index) => {
// Even index: outside asterisks, odd index: inside asterisks (to be bolded)
const node = applyNewline(part);
return index % 2 === 0 ? <span key={index}>{node}</span> : <b key={index}>{node}</b>;
});
return elements;
}
export function renderLabelContents(template: string, spool: ISpool): JSX.Element {
// Find all {tags} in the template string and loop over them
let result = template.replace(/\{(.*?)\}/g, function (_, tag) {
return getTagValue(tag, spool);
});
// Split string on \n into individual lines
return <>{applyTextFormatting(result)}</>;
}

View File

@@ -0,0 +1,836 @@
import { FileImageOutlined, PrinterOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
Button,
Col,
Collapse,
Divider,
Form,
InputNumber,
Radio,
RadioChangeEvent,
Row,
Select,
Slider,
Space,
} from "antd";
import * as htmlToImage from "html-to-image";
import React, { useRef } from "react";
import ReactToPrint from "react-to-print";
import { useSavedState } from "../../utils/saveload";
import { PrintSettings } from "./printing";
interface PrintingDialogProps {
items: JSX.Element[];
printSettings: PrintSettings;
setPrintSettings: (setPrintSettings: PrintSettings) => void;
style?: string;
extraSettings?: JSX.Element;
extraSettingsStart?: JSX.Element;
extraButtons?: JSX.Element;
}
interface PaperDimensions {
width: number;
height: number;
}
const paperDimensions: { [key: string]: PaperDimensions } = {
A3: {
width: 297,
height: 420,
},
A4: {
width: 210,
height: 297,
},
A5: {
width: 148,
height: 210,
},
Letter: {
width: 216,
height: 279,
},
Legal: {
width: 216,
height: 356,
},
Tabloid: {
width: 279,
height: 432,
},
};
const PrintingDialog: React.FC<PrintingDialogProps> = ({
items,
printSettings,
setPrintSettings,
style,
extraSettings,
extraSettingsStart,
extraButtons,
}) => {
const t = useTranslate();
const [collapseState, setCollapseState] = useSavedState<string[]>("print-collapseState", []);
const [previewScale, setPreviewScale] = useSavedState("print-previewScale", 0.6);
const margin = printSettings?.margin || { top: 10, bottom: 10, left: 10, right: 10 };
const printerMargin = printSettings?.printerMargin || { top: 5, bottom: 5, left: 5, right: 5 };
const spacing = printSettings?.spacing || { horizontal: 0, vertical: 0 };
const paperColumns = printSettings?.columns || 3;
const paperRows = printSettings?.rows || 8;
const skipItems = printSettings?.skipItems || 0;
const itemCopies = printSettings?.itemCopies || 1;
const paperSize = printSettings?.paperSize || "A4";
const customPaperSize = printSettings?.customPaperSize || { width: 210, height: 297 };
const borderShowMode = printSettings?.borderShowMode || "grid";
const paperWidth = paperSize === "custom" ? customPaperSize.width : paperDimensions[paperSize].width;
const paperHeight = paperSize === "custom" ? customPaperSize.height : paperDimensions[paperSize].height;
const printRef = useRef<HTMLDivElement>(null);
const itemWidth = (paperWidth - margin.left - margin.right - spacing.horizontal) / paperColumns - spacing.horizontal;
const itemHeight = (paperHeight - margin.top - margin.bottom - spacing.vertical) / paperRows - spacing.vertical;
const itemsPerRow = paperColumns;
const itemsPerPage = itemsPerRow * paperRows;
const itemsIncludingSkipped = [...Array(skipItems).fill(<></>)];
for (const item of items) {
for (let i = 0; i < itemCopies; i += 1) {
itemsIncludingSkipped.push(item);
}
}
const pageBlocks = [];
for (let page_idx = 0; page_idx < itemsIncludingSkipped.length / itemsPerPage; page_idx += 1) {
pageBlocks.push(itemsIncludingSkipped.slice(page_idx * itemsPerPage, (page_idx + 1) * itemsPerPage));
}
const pages = pageBlocks.map(function (items, pageIdx) {
const itemDivs = items.map((item, itemIdx) => {
const isFirstColumn = itemIdx % itemsPerRow === 0;
const isLastColumn = (itemIdx + 1) % itemsPerRow === 0;
const isFirstRow = itemIdx < itemsPerRow;
const isLastRow = itemsPerPage - itemIdx <= itemsPerRow;
return (
<div
key={itemIdx}
className="print-page-item"
style={{
width: `${itemWidth}mm`,
height: `${itemHeight}mm`,
border: borderShowMode === "grid" ? "1px solid #000" : "none",
paddingLeft: isFirstColumn ? `${Math.max(printerMargin.left - margin.left, 0)}mm` : 0,
paddingRight: isLastColumn ? `${Math.max(printerMargin.right - margin.right, 0)}mm` : 0,
paddingTop: isFirstRow ? `${Math.max(printerMargin.top - margin.top, 0)}mm` : 0,
paddingBottom: isLastRow ? `${Math.max(printerMargin.bottom - margin.bottom, 0)}mm` : 0,
}}
>
{item}
</div>
);
});
return (
<div
className="print-page"
key={pageIdx}
style={{
width: `${paperWidth}mm`,
height: `${paperHeight}mm`,
backgroundColor: "#FFF",
overflow: "hidden",
}}
>
<div
className="print-page-area"
style={{
outline: borderShowMode !== "none" ? "1px solid #000" : "none",
outlineOffset: "-1px",
height: `${paperHeight - margin.top - margin.bottom}mm`,
width: `${paperWidth - margin.left - margin.right}mm`,
marginTop: `calc(${margin.top}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
marginLeft: `calc(${margin.left}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
marginRight: `calc(${margin.right}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
marginBottom: `calc(${margin.bottom}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
rowGap: `${spacing.vertical}mm`,
columnGap: `${spacing.horizontal}mm`,
paddingTop: `${spacing.vertical}mm`,
paddingLeft: `${spacing.horizontal}mm`,
}}
>
{itemDivs}
</div>
</div>
</div>
);
});
const saveAsImage = () => {
const hasPrinted: Element[] = [];
Array.from(document.getElementsByClassName("print-qrcode-item")).forEach(async (item) => {
// Prevent printing copies
for (let i = 0; i < hasPrinted.length; i += 1) {
if (item.isEqualNode(hasPrinted[i])) return;
}
hasPrinted.push(item);
// Generate png image
const url = await htmlToImage.toPng(item as HTMLElement, {
backgroundColor: "#FFF",
cacheBust: true,
});
// Download image
const link = document.createElement("a");
link.href = url;
link.download = "spoolmanlabel.png";
link.click();
});
};
return (
<>
<Row gutter={16}>
<Col
span={14}
style={{
// This magic makes this column take the height of the sibling column
// https://stackoverflow.com/a/49065029/2911165
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
transform: "translateZ(0)",
overflow: "auto",
flexBasis: "0px",
flexGrow: "1",
}}
>
<div
className="print-container"
ref={printRef}
style={{
transform: `scale(${previewScale})`,
transformOrigin: "top left",
}}
>
<style>
{`
@media print {
html, body {
height: initial !important;
overflow: initial !important;
-webkit-print-color-adjust: exact;
}
@page {
size: auto;
margin: 0;
}
.print-container {
transform: scale(1) !important;
}
.print-page {
page-break-before: auto;
}
}
@media screen {
.print-page {
margin-top: 10mm;
}
}
.print-page {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
line-height: 1.2;
}
.print-page * {
box-sizing: border-box;
}
${style ?? ""}
`}
</style>
{pages}
</div>
</div>
</Col>
<Col span={10}>
<Form labelAlign="left" colon={false} labelWrap={true} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }}>
{extraSettingsStart}
<Divider />
<Form.Item label={t("printing.generic.skipItems")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={30}
value={skipItems}
onChange={(value) => {
printSettings.skipItems = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
min={0}
style={{ margin: "0 16px" }}
value={skipItems}
onChange={(value) => {
printSettings.skipItems = value ?? 1;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.itemCopies")}>
<Row>
<Col span={12}>
<Slider
min={1}
max={3}
value={itemCopies}
onChange={(value) => {
printSettings.itemCopies = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
min={1}
style={{ margin: "0 16px" }}
value={itemCopies}
onChange={(value) => {
printSettings.itemCopies = value ?? 1;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.showBorder")}>
<Radio.Group
options={[
{ label: t("printing.generic.borders.none"), value: "none" },
{
label: t("printing.generic.borders.border"),
value: "border",
},
{ label: t("printing.generic.borders.grid"), value: "grid" },
]}
onChange={(e: RadioChangeEvent) => {
printSettings.borderShowMode = e.target.value;
setPrintSettings(printSettings);
}}
value={borderShowMode}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item label={t("printing.generic.previewScale")}>
<Row>
<Col span={12}>
<Slider
min={0.1}
max={3}
step={0.01}
value={previewScale}
onChange={(value) => {
setPreviewScale(value);
}}
/>
</Col>
<Col span={12}>
<InputNumber
min={0.1}
max={3}
step={0.01}
style={{ margin: "0 16px" }}
value={previewScale}
onChange={(value) => {
setPreviewScale(value ?? 0.1);
}}
/>
</Col>
</Row>
</Form.Item>
<Divider />
<Collapse
defaultActiveKey={collapseState}
bordered={false}
ghost
onChange={(key) => {
if (Array.isArray(key)) {
setCollapseState(key);
}
}}
>
<Collapse.Panel header={t("printing.generic.contentSettings")} key="1">
{extraSettings}
</Collapse.Panel>
<Collapse.Panel header={t("printing.generic.layoutSettings")} key="2">
{t("printing.generic.description")}
<Divider />
<Form.Item label={t("printing.generic.paperSize")}>
<Select
value={paperSize}
onChange={(value) => {
printSettings.paperSize = value;
setPrintSettings(printSettings);
}}
>
{Object.keys(paperDimensions).map((key) => (
<Select.Option key={key} value={key}>
{key}
</Select.Option>
))}
<Select.Option value="custom">{t("printing.generic.customSize")}</Select.Option>
</Select>
</Form.Item>
<Form.Item label={t("printing.generic.dimensions")} hidden={paperSize !== "custom"}>
<Row align="middle">
<Col span={11}>
<InputNumber
value={customPaperSize.width}
min={0.1}
addonAfter="mm"
onChange={(value) => {
customPaperSize.width = value ?? 0;
printSettings.customPaperSize = customPaperSize;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={2} style={{ textAlign: "center" }}>
x
</Col>
<Col span={11}>
<InputNumber
value={customPaperSize.height}
min={0.1}
addonAfter="mm"
onChange={(value) => {
customPaperSize.height = value ?? 0;
printSettings.customPaperSize = customPaperSize;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.columns")}>
<Row>
<Col span={12}>
<Slider
min={1}
max={5}
value={paperColumns}
onChange={(value) => {
printSettings.columns = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
min={1}
style={{ margin: "0 16px" }}
value={paperColumns}
onChange={(value) => {
printSettings.columns = value ?? 1;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.rows")}>
<Row>
<Col span={12}>
<Slider
min={1}
max={15}
value={paperRows}
onChange={(value) => {
printSettings.rows = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
min={1}
style={{ margin: "0 16px" }}
value={paperRows}
onChange={(value) => {
printSettings.rows = value ?? 1;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Divider />
<p>{t("printing.generic.helpMargin")}</p>
<Form.Item label={t("printing.generic.marginLeft")}>
<Row>
<Col span={12}>
<Slider
min={-20}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={margin.left}
onChange={(value) => {
margin.left = value;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={margin.left}
addonAfter="mm"
onChange={(value) => {
margin.left = value ?? 0;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.marginTop")}>
<Row>
<Col span={12}>
<Slider
min={-20}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={margin.top}
onChange={(value) => {
margin.top = value;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={margin.top}
addonAfter="mm"
onChange={(value) => {
margin.top = value ?? 0;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.marginRight")}>
<Row>
<Col span={12}>
<Slider
min={-20}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={margin.right}
onChange={(value) => {
margin.right = value;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={margin.right}
addonAfter="mm"
onChange={(value) => {
margin.right = value ?? 0;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.marginBottom")}>
<Row>
<Col span={12}>
<Slider
min={-20}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={margin.bottom}
onChange={(value) => {
margin.bottom = value;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={margin.bottom}
addonAfter="mm"
onChange={(value) => {
margin.bottom = value ?? 0;
printSettings.margin = margin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Divider />
<p>{t("printing.generic.helpPrinterMargin")}</p>
<Form.Item label={t("printing.generic.printerMarginLeft")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={printerMargin.left}
onChange={(value) => {
printerMargin.left = value;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={printerMargin.left}
addonAfter="mm"
onChange={(value) => {
printerMargin.left = value ?? 0;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.printerMarginTop")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={printerMargin.top}
onChange={(value) => {
printerMargin.top = value;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={printerMargin.top}
addonAfter="mm"
onChange={(value) => {
printerMargin.top = value ?? 0;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.printerMarginRight")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={printerMargin.right}
onChange={(value) => {
printerMargin.right = value;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={printerMargin.right}
addonAfter="mm"
onChange={(value) => {
printerMargin.right = value ?? 0;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.printerMarginBottom")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={50}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={printerMargin.bottom}
onChange={(value) => {
printerMargin.bottom = value;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={printerMargin.bottom}
addonAfter="mm"
onChange={(value) => {
printerMargin.bottom = value ?? 0;
printSettings.printerMargin = printerMargin;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Divider />
<Form.Item label={t("printing.generic.horizontalSpacing")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={20}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={spacing.horizontal}
onChange={(value) => {
spacing.horizontal = value;
printSettings.spacing = spacing;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={spacing.horizontal}
addonAfter="mm"
onChange={(value) => {
spacing.horizontal = value ?? 0;
printSettings.spacing = spacing;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.generic.verticalSpacing")}>
<Row>
<Col span={12}>
<Slider
min={0}
max={20}
step={0.1}
tooltip={{ formatter: (value) => `${value} mm` }}
value={spacing.vertical}
onChange={(value) => {
spacing.vertical = value;
printSettings.spacing = spacing;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
step={0.1}
style={{ margin: "0 16px" }}
value={spacing.vertical}
addonAfter="mm"
onChange={(value) => {
spacing.vertical = value ?? 0;
printSettings.spacing = spacing;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
</Collapse.Panel>
</Collapse>
</Form>
</Col>
</Row>
<Row justify={"end"}>
<Col>
<Space>
{extraButtons}
<Button type="primary" icon={<FileImageOutlined />} size="large" onClick={saveAsImage}>
{t("printing.generic.saveAsImage")}
</Button>
<ReactToPrint
key="print-button"
trigger={() => (
<Button type="primary" icon={<PrinterOutlined />} size="large">
{t("printing.generic.print")}
</Button>
)}
content={() => printRef.current}
/>
</Space>
</Col>
</Row>
</>
);
};
export default PrintingDialog;

View File

@@ -0,0 +1,174 @@
import { useTranslate } from "@refinedev/core";
import { Col, Form, InputNumber, QRCode, Radio, RadioChangeEvent, Row, Slider, Switch } from "antd";
import { QRCodePrintSettings } from "./printing";
import PrintingDialog from "./printingDialog";
interface QRCodeData {
value: string;
label?: JSX.Element;
errorLevel?: "L" | "M" | "Q" | "H";
}
interface QRCodePrintingDialogProps {
items: QRCodeData[];
printSettings: QRCodePrintSettings;
setPrintSettings: (setPrintSettings: QRCodePrintSettings) => void;
extraSettings?: JSX.Element;
extraSettingsStart?: JSX.Element;
extraButtons?: JSX.Element;
}
const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
items,
printSettings,
setPrintSettings,
extraSettings,
extraSettingsStart,
extraButtons,
}) => {
const t = useTranslate();
const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent;
const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon";
const textSize = printSettings?.textSize || 3;
const elements = items.map((item) => {
return (
<div className="print-qrcode-item">
{showQRCodeMode !== "no" && (
<div className="print-qrcode-container">
<QRCode
className="print-qrcode"
icon={showQRCodeMode === "withIcon" ? "/favicon.svg" : undefined}
value={item.value}
errorLevel={item.errorLevel}
type="svg"
color="#000"
/>
</div>
)}
{showContent && (
<div className="print-qrcode-title" style={showQRCodeMode === "no" ? { paddingLeft: "1mm" } : {}}>
{item.label ?? item.value}
</div>
)}
</div>
);
});
return (
<PrintingDialog
items={elements}
printSettings={printSettings.printSettings}
setPrintSettings={(newSettings) => {
printSettings.printSettings = newSettings;
setPrintSettings(printSettings);
}}
extraButtons={extraButtons}
extraSettingsStart={extraSettingsStart}
extraSettings={
<>
<Form.Item label={t("printing.qrcode.showQRCode")}>
<Radio.Group
options={[
{ label: t("printing.qrcode.showQRCodeMode.no"), value: "no" },
{
label: t("printing.qrcode.showQRCodeMode.simple"),
value: "simple",
},
{ label: t("printing.qrcode.showQRCodeMode.withIcon"), value: "withIcon" },
]}
onChange={(e: RadioChangeEvent) => {
printSettings.showQRCodeMode = e.target.value;
setPrintSettings(printSettings);
}}
value={showQRCodeMode}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item label={t("printing.qrcode.showContent")}>
<Switch
checked={showContent}
onChange={(checked) => {
printSettings.showContent = checked;
setPrintSettings(printSettings);
}}
/>
</Form.Item>
<Form.Item label={t("printing.qrcode.textSize")}>
<Row>
<Col span={12}>
<Slider
disabled={!showContent}
tooltip={{ formatter: (value) => `${value} mm` }}
min={2}
max={7}
value={textSize}
step={0.1}
onChange={(value) => {
printSettings.textSize = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
disabled={!showContent}
min={0.01}
step={0.1}
style={{ margin: "0 16px" }}
value={textSize}
addonAfter="mm"
onChange={(value) => {
printSettings.textSize = value ?? 5;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
{extraSettings}
</>
}
style={`
.print-page .print-qrcode-item {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
}
.print-page .print-qrcode-container {
max-width: ${showContent ? "50%" : "100%"};
display: flex;
}
.print-page .print-qrcode {
width: auto !important;
height: auto !important;
padding: 2mm;
}
.print-page .print-qrcode-title {
flex: 1 1 auto;
font-size: ${textSize}mm;
color: #000;
overflow: hidden;
}
.print-page canvas, .print-page svg {
/* display: block; */
object-fit: contain;
height: 100% !important;
width: 100% !important;
max-height: 100%;
max-width: 100%;
}
`}
/>
);
};
export default QRCodePrintingDialog;

View File

@@ -0,0 +1,367 @@
import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { useGetSetting } from "../../utils/querySettings";
import { useTranslate } from "@refinedev/core";
import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message, Switch } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useSavedState } from "../../utils/saveload";
import { useGetSpoolsByIds } from "../spools/functions";
import { ISpool } from "../spools/model";
import {
SpoolQRCodePrintSettings,
renderLabelContents,
useGetPrintSettings as useGetPrintPresets,
useSetPrintSettings as useSetPrintPresets,
} from "./printing";
import QRCodePrintingDialog from "./qrCodePrintingDialog";
const { Text } = Typography;
interface SpoolQRCodePrintingDialog {
spoolIds: number[];
}
const SpoolQRCodePrintingDialog: React.FC<SpoolQRCodePrintingDialog> = ({ spoolIds }) => {
const t = useTranslate();
const baseUrlSetting = useGetSetting("qr_code_url");
const baseUrlRoot =
baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== ""
? JSON.parse(baseUrlSetting.data?.value)
: window.location.origin;
const [messageApi, contextHolder] = message.useMessage();
const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", baseUrlSetting.data?.value !== "");
const itemQueries = useGetSpoolsByIds(spoolIds);
const items = itemQueries
.map((itemQuery) => {
return itemQuery.data ?? null;
})
.filter((item) => item !== null) as ISpool[];
// Selected preset state
const [selectedPresetState, setSelectedPresetState] = useSavedState<string | undefined>("selectedPreset", undefined);
// Keep a local copy of the settings which is what's actually displayed. Use the remote state only for saving.
// This decouples the debounce stuff from the UI
const [localPresets, setLocalPresets] = useState<SpoolQRCodePrintSettings[] | undefined>();
const remotePresets = useGetPrintPresets();
const setRemotePresets = useSetPrintPresets();
const localOrRemotePresets = localPresets ?? remotePresets;
const savePresetsRemote = () => {
if (!localPresets) return;
setRemotePresets(localPresets);
};
// Functions to update settings
const addNewPreset = () => {
if (!localOrRemotePresets) return;
const newId = uuidv4();
const newPreset = {
labelSettings: {
printSettings: {
id: newId,
name: t("printing.generic.newSetting"),
},
},
};
setLocalPresets([...localOrRemotePresets, newPreset]);
setSelectedPresetState(newId);
return newPreset;
};
const duplicateCurrentPreset = () => {
if (!localOrRemotePresets) return;
const newPreset = {
...curPreset,
labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } },
};
newPreset.labelSettings.printSettings.id = uuidv4();
setLocalPresets([...localOrRemotePresets, newPreset]);
setSelectedPresetState(newPreset.labelSettings.printSettings.id);
};
const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => {
if (!localOrRemotePresets) return;
setLocalPresets(
localOrRemotePresets.map((presets) =>
presets.labelSettings.printSettings.id === newSettings.labelSettings.printSettings.id ? newSettings : presets
)
);
};
const deleteCurrentPreset = () => {
if (!localOrRemotePresets) return;
setLocalPresets(
localOrRemotePresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== selectedPresetState)
);
setSelectedPresetState(undefined);
};
// Initialize presets
let curPreset: SpoolQRCodePrintSettings;
if (localOrRemotePresets === undefined) {
// DB not loaded yet, use a temporary one
curPreset = {
labelSettings: {
printSettings: {
id: "TEMP",
name: t("printing.generic.newSetting"),
},
},
};
} else {
// DB is loaded, find the selected setting
if (localOrRemotePresets.length === 0) {
// DB loaded, but no settings found, add a new one and select it
const newSetting = addNewPreset();
if (!newSetting) {
console.error("Error adding new setting, this should never happen");
return;
}
// Mutate the allPrintSettings list so that the rest of the UI will work fine
localOrRemotePresets.push(newSetting);
curPreset = newSetting;
} else {
// DB loaded and at least 1 setting exists
if (!selectedPresetState) {
// No setting has been selected, select the first one
curPreset = localOrRemotePresets[0];
setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
} else {
// A setting has been selected, find it
const foundSetting = localOrRemotePresets.find(
(settings) => settings.labelSettings.printSettings.id === selectedPresetState
);
if (foundSetting) {
curPreset = foundSetting;
} else {
// Selected setting not found, select a temp one
curPreset = {
labelSettings: {
printSettings: {
id: "TEMP",
name: t("printing.generic.newSetting"),
},
},
};
}
}
}
}
const [templateHelpOpen, setTemplateHelpOpen] = useState(false);
const template =
curPreset.template ??
`**{filament.vendor.name} - {filament.name}
#{id} - {filament.material}**
Spool Weight: {filament.spool_weight} g
ET: {filament.settings_extruder_temp} °C
BT: {filament.settings_bed_temp} °C
Lot Nr: {lot_nr}
{comment}
{filament.comment}
{filament.vendor.comment}`;
const spoolTags = [
{ tag: "id" },
{ tag: "registered" },
{ tag: "first_used" },
{ tag: "last_used" },
{ tag: "price" },
{ tag: "initial_weight" },
{ tag: "spool_weight" },
{ tag: "remaining_weight" },
{ tag: "used_weight" },
{ tag: "remaining_length" },
{ tag: "used_length" },
{ tag: "location" },
{ tag: "lot_nr" },
{ tag: "comment" },
{ tag: "archived" },
];
const spoolFields = useGetFields(EntityType.spool);
if (spoolFields.data !== undefined) {
spoolFields.data.forEach((field) => {
spoolTags.push({ tag: `extra.${field.key}` });
});
}
const filamentTags = [
{ tag: "filament.id" },
{ tag: "filament.registered" },
{ tag: "filament.name" },
{ tag: "filament.material" },
{ tag: "filament.price" },
{ tag: "filament.density" },
{ tag: "filament.diameter" },
{ tag: "filament.weight" },
{ tag: "filament.spool_weight" },
{ tag: "filament.article_number" },
{ tag: "filament.comment" },
{ tag: "filament.settings_extruder_temp" },
{ tag: "filament.settings_bed_temp" },
{ tag: "filament.color_hex" },
{ tag: "filament.multi_color_hexes" },
{ tag: "filament.multi_color_direction" },
{ tag: "filament.external_id" },
];
const filamentFields = useGetFields(EntityType.filament);
if (filamentFields.data !== undefined) {
filamentFields.data.forEach((field) => {
filamentTags.push({ tag: `filament.extra.${field.key}` });
});
}
const vendorTags = [
{ tag: "filament.vendor.id" },
{ tag: "filament.vendor.registered" },
{ tag: "filament.vendor.name" },
{ tag: "filament.vendor.comment" },
{ tag: "filament.vendor.empty_spool_weight" },
{ tag: "filament.vendor.external_id" },
];
const vendorFields = useGetFields(EntityType.vendor);
if (vendorFields.data !== undefined) {
vendorFields.data.forEach((field) => {
vendorTags.push({ tag: `filament.vendor.extra.${field.key}` });
});
}
const templateTags = [...spoolTags, ...filamentTags, ...vendorTags];
return (
<>
{contextHolder}
<QRCodePrintingDialog
printSettings={curPreset.labelSettings}
setPrintSettings={(newSettings) => {
curPreset.labelSettings = newSettings;
updateCurrentPreset(curPreset);
}}
extraSettingsStart={
<>
<Form.Item label={t("printing.generic.settings")}>
<Flex gap={8}>
<Select
value={selectedPresetState}
onChange={(value) => {
setSelectedPresetState(value);
}}
options={
localOrRemotePresets &&
localOrRemotePresets.map((settings) => ({
label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"),
value: settings.labelSettings.printSettings.id,
}))
}
></Select>
<Button
style={{ width: "3em" }}
icon={<PlusOutlined />}
title={t("printing.generic.addSettings")}
onClick={addNewPreset}
/>
<Button
style={{ width: "3em" }}
icon={<CopyOutlined />}
title={t("printing.generic.duplicateSettings")}
onClick={duplicateCurrentPreset}
/>
{localOrRemotePresets && localOrRemotePresets.length > 1 && (
<Popconfirm
title={t("printing.generic.deleteSettings")}
description={t("printing.generic.deleteSettingsConfirm")}
onConfirm={deleteCurrentPreset}
okText={t("buttons.delete")}
cancelText={t("buttons.cancel")}
>
<Button
style={{ width: "3em" }}
danger
icon={<DeleteOutlined />}
title={t("printing.generic.deleteSettings")}
/>
</Popconfirm>
)}
</Flex>
</Form.Item>
<Form.Item label={t("printing.generic.settingsName")}>
<Input
value={curPreset.labelSettings.printSettings?.name}
onChange={(e) => {
curPreset.labelSettings.printSettings.name = e.target.value;
updateCurrentPreset(curPreset);
}}
/>
</Form.Item>
</>
}
items={items.map((spool) => ({
value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `web+spoolman:s-${spool.id}`,
label: (
<p
style={{
padding: "1mm 1mm 1mm 0",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{renderLabelContents(template, spool)}
</p>
),
errorLevel: "H",
}))}
extraSettings={
<>
<Form.Item label={t("printing.qrcode.template")}>
<TextArea
value={template}
rows={8}
onChange={(newValue) => {
curPreset.template = newValue.target.value;
updateCurrentPreset(curPreset);
}}
/>
</Form.Item>
<Form.Item label={t("printing.qrcode.useHTTPUrl.label")} tooltip={t("printing.qrcode.useHTTPUrl.tooltip")}>
<Switch checked={useHTTPUrl} onChange={(checked) => setUseHTTPUrl(checked)} />
</Form.Item>
<Modal open={templateHelpOpen} footer={null} onCancel={() => setTemplateHelpOpen(false)}>
<Table
size="small"
showHeader={false}
pagination={false}
scroll={{ y: 400 }}
columns={[{ dataIndex: "tag" }]}
dataSource={templateTags}
/>
</Modal>
<Text type="secondary">
{t("printing.qrcode.templateHelp")}{" "}
<Button size="small" onClick={() => setTemplateHelpOpen(true)}>
{t("actions.show")}
</Button>
</Text>
</>
}
extraButtons={
<>
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
onClick={() => {
savePresetsRemote();
messageApi.success(t("notifications.saveSuccessful"));
}}
>
{t("printing.generic.saveSetting")}
</Button>
</>
}
/>
</>
);
};
export default SpoolQRCodePrintingDialog;

View File

@@ -0,0 +1,221 @@
import { RightOutlined } from "@ant-design/icons";
import { useTable } from "@refinedev/antd";
import { Button, Checkbox, Col, message, Row, Space, Table } from "antd";
import { t } from "i18next";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "../../components/column";
import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "../../components/otherModels";
import { removeUndefined } from "../../utils/filtering";
import { TableState } from "../../utils/saveload";
import { ISpool } from "../spools/model";
interface Props {
description?: string;
onContinue: (selectedSpools: ISpool[]) => void;
}
interface ISpoolCollapsed extends ISpool {
"filament.combined_name": string;
"filament.id": number;
"filament.material"?: string;
}
function collapseSpool(element: ISpool): ISpoolCollapsed {
let filament_name: string;
if (element.filament.vendor && "name" in element.filament.vendor) {
filament_name = `${element.filament.vendor.name} - ${element.filament.name}`;
} else {
filament_name = element.filament.name ?? element.filament.id.toString();
}
return {
...element,
"filament.combined_name": filament_name,
"filament.id": element.filament.id,
"filament.material": element.filament.material,
};
}
const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
const [selectedItems, setSelectedItems] = useState<number[]>([]);
const [showArchived, setShowArchived] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
const { tableProps, sorters, filters, current, pageSize } = useTable<ISpoolCollapsed>({
resource: "spool",
meta: {
queryParams: {
["allow_archived"]: showArchived,
},
},
syncWithLocation: false,
pagination: {
mode: "off",
current: 1,
pageSize: 10,
},
sorters: {
mode: "server",
},
filters: {
mode: "server",
},
queryOptions: {
select(data) {
return {
total: data.total,
data: data.data.map(collapseSpool),
};
},
},
});
// Store state in local storage
const tableState: TableState = {
sorters,
filters,
pagination: { current, pageSize },
};
// Collapse the dataSource to a mutable list and add a filament_name field
const dataSource: ISpoolCollapsed[] = useMemo(
() => (tableProps.dataSource || []).map((record) => ({ ...record })),
[tableProps.dataSource]
);
// Function to add/remove all filtered items from selected items
const selectUnselectFiltered = (select: boolean) => {
setSelectedItems((prevSelected) => {
const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool));
return select ? [...prevSelected, ...filtered] : filtered;
});
};
// Handler for selecting/unselecting individual items
const handleSelectItem = (item: number) => {
setSelectedItems((prevSelected) =>
prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item]
);
};
// State for the select/unselect all checkbox
const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id));
const isSomeButNotAllFilteredSelected =
dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected;
const commonProps = {
t,
navigate,
actions: () => {
return [];
},
dataSource,
tableState,
sorter: true,
};
return (
<>
{contextHolder}
<Space direction="vertical" style={{ width: "100%" }}>
{description && <div>{description}</div>}
<Table
{...tableProps}
rowKey="id"
tableLayout="auto"
dataSource={dataSource}
pagination={false}
scroll={{ y: 200 }}
columns={removeUndefined([
{
width: 50,
render: (_, item: ISpool) => (
<Checkbox checked={selectedItems.includes(item.id)} onChange={() => handleSelectItem(item.id)} />
),
},
SortedColumn({
...commonProps,
id: "id",
i18ncat: "spool",
width: 80,
}),
SpoolIconColumn({
...commonProps,
id: "filament.combined_name",
dataId: "filament.combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex,
filterValueQuery: useSpoolmanFilamentFilter(),
}),
FilteredQueryColumn({
...commonProps,
id: "filament.material",
i18nkey: "spool.fields.material",
filterValueQuery: useSpoolmanMaterials(),
}),
])}
/>
<Row gutter={[10, 10]}>
<Col span={12}>
<Checkbox
checked={isAllFilteredSelected}
indeterminate={isSomeButNotAllFilteredSelected}
onChange={(e) => {
selectUnselectFiltered(e.target.checked);
}}
>
{t("printing.spoolSelect.selectAll")}
</Checkbox>
</Col>
<Col span={12}>
<div style={{ float: "right" }}>
{t("printing.spoolSelect.selectedTotal", {
count: selectedItems.length,
})}
</div>
</Col>
<Col span={12}>
<Checkbox
checked={showArchived}
onChange={(e) => {
setShowArchived(e.target.checked);
if (!e.target.checked) {
// Remove archived spools from selected items
setSelectedItems((prevSelected) =>
prevSelected.filter(
(selected) => dataSource.find((spool) => spool.id === selected)?.archived !== true
)
);
}
}}
>
{t("printing.spoolSelect.showArchived")}
</Checkbox>
</Col>
<Col span={24}>
<Button
type="primary"
icon={<RightOutlined />}
iconPosition="end"
onClick={() => {
if (selectedItems.length === 0) {
messageApi.open({
type: "error",
content: t("printing.spoolSelect.noSpoolsSelected"),
});
return;
}
onContinue(dataSource.filter((spool) => selectedItems.includes(spool.id)));
}}
>
{t("buttons.continue")}
</Button>
</Col>
</Row>
</Space>
</>
);
};
export default SpoolSelectModal;

View File

@@ -1,3 +1,5 @@
import { PlusOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
Button,
Checkbox,
@@ -12,20 +14,18 @@ import {
Table,
message,
} from "antd";
import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
import { useTranslate } from "@refinedev/core";
import { useState } from "react";
import { FormItemProps, Rule } from "antd/es/form";
import { ColumnType } from "antd/es/table";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useState } from "react";
import { Trans } from "react-i18next";
import { useParams } from "react-router-dom";
import { FormItemProps, Rule } from "antd/es/form";
import { PlusOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { DateTimePicker } from "../../components/dateTimePicker";
import { InputNumberRange } from "../../components/inputNumberRange";
import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
dayjs.extend(utc);
dayjs.extend(timezone);

View File

@@ -1,17 +1,17 @@
import React from "react";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message } from "antd";
import { useEffect } from "react";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
export function GeneralSettings() {
const settings = useGetSettings();
const setSetting = useSetSetting();
const setCurrency = useSetSetting("currency");
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
const t = useTranslate();
// Set initial form values
React.useEffect(() => {
useEffect(() => {
if (settings.data) {
form.setFieldsValue({
currency: JSON.parse(settings.data.currency.value),
@@ -21,20 +21,17 @@ export function GeneralSettings() {
}, [settings.data, form]);
// Popup message if setSetting is successful
React.useEffect(() => {
if (setSetting.isSuccess) {
useEffect(() => {
if (setCurrency.isSuccess) {
messageApi.success(t("notifications.saveSuccessful"));
}
}, [setSetting.isSuccess, messageApi, t]);
}, [setCurrency.isSuccess, messageApi, t]);
// Handle form submit
const onFinish = (values: { currency: string, qr_code_url: string }) => {
// Check if the currency has changed
if (settings.data?.currency.value !== JSON.stringify(values.currency)) {
setSetting.mutate({
key: "currency",
value: JSON.stringify(values.currency),
});
setCurrency.mutate(values.currency);
}
// Check if the QR code URL has changed
if (settings.data?.qr_code_url.value !== JSON.stringify(values.qr_code_url)) {
@@ -94,7 +91,7 @@ export function GeneralSettings() {
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" htmlType="submit" loading={settings.isFetching || setSetting.isLoading}>
<Button type="primary" htmlType="submit" loading={settings.isFetching || setCurrency.isLoading}>
{t("buttons.save")}
</Button>
</Form.Item>

View File

@@ -1,13 +1,13 @@
import React from "react";
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Menu, theme } from "antd";
import { Content } from "antd/es/layout/layout";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Content } from "antd/es/layout/layout";
import { Menu, theme } from "antd";
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { GeneralSettings } from "./generalSettings";
import { ExtraFieldsSettings } from "./extraFieldsSettings";
import React from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { ExtraFieldsSettings } from "./extraFieldsSettings";
import { GeneralSettings } from "./generalSettings";
dayjs.extend(utc);

View File

@@ -1,19 +1,21 @@
import React, { useState } from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button, Typography } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import "../../utils/overrides.css";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { useSpoolmanLocations } from "../../components/otherModels";
import { searchMatches } from "../../utils/filtering";
import "../../utils/overrides.css";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { createFilamentFromExternal } from "../filaments/functions";
import { useGetFilamentSelectOptions } from "./functions";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
dayjs.extend(utc);
@@ -21,6 +23,10 @@ interface CreateOrCloneProps {
mode: "create" | "clone";
}
type ISpoolRequest = Omit<ISpoolParsedExtras, "id" | "registered"> & {
filament_id: number | string;
};
export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
@@ -29,7 +35,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const { form, formProps, formLoading, onFinish, redirect } = useForm<
ISpool,
HttpError,
ISpoolParsedExtras,
ISpoolRequest,
ISpoolParsedExtras
>({
redirect: false,
@@ -39,6 +45,9 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
formProps.initialValues = {};
}
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
formProps.initialValues.first_used = null;
@@ -46,7 +55,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
formProps.initialValues.used_weight = 0;
// Fix the filament_id
formProps.initialValues.filament_id = formProps.initialValues.filament.id;
if (formProps.initialValues.filament) {
formProps.initialValues.filament_id = formProps.initialValues.filament.id;
}
// Parse the extra fields from string values into real types
formProps.initialValues = ParsedExtras(formProps.initialValues);
}
// If the query variable filament_id is set, set the filament_id field to that value
@@ -56,8 +70,53 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
formProps.initialValues.filament_id = parseInt(filament_id);
}
//
// Set up the filament selection options
//
const {
options: filamentOptions,
internalSelectOptions,
externalSelectOptions,
allExternalFilaments,
} = useGetFilamentSelectOptions();
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament.
if (typeof selectedFilamentID === "number") {
return (
internalSelectOptions?.find((obj) => {
return obj.value === selectedFilamentID;
}) ?? null
);
} else if (typeof selectedFilamentID === "string") {
return (
externalSelectOptions?.find((obj) => {
return obj.value === selectedFilamentID;
}) ?? null
);
} else {
return null;
}
}, [selectedFilamentID, internalSelectOptions, externalSelectOptions]);
//
// Submit handler
//
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const values = StringifiedExtras(await form.validateFields());
if (selectedFilament?.is_internal === false) {
// Filament ID being a string indicates its an external filament.
// If so, we should first create the internal filament version, then create the spool(s)
const externalFilament = allExternalFilaments?.find((f) => f.id === values.filament_id);
if (!externalFilament) {
throw new Error("Unknown external filament");
}
const internalFilament = await createFilamentFromExternal(externalFilament);
values.filament_id = internalFilament.id;
}
if (quantity > 1) {
const submit = Array(quantity).fill(values);
// queue multiple creates this way for now Refine doesn't seem to map Arrays to createMany or multiple creates like it says it does
@@ -65,16 +124,13 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
} else {
await onFinish(values);
}
redirect(redirectTo, (values as ISpool).id);
};
const { queryResult } = useSelect<IFilament>({
resource: "filament",
});
redirect(redirectTo);
};
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
@@ -83,58 +139,23 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
});
}, [form, extraFields.data, formProps.initialValues]);
const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = "";
if (item.vendor) {
vendorPrefix = `${item.vendor.name} - `;
}
let name = item.name;
if (!name) {
name = `ID: ${item.id}`;
}
let material = "";
if (item.material) {
material = ` - ${item.material}`;
}
const label = `${vendorPrefix}${name}${material}`;
return {
label: label,
value: item.id,
weight: item.weight,
spool_weight: item.spool_weight,
};
});
filamentOptions?.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
//
// Weight calculations
//
const [weightToEnter, setWeightToEnter] = useState(1);
const [usedWeight, setUsedWeight] = useState(0);
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = filamentOptions?.find((obj) => {
return obj.value === selectedFilamentID;
});
const filamentWeight = selectedFilament?.weight || 0;
const spoolWeight = selectedFilament?.spool_weight || 0;
const filamentChange = (newID: number) => {
const newSelectedFilament = filamentOptions?.find((obj) => {
return obj.value === newID;
});
const newFilamentWeight = newSelectedFilament?.weight || 0;
const newSpoolWeight = newSelectedFilament?.spool_weight || 0;
if (weightToEnter >= 3) {
if (!(newFilamentWeight && newSpoolWeight)) {
setWeightToEnter(2);
}
useEffect(() => {
const newFilamentWeight = selectedFilament?.weight || 0;
const newSpoolWeight = selectedFilament?.spool_weight || 0;
if (newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
if (weightToEnter >= 2) {
if (!newFilamentWeight) {
setWeightToEnter(1);
}
if (newSpoolWeight > 0) {
form.setFieldValue("spool_weight", newSpoolWeight);
}
};
}, [selectedFilament]);
const weightChange = (weight: number) => {
setUsedWeight(weight);
@@ -160,6 +181,67 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
setQuantity(quantity - 1);
};
const getSpoolWeight = (): number => {
return spoolWeightValue ?? selectedFilament?.spool_weight ?? 0;
};
const getFilamentWeight = (): number => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
};
const getMeasuredWeight = (): number => {
const grossWeight = getGrossWeight();
return grossWeight - usedWeight;
};
const getRemainingWeight = (): number => {
const initial_weight = getFilamentWeight();
return initial_weight - usedWeight;
};
const isMeasuredWeightEnabled = (): boolean => {
if (!isRemainingWeightEnabled()) {
return false;
}
const spool_weight = spoolWeightValue;
return spool_weight || selectedFilament?.spool_weight ? true : false;
};
const isRemainingWeightEnabled = (): boolean => {
const initial_weight = initialWeightValue;
if (initial_weight) {
return true;
}
return selectedFilament?.weight ? true : false;
};
useEffect(() => {
if (weightToEnter >= WeightToEnter.measured_weight) {
if (!isMeasuredWeightEnabled()) {
setWeightToEnter(WeightToEnter.remaining_weight);
return;
}
}
if (weightToEnter >= WeightToEnter.remaining_weight) {
if (!isRemainingWeightEnabled()) {
setWeightToEnter(WeightToEnter.used_weight);
return;
}
}
}, [selectedFilament]);
return (
<Create
title={props.mode === "create" ? t("spool.titles.create") : t("spool.titles.clone")}
@@ -227,17 +309,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<Select
options={filamentOptions}
showSearch
filterOption={(input, option) =>
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
}
onChange={(value) => {
filamentChange(value);
}}
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
/>
</Form.Item>
{selectedFilament?.is_internal === false && (
<Alert message={t("spool.fields_help.external_filament")} type="info" />
)}
<Form.Item
label={t("filament.fields.price")}
help={t("filament.fields_help.price")}
label={t("spool.fields.price")}
help={t("spool.fields_help.price")}
name={["price"]}
rules={[
{
@@ -254,6 +334,36 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
parser={numberParser}
/>
</Form.Item>
<Form.Item
label={t("spool.fields.initial_weight")}
help={t("spool.fields_help.initial_weight")}
name={["initial_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.spool_weight")}
help={t("spool.fields_help.spool_weight")}
name={["spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>
@@ -263,14 +373,14 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
onChange={(value) => {
setWeightToEnter(value.target.value);
}}
defaultValue={1}
defaultValue={WeightToEnter.used_weight}
value={weightToEnter}
>
<Radio.Button value={1}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={2} disabled={!filamentWeight}>
<Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}>
{t("spool.fields.remaining_weight")}
</Radio.Button>
<Radio.Button value={3} disabled={!(filamentWeight && spoolWeight)}>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}>
{t("spool.fields.measured_weight")}
</Radio.Button>
</Radio.Group>
@@ -283,7 +393,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 1}
disabled={weightToEnter != WeightToEnter.used_weight}
value={usedWeight}
onChange={(value) => {
weightChange(value ?? 0);
@@ -301,10 +411,10 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 2}
value={filamentWeight ? filamentWeight - usedWeight : 0}
disabled={weightToEnter != WeightToEnter.remaining_weight}
value={getRemainingWeight()}
onChange={(value) => {
weightChange(filamentWeight - (value ?? 0));
weightChange(getFilamentWeight() - (value ?? 0));
}}
/>
</Form.Item>
@@ -319,10 +429,11 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
precision={1}
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != 3}
value={filamentWeight && spoolWeight ? filamentWeight - usedWeight + spoolWeight : 0}
disabled={weightToEnter != WeightToEnter.measured_weight}
value={getMeasuredWeight()}
onChange={(value) => {
weightChange(filamentWeight - ((value ?? 0) - spoolWeight));
const totalWeight = getGrossWeight();
weightChange(totalWeight - (value ?? 0));
}}
/>
</Form.Item>

View File

@@ -1,18 +1,19 @@
import React, { useEffect, useState } from "react";
import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert, Typography } from "antd";
import dayjs from "dayjs";
import { Alert, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model";
import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels";
import { message } from "antd/lib";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { useSpoolmanLocations } from "../../components/otherModels";
import { searchMatches } from "../../utils/filtering";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { createFilamentFromExternal } from "../filaments/functions";
import { useGetFilamentSelectOptions } from "./functions";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
@@ -21,11 +22,9 @@ We also need to stringify them again before sending them back to the API, which
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
enum WeightToEnter {
used_weight = 1,
remaining_weight = 2,
measured_weight = 3,
}
type ISpoolRequest = ISpoolParsedExtras & {
filament_id: number | string;
};
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
@@ -34,7 +33,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpool, ISpool>({
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the spool has been updated since the form was opened
@@ -43,10 +42,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
},
});
// Get filament selection options
const { queryResult } = useSelect<IFilament>({
resource: "filament",
});
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
// Add the filament_id field to the form
if (formProps.initialValues) {
@@ -56,75 +53,85 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
formProps.initialValues = ParsedExtras(formProps.initialValues);
}
//
// Set up the filament selection options
//
const {
options: filamentOptions,
internalSelectOptions,
externalSelectOptions,
allExternalFilaments,
} = useGetFilamentSelectOptions();
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament.
if (typeof selectedFilamentID === "number") {
return (
internalSelectOptions?.find((obj) => {
return obj.value === selectedFilamentID;
}) ?? null
);
} else if (typeof selectedFilamentID === "string") {
return (
externalSelectOptions?.find((obj) => {
return obj.value === selectedFilamentID;
}) ?? null
);
} else {
return null;
}
}, [selectedFilamentID, internalSelectOptions, externalSelectOptions]);
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: ISpoolParsedExtras) => {
formProps.onFinish = (allValues: ISpoolRequest) => {
if (allValues !== undefined && allValues !== null) {
// Lot of stupidity here to make types work
const stringifiedAllValues = StringifiedExtras<ISpoolParsedExtras>(allValues);
originalOnFinish?.({
extra: {},
...stringifiedAllValues,
});
const values = StringifiedExtras<ISpoolRequest>(allValues);
if (selectedFilament?.is_internal === false) {
// Filament ID being a string indicates its an external filament.
// If so, we should first create the internal filament version, then edit the spool
const externalFilament = allExternalFilaments?.find((f) => f.id === values.filament_id);
if (!externalFilament) {
throw new Error("Unknown external filament");
}
createFilamentFromExternal(externalFilament).then((internalFilament) => {
values.filament_id = internalFilament.id;
originalOnFinish?.({
extra: {},
...values,
});
});
} else {
originalOnFinish?.({
extra: {},
...values,
});
}
}
};
const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = "";
if (item.vendor) {
vendorPrefix = `${item.vendor.name} - `;
}
let name = item.name;
if (!name) {
name = `ID: ${item.id}`;
}
let material = "";
if (item.material) {
material = ` - ${item.material}`;
}
const label = `${vendorPrefix}${name}${material}`;
return {
label: label,
value: item.id,
weight: item.weight,
spool_weight: item.spool_weight,
};
});
filamentOptions?.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
const [weightToEnter, setWeightToEnter] = useState(1);
const [usedWeight, setUsedWeight] = useState(0);
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = filamentOptions?.find((obj) => {
return obj.value === selectedFilamentID;
});
const filamentWeight = selectedFilament?.weight || 0;
const spoolWeight = selectedFilament?.spool_weight || 0;
const filamentChange = (newID: number) => {
const newSelectedFilament = filamentOptions?.find((obj) => {
return obj.value === newID;
});
const filamentHasWeight = newSelectedFilament?.weight || 0;
const filamentHasSpoolWeight = newSelectedFilament?.spool_weight || 0;
if (weightToEnter == WeightToEnter.measured_weight) {
if (!(filamentHasWeight && filamentHasSpoolWeight)) {
setWeightToEnter(WeightToEnter.remaining_weight);
}
useEffect(() => {
const newFilamentWeight = selectedFilament?.weight || 0;
const newSpoolWeight = selectedFilament?.spool_weight || 0;
console.log("selectedFilament", selectedFilament, newFilamentWeight, newSpoolWeight);
if (newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
if (weightToEnter == WeightToEnter.remaining_weight || weightToEnter == WeightToEnter.measured_weight) {
if (!filamentHasWeight) {
setWeightToEnter(WeightToEnter.used_weight);
}
if (newSpoolWeight > 0) {
form.setFieldValue("spool_weight", newSpoolWeight);
}
};
}, [selectedFilament]);
const weightChange = (weight: number) => {
setUsedWeight(weight);
form.setFieldValue("used_weight", weight);
form.setFieldsValue({
used_weight: weight,
});
};
const locations = useSpoolmanLocations(true);
@@ -135,6 +142,67 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
allLocations.push(newLocation.trim());
}
const getSpoolWeight = (): number => {
return spoolWeightValue ?? selectedFilament?.spool_weight ?? 0;
};
const getFilamentWeight = (): number => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
};
const getMeasuredWeight = (): number => {
const grossWeight = getGrossWeight();
return grossWeight - usedWeight;
};
const getRemainingWeight = (): number => {
const initial_weight = getFilamentWeight();
return initial_weight - usedWeight;
};
const isMeasuredWeightEnabled = (): boolean => {
if (!isRemainingWeightEnabled()) {
return false;
}
const spool_weight = spoolWeightValue;
return spool_weight || selectedFilament?.spool_weight ? true : false;
};
const isRemainingWeightEnabled = (): boolean => {
const initial_weight = initialWeightValue;
if (initial_weight) {
return true;
}
return selectedFilament?.weight ? true : false;
};
useEffect(() => {
if (weightToEnter >= WeightToEnter.measured_weight) {
if (!isMeasuredWeightEnabled()) {
setWeightToEnter(WeightToEnter.remaining_weight);
return;
}
}
if (weightToEnter >= WeightToEnter.remaining_weight) {
if (!isRemainingWeightEnabled()) {
setWeightToEnter(WeightToEnter.used_weight);
return;
}
}
}, [selectedFilament]);
const initialUsedWeight = formProps.initialValues?.used_weight || 0;
useEffect(() => {
if (initialUsedWeight) {
@@ -211,14 +279,12 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<Select
options={filamentOptions}
showSearch
filterOption={(input, option) =>
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
}
onChange={(value) => {
filamentChange(value);
}}
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
/>
</Form.Item>
{selectedFilament?.is_internal === false && (
<Alert message={t("spool.fields_help.external_filament")} type="info" />
)}
<Form.Item
label={t("spool.fields.price")}
help={t("spool.fields_help.price")}
@@ -238,9 +304,40 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
parser={numberParser}
/>
</Form.Item>
<Form.Item
label={t("spool.fields.initial_weight")}
help={t("spool.fields_help.initial_weight")}
name={["initial_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.spool_weight")}
help={t("spool.fields_help.spool_weight")}
name={["spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>
<Form.Item label={t("spool.fields.weight_to_use")} help={t("spool.fields_help.weight_to_use")}>
<Radio.Group
onChange={(value) => {
@@ -250,10 +347,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
value={weightToEnter}
>
<Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!filamentWeight}>
<Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}>
{t("spool.fields.remaining_weight")}
</Radio.Button>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!(filamentWeight && spoolWeight)}>
<Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}>
{t("spool.fields.measured_weight")}
</Radio.Button>
</Radio.Group>
@@ -280,9 +377,9 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != WeightToEnter.remaining_weight}
value={filamentWeight ? filamentWeight - usedWeight : 0}
value={getRemainingWeight()}
onChange={(value) => {
weightChange(filamentWeight - (value ?? 0));
weightChange(getFilamentWeight() - (value ?? 0));
}}
/>
</Form.Item>
@@ -294,9 +391,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
formatter={numberFormatter}
parser={numberParser}
disabled={weightToEnter != WeightToEnter.measured_weight}
value={filamentWeight && spoolWeight ? filamentWeight - usedWeight + spoolWeight : 0}
value={getMeasuredWeight()}
onChange={(value) => {
weightChange(filamentWeight - ((value ?? 0) - spoolWeight));
const totalWeight = getGrossWeight();
weightChange(totalWeight - (value ?? 0));
}}
/>
</Form.Item>

View File

@@ -1,16 +0,0 @@
import { ISpool } from "./model";
export async function setSpoolArchived(spool: ISpool, archived: boolean) {
const apiEndpoint = import.meta.env.VITE_APIURL;
const init: RequestInit = {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
archived: archived,
}),
};
const request = new Request(apiEndpoint + "/spool/" + spool.id);
await fetch(request, init);
}

View File

@@ -0,0 +1,238 @@
import { useSelect, useTranslate } from "@refinedev/core";
import { useQueries } from "@tanstack/react-query";
import { Form, InputNumber, Modal, Radio } from "antd";
import { useForm } from "antd/es/form/Form";
import { useCallback, useMemo, useState } from "react";
import { formatLength, formatWeight } from "../../utils/parsing";
import { SpoolType, useGetExternalDBFilaments } from "../../utils/queryExternalDB";
import { getAPIURL } from "../../utils/url";
import { IFilament } from "../filaments/model";
import { ISpool } from "./model";
export async function setSpoolArchived(spool: ISpool, archived: boolean) {
const init: RequestInit = {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
archived: archived,
}),
};
const request = new Request(getAPIURL() + "/spool/" + spool.id);
await fetch(request, init);
}
/**
* Use some spool filament from this spool. Either specify length or weight.
* @param spool The spool
* @param length The length to add/subtract from the spool, in mm
* @param weight The weight to add/subtract from the spool, in g
*/
export async function useSpoolFilament(spool: ISpool, length?: number, weight?: number) {
const init: RequestInit = {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
use_length: length,
use_weight: weight,
}),
};
const request = new Request(`${getAPIURL()}/spool/${spool.id}/use`);
await fetch(request, init);
}
/**
* Returns an array of queries using the useQueries hook from @tanstack/react-query.
* Each query fetches a spool by its ID from the server.
*
* @param {number[]} ids - An array of spool IDs to fetch.
* @return An array of query results, each containing the fetched spool data.
*/
export function useGetSpoolsByIds(ids: number[]) {
return useQueries({
queries: ids.map((id) => {
return {
queryKey: ["spool", id],
queryFn: async () => {
const res = await fetch(getAPIURL() + "/spool/" + id);
return (await res.json()) as ISpool;
},
};
}),
});
}
/**
* Formats a filament label with the given parameters.
*/
export function formatFilamentLabel(
name: string,
diameter: number,
vendorName?: string,
material?: string,
weight?: number,
spoolType?: SpoolType
): string {
const portions = [];
if (vendorName) {
portions.push(vendorName);
}
portions.push(name);
const extras = [];
if (material) {
extras.push(material);
}
extras.push(formatLength(diameter));
if (weight) {
extras.push(formatWeight(weight));
}
if (spoolType) {
extras.push(spoolType.charAt(0).toUpperCase() + spoolType.slice(1) + " spool");
}
return `${portions.join(" - ")} (${extras.join(", ")})`;
}
interface SelectOption {
label: string;
value: string | number;
weight?: number;
spool_weight?: number;
is_internal: boolean;
}
export function useGetFilamentSelectOptions() {
// Setup hooks
const t = useTranslate();
const { queryResult: internalFilaments } = useSelect<IFilament>({
resource: "filament",
});
const externalFilaments = useGetExternalDBFilaments();
// Format and sort internal filament options
const filamentSelectInternal: SelectOption[] = useMemo(() => {
const data =
internalFilaments.data?.data.map((item) => {
return {
label: formatFilamentLabel(
item.name ?? `ID ${item.id}`,
item.diameter,
item.vendor?.name,
item.material,
item.weight
),
value: item.id,
weight: item.weight,
spool_weight: item.spool_weight,
is_internal: true,
};
}) ?? [];
data.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
return data;
}, [internalFilaments.data?.data]);
// Format and sort external filament options
const filamentSelectExternal: SelectOption[] = useMemo(() => {
const data =
externalFilaments.data?.map((item) => {
return {
label: formatFilamentLabel(
item.name,
item.diameter,
item.manufacturer,
item.material,
item.weight,
item.spool_type
),
value: item.id,
weight: item.weight,
spool_weight: item.spool_weight || undefined,
is_internal: false,
};
}) ?? [];
data.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
return data;
}, [externalFilaments.data]);
return {
options: [
{
label: <span>{t("spool.fields.filament_internal")}</span>,
options: filamentSelectInternal,
},
{
label: <span>{t("spool.fields.filament_external")}</span>,
options: filamentSelectExternal,
},
],
internalSelectOptions: filamentSelectInternal,
externalSelectOptions: filamentSelectExternal,
allExternalFilaments: externalFilaments.data,
};
}
type MeasurementType = "length" | "weight";
export function useSpoolAdjustModal() {
const t = useTranslate();
const [form] = useForm();
const [curSpool, setCurSpool] = useState<ISpool | null>(null);
const [measurementType, setMeasurementType] = useState<MeasurementType>("length");
const openSpoolAdjustModal = useCallback((spool: ISpool) => {
setCurSpool(spool);
}, []);
const spoolAdjustModal = useMemo(() => {
if (curSpool === null) {
return null;
}
const onSubmit = async () => {
if (curSpool === null) {
return;
}
const value = form.getFieldValue("filament_value");
if (value === undefined || value === null) {
return;
}
if (measurementType === "length") {
await useSpoolFilament(curSpool, value, undefined);
} else {
await useSpoolFilament(curSpool, undefined, value);
}
setCurSpool(null);
};
return (
<Modal title={t("spool.titles.adjust")} open onCancel={() => setCurSpool(null)} onOk={form.submit}>
<p>{t("spool.form.adjust_filament_help")}</p>
<Form form={form} initialValues={{ measurement_type: measurementType }} onFinish={onSubmit}>
<Form.Item label={t("spool.form.measurement_type_label")} name="measurement_type">
<Radio.Group
value={measurementType}
onChange={({ target: { value } }) => setMeasurementType(value as MeasurementType)}
>
<Radio.Button value="length">{t("spool.form.measurement_type.length")}</Radio.Button>
<Radio.Button value="weight">{t("spool.form.measurement_type.weight")}</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label={t("spool.form.adjust_filament_value")} name="filament_value">
<InputNumber precision={1} addonAfter={measurementType === "length" ? "mm" : "g"} />
</Form.Item>
</Form>
</Modal>
);
}, [curSpool, measurementType, t]);
return {
openSpoolAdjustModal,
spoolAdjustModal,
};
}

View File

@@ -1,43 +1,44 @@
import React from "react";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { useTable, List } from "@refinedev/antd";
import { Table, Button, Dropdown, Modal } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { ISpool } from "./model";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import {
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Action,
ActionsColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
Action,
ActionsColumn,
CustomFieldColumn,
} from "../../components/column";
import { setSpoolArchived } from "./functions";
import SelectAndPrint from "../../components/selectAndPrintDialog";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
useSpoolmanMaterials,
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import { useCurrency } from "../../utils/settings";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model";
dayjs.extend(utc);
@@ -56,7 +57,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
} else {
filament_name = element.filament.name ?? element.filament.id.toString();
}
if (!element.price) {
if (element.price === undefined) {
element.price = element.filament.price;
}
return {
@@ -102,6 +103,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal();
const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
@@ -157,7 +159,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
});
// Create state for the columns to show
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? defaultColumns);
const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? defaultColumns);
// Store state in local storage
const tableState: TableState = {
@@ -169,7 +171,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list
const queryDataSource: ISpoolCollapsed[] = React.useMemo(
const queryDataSource: ISpoolCollapsed[] = useMemo(
() => (tableProps.dataSource || []).map((record) => ({ ...record })),
[tableProps.dataSource]
);
@@ -208,23 +210,27 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}
const { editUrl, showUrl, cloneUrl } = useNavigation();
const actions = (record: ISpoolCollapsed) => {
const actions: Action[] = [
{ name: t("buttons.show"), icon: <EyeOutlined />, link: showUrl("spool", record.id) },
{ name: t("buttons.edit"), icon: <EditOutlined />, link: editUrl("spool", record.id) },
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("spool", record.id) },
];
if (record.archived) {
actions.push({
name: t("buttons.unArchive"),
icon: <ToTopOutlined />,
onClick: () => archiveSpool(record, false),
});
} else {
actions.push({ name: t("buttons.archive"), icon: <InboxOutlined />, onClick: () => archiveSpoolPopup(record) });
}
return actions;
};
const actions = useCallback(
(record: ISpoolCollapsed) => {
const actions: Action[] = [
{ name: t("buttons.show"), icon: <EyeOutlined />, link: showUrl("spool", record.id) },
{ name: t("buttons.edit"), icon: <EditOutlined />, link: editUrl("spool", record.id) },
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("spool", record.id) },
{ name: t("spool.titles.adjust"), icon: <ToolOutlined />, onClick: () => openSpoolAdjustModal(record) },
];
if (record.archived) {
actions.push({
name: t("buttons.unArchive"),
icon: <ToTopOutlined />,
onClick: () => archiveSpool(record, false),
});
} else {
actions.push({ name: t("buttons.archive"), icon: <InboxOutlined />, onClick: () => archiveSpoolPopup(record) });
}
return actions;
},
[t, editUrl, showUrl, cloneUrl, openSpoolAdjustModal, archiveSpool, archiveSpoolPopup]
);
const originalOnChange = tableProps.onChange;
tableProps.onChange = (pagination, filters, sorter, extra) => {
@@ -254,7 +260,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
<List
headerButtons={({ defaultButtons }) => (
<>
<SelectAndPrint />
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={() => {
navigate("print");
}}
>
{t("printing.qrcode.button")}
</Button>
<Button
type="primary"
icon={<InboxOutlined />}
@@ -311,6 +325,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
</>
)}
>
{spoolAdjustModal}
<Table
{...tableProps}
sticky
@@ -342,7 +357,13 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "filament.combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex,
color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex,
dataId: "filament.combined_name",
filterValueQuery: useSpoolmanFilamentFilter(),
}),
@@ -357,6 +378,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "price",
i18ncat: "spool",
align: "right",
width: 80,
render: (_, obj: ISpoolCollapsed) => {
return obj.price?.toLocaleString(undefined, {
@@ -371,8 +393,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "used_weight",
i18ncat: "spool",
align: "right",
unit: "g",
maxDecimals: 1,
maxDecimals: 0,
width: 110,
}),
NumberColumn({
@@ -380,7 +403,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "remaining_weight",
i18ncat: "spool",
unit: "g",
maxDecimals: 1,
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
}),
@@ -389,7 +412,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "used_length",
i18ncat: "spool",
unit: "mm",
maxDecimals: 1,
maxDecimals: 0,
width: 120,
}),
NumberColumn({
@@ -397,7 +420,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "remaining_length",
i18ncat: "spool",
unit: "mm",
maxDecimals: 1,
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
}),
@@ -442,7 +465,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
i18ncat: "spool",
width: 150,
}),
ActionsColumn(actions),
ActionsColumn(t("table.actions"), actions),
])}
/>
</List>

View File

@@ -1,5 +1,11 @@
import { IFilament } from "../filaments/model";
export enum WeightToEnter {
used_weight = 1,
remaining_weight = 2,
measured_weight = 3,
}
export interface ISpool {
id: number;
registered: string;
@@ -7,6 +13,8 @@ export interface ISpool {
last_used?: string;
filament: IFilament;
price?: number;
initial_weight?: number;
spool_weight?: number;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;

View File

@@ -1,16 +1,18 @@
import React from "react";
import { PrinterOutlined } from "@ant-design/icons";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
import { Show, NumberField, DateField, TextField } from "@refinedev/antd";
import { Typography } from "antd";
import { NumberFieldUnit } from "../../components/numberField";
import { Button, Typography } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { ISpool } from "./model";
import { enrichText } from "../../utils/parsing";
import { IFilament } from "../filaments/model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import React from "react";
import { ExtraFieldDisplay } from "../../components/extraFields";
import { NumberFieldUnit } from "../../components/numberField";
import SpoolIcon from "../../components/spoolIcon";
import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrency } from "../../utils/settings";
import { IFilament } from "../filaments/model";
import { ISpool } from "./model";
dayjs.extend(utc);
@@ -30,7 +32,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const spoolPrice = (item: ISpool) => {
let spoolPrice = "";
if (!item.price) {
if (item.price === undefined) {
spoolPrice = `${item.filament.price}`;
return spoolPrice;
}
@@ -66,12 +68,34 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
});
};
const colorObj = record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record?.filament.color_hex;
return (
<Show isLoading={isLoading} title={record ? formatTitle(record) : ""}>
<Show
isLoading={isLoading}
title={record ? formatTitle(record) : ""}
headerButtons={({ defaultButtons }) => (
<>
<Button
type="primary"
icon={<PrinterOutlined />}
href={"/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
>
{t("printing.qrcode.button")}
</Button>
{defaultButtons}
</>
)}
>
<Title level={5}>{t("spool.fields.id")}</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>{t("spool.fields.filament")}</Title>
<TextField value={record ? filamentURL(record?.filament) : ""} />
{colorObj && <SpoolIcon color={colorObj} />} <TextField value={record ? filamentURL(record?.filament) : ""} />
<Title level={5}>{t("spool.fields.price")}</Title>
<NumberField
value={record ? spoolPrice(record) : ""}

View File

@@ -1,13 +1,13 @@
import React from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
import { Button, Form, Input, Typography } from "antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Button, Form, Input, InputNumber, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { IVendor, IVendorParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { IVendor, IVendorParsedExtras } from "./model";
dayjs.extend(utc);
@@ -30,6 +30,11 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
formProps.initialValues = {};
}
if (props.mode === "clone") {
// Parse the extra fields from string values into real types
formProps.initialValues = ParsedExtras(formProps.initialValues);
}
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const values = StringifiedExtras(await form.validateFields());
await onFinish(values);
@@ -38,7 +43,7 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
@@ -85,6 +90,20 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
>
<TextArea maxLength={1024} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
help={t("vendor.fields_help.empty_spool_weight")}
name={["empty_spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />

View File

@@ -1,13 +1,12 @@
import React, { useState } from "react";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { IVendor, IVendorParsedExtras } from "./model";
import dayjs from "dayjs";
import React, { useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
import { IVendor, IVendorParsedExtras } from "./model";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
@@ -100,6 +99,20 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
>
<TextArea maxLength={1024} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
help={t("vendor.fields_help.empty_spool_weight")}
name={["empty_spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />

48
client/src/pages/vendors/functions.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
import { getAPIURL } from "../../utils/url";
import { IVendor } from "./model";
/**
* Get a vendor given its external ID.
*/
export async function getVendorByExternalID(external_id: string): Promise<IVendor | null> {
// Make a search using GET and query params
const response = await fetch(`${getAPIURL()}/vendor?${new URLSearchParams({ external_id })}`);
if (!response.ok) {
return null;
}
const data: IVendor[] = await response.json();
if (data.length === 0) {
return null;
}
return data[0];
}
/**
* Create a new internal filament given an external filament object.
* Returns the created internal filament.
*/
export async function getOrCreateVendorFromExternal(vendor_external_id: string): Promise<IVendor> {
const existingVendor = await getVendorByExternalID(vendor_external_id);
if (existingVendor) {
return existingVendor;
}
const body: Omit<IVendor, "id" | "registered" | "extra"> = {
name: vendor_external_id,
external_id: vendor_external_id,
};
const response = await fetch(getAPIURL() + "/vendor", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
}

View File

@@ -1,23 +1,30 @@
import React from "react";
import { IResourceComponentsProps, useTranslate, useInvalidate, useNavigation } from "@refinedev/core";
import { useTable, List } from "@refinedev/antd";
import { Table, Button, Dropdown } from "antd";
import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IVendor } from "./model";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { DateColumn, RichColumn, SortedColumn, ActionsColumn, CustomFieldColumn } from "../../components/column";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionsColumn,
CustomFieldColumn,
DateColumn,
NumberColumn,
RichColumn,
SortedColumn,
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { IVendor } from "./model";
dayjs.extend(utc);
const namespace = "vendorList-v2";
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"];
const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment", "empty_spool_weight"];
export const VendorList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
@@ -59,7 +66,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
});
// Create state for the columns to show
const [showColumns, setShowColumns] = React.useState<string[]>(initialState.showColumns ?? allColumns);
const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? allColumns);
// Store state in local storage
const tableState: TableState = {
@@ -71,11 +78,14 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
useStoreInitialState(namespace, tableState);
// Collapse the dataSource to a mutable list
const queryDataSource: IVendor[] = React.useMemo(
() => (tableProps.dataSource || []).map((record) => ({ ...record })),
[tableProps.dataSource]
const queryDataSource: IVendor[] = useMemo(() => {
return (tableProps.dataSource || []).map((record) => ({ ...record }));
}, [tableProps.dataSource]);
const dataSource = useLiveify(
"vendor",
queryDataSource,
useCallback((record: IVendor) => record, [])
);
const dataSource = useLiveify("vendor", queryDataSource, (record: IVendor) => record);
if (tableProps.pagination) {
tableProps.pagination.showSizeChanger = true;
@@ -171,6 +181,15 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
...commonProps,
id: "registered",
i18ncat: "vendor",
width: 200,
}),
NumberColumn({
...commonProps,
id: "empty_spool_weight",
i18ncat: "vendor",
unit: "g",
maxDecimals: 0,
width: 200,
}),
...(extraFields.data?.map((field) => {
return CustomFieldColumn({
@@ -183,7 +202,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
id: "comment",
i18ncat: "vendor",
}),
ActionsColumn<IVendor>(actions),
ActionsColumn<IVendor>(t("table.actions"), actions),
])}
/>
</List>

View File

@@ -3,6 +3,8 @@ export interface IVendor {
registered: string;
name: string;
comment?: string;
empty_spool_weight?: number;
external_id?: string;
extra: { [key: string]: string };
}

View File

@@ -1,13 +1,13 @@
import React from "react";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
import { Show, NumberField, DateField, TextField } from "@refinedev/antd";
import { Typography } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { IVendor } from "./model";
import React from "react";
import { ExtraFieldDisplay } from "../../components/extraFields";
import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldDisplay } from "../../components/extraFields";
import { IVendor } from "./model";
dayjs.extend(utc);
@@ -42,6 +42,10 @@ export const VendorShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={record?.name} />
<Title level={5}>{t("vendor.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} />
<Title level={5}>{t("vendor.fields.empty_spool_weight")}</Title>
<TextField value={record?.empty_spool_weight} />
<Title level={5}>{t("vendor.fields.external_id")}</Title>
<TextField value={record?.external_id} />
<Title level={4}>{t("settings.extra_fields.tab")}</Title>
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />