Merge remote-tracking branch 'upstream/master'
This commit is contained in:
@@ -5,11 +5,6 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ErrorComponent } from "@refinedev/antd";
|
||||
import "@refinedev/antd/dist/reset.css";
|
||||
|
||||
import routerBindings, { DocumentTitleHandler, UnsavedChangesNotifier } from "@refinedev/react-router-v6";
|
||||
import dataProvider from "./components/dataProvider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
|
||||
import { ColorModeContextProvider } from "./contexts/color-mode";
|
||||
import {
|
||||
FileOutlined,
|
||||
HighlightOutlined,
|
||||
@@ -18,14 +13,21 @@ import {
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ConfigProvider } from "antd";
|
||||
import React from "react";
|
||||
import { Locale } from "antd/es/locale";
|
||||
import { languages } from "./i18n";
|
||||
import loadable from "@loadable/component";
|
||||
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
||||
import routerBindings, { DocumentTitleHandler, UnsavedChangesNotifier } from "@refinedev/react-router-v6";
|
||||
import { ConfigProvider } from "antd";
|
||||
import { Locale } from "antd/es/locale";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
|
||||
import dataProvider from "./components/dataProvider";
|
||||
import { Favicon } from "./components/favicon";
|
||||
import { SpoolmanLayout } from "./components/layout";
|
||||
import liveProvider from "./components/liveProvider";
|
||||
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
||||
import { ColorModeContextProvider } from "./contexts/color-mode";
|
||||
import { languages } from "./i18n";
|
||||
import { getAPIURL, getBasePath } from "./utils/url";
|
||||
|
||||
interface ResourcePageProps {
|
||||
resource: "spools" | "filaments" | "vendors";
|
||||
@@ -60,8 +62,8 @@ function App() {
|
||||
};
|
||||
|
||||
// Fetch the antd locale using dynamic imports
|
||||
const [antdLocale, setAntdLocale] = React.useState<Locale | undefined>();
|
||||
React.useEffect(() => {
|
||||
const [antdLocale, setAntdLocale] = useState<Locale | undefined>();
|
||||
useEffect(() => {
|
||||
const fetchLocale = async () => {
|
||||
const locale = await import(
|
||||
`./../node_modules/antd/es/locale/${languages[i18n.language].fullCode.replace("-", "_")}.js`
|
||||
@@ -84,7 +86,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={getBasePath() + "/"}>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider>
|
||||
<ConfigProvider
|
||||
@@ -96,11 +98,11 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<Refine
|
||||
dataProvider={dataProvider(import.meta.env.VITE_APIURL)}
|
||||
dataProvider={dataProvider(getAPIURL())}
|
||||
notificationProvider={SpoolmanNotificationProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
routerProvider={routerBindings}
|
||||
liveProvider={liveProvider(import.meta.env.VITE_APIURL)}
|
||||
liveProvider={liveProvider(getAPIURL())}
|
||||
resources={[
|
||||
{
|
||||
name: "home",
|
||||
@@ -190,6 +192,7 @@ function App() {
|
||||
/>
|
||||
<Route path="edit/:id" element={<LoadableResourcePage resource="spools" page="edit" />} />
|
||||
<Route path="show/:id" element={<LoadableResourcePage resource="spools" page="show" />} />
|
||||
<Route path="print" element={<LoadablePage name="printing" />} />
|
||||
</Route>
|
||||
<Route path="/filament">
|
||||
<Route index element={<LoadableResourcePage resource="filaments" page="list" />} />
|
||||
@@ -227,6 +230,7 @@ function App() {
|
||||
<UnsavedChangesNotifier />
|
||||
<DocumentTitleHandler />
|
||||
<ReactQueryDevtools />
|
||||
<Favicon url={getBasePath() + "/favicon.svg"} />
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
</ColorModeContextProvider>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { DateField, TextField } from "@refinedev/antd";
|
||||
import { UseQueryResult } from "@tanstack/react-query";
|
||||
import { Button, Col, Dropdown, Row, Space, Spin } from "antd";
|
||||
import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { AlignType } from "rc-table/lib/interface";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getFiltersForField, typeFilters } from "../utils/filtering";
|
||||
import { enrichText } from "../utils/parsing";
|
||||
import { Field, FieldType } from "../utils/queryFields";
|
||||
import { TableState } from "../utils/saveload";
|
||||
import { getSortOrderForField, typeSorters } from "../utils/sorting";
|
||||
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { DateField, TextField } from "@refinedev/antd";
|
||||
import Icon from "@ant-design/icons";
|
||||
import SpoolIcon from "../icon_spool.svg?react";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { enrichText } from "../utils/parsing";
|
||||
import { UseQueryResult } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Field, FieldType } from "../utils/queryFields";
|
||||
import SpoolIcon from "./spoolIcon";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -45,6 +44,7 @@ interface BaseColumnProps<Obj extends Entity> {
|
||||
i18ncat?: string;
|
||||
i18nkey?: string;
|
||||
title?: string;
|
||||
align?: AlignType;
|
||||
sorter?: boolean;
|
||||
t: (key: string) => string;
|
||||
navigate: (link: string) => void;
|
||||
@@ -88,6 +88,7 @@ function Column<Obj extends Entity>(
|
||||
|
||||
const columnProps: ColumnType<Obj> = {
|
||||
dataIndex: props.id,
|
||||
align: props.align,
|
||||
title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
|
||||
filterMultiple: props.allowMultipleFilters ?? true,
|
||||
width: props.width ?? undefined,
|
||||
@@ -196,7 +197,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
|
||||
if (typeof item === "string") {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
value: '"' + item + '"',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
@@ -227,6 +228,7 @@ interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||
export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
|
||||
return Column({
|
||||
...props,
|
||||
align: "right",
|
||||
render: (rawValue) => {
|
||||
const value = props.transform ? props.transform(rawValue) : rawValue;
|
||||
if (value === null || value === undefined) {
|
||||
@@ -256,18 +258,19 @@ export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
|
||||
hidden={!value}
|
||||
value={dayjs.utc(value).local()}
|
||||
title={dayjs.utc(value).local().format()}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ActionsColumn<Obj extends Entity>(actionsFn: (record: Obj) => Action[]): ColumnType<Obj> | undefined {
|
||||
const t = useTranslate();
|
||||
|
||||
export function ActionsColumn<Obj extends Entity>(
|
||||
title: string,
|
||||
actionsFn: (record: Obj) => Action[]
|
||||
): ColumnType<Obj> | undefined {
|
||||
return {
|
||||
title: t("table.actions"),
|
||||
title,
|
||||
responsive: ["lg"],
|
||||
render: (_, record) => {
|
||||
const buttons = actionsFn(record).map((action) => {
|
||||
@@ -296,7 +299,7 @@ export function ActionsColumn<Obj extends Entity>(actionsFn: (record: Obj) => Ac
|
||||
}
|
||||
|
||||
interface SpoolIconColumnProps<Obj extends Entity> extends FilteredQueryColumnProps<Obj> {
|
||||
color: (record: Obj) => string | undefined;
|
||||
color: (record: Obj) => string | { colors: string[]; vertical: boolean } | undefined;
|
||||
}
|
||||
|
||||
export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<Obj>) {
|
||||
@@ -308,7 +311,7 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
|
||||
if (typeof item === "string") {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
value: '"' + item + '"',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
@@ -343,19 +346,12 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
|
||||
},
|
||||
render: (rawValue, record: Obj) => {
|
||||
const value = props.transform ? props.transform(rawValue) : rawValue;
|
||||
const colorStr = props.color(record);
|
||||
const colorObj = props.color(record);
|
||||
return (
|
||||
<Row wrap={false} justify="space-around" align="middle">
|
||||
{colorStr && (
|
||||
{colorObj && (
|
||||
<Col flex="none">
|
||||
<Icon
|
||||
component={SpoolIcon}
|
||||
style={{
|
||||
color: "#" + colorStr,
|
||||
fontSize: 42,
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
<SpoolIcon color={colorObj} />
|
||||
</Col>
|
||||
)}
|
||||
<Col flex="auto">{value}</Col>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { stringify } from "query-string";
|
||||
import { DataProvider } from "@refinedev/core";
|
||||
import { axiosInstance } from "@refinedev/simple-rest";
|
||||
import { AxiosInstance } from "axios";
|
||||
import { stringify } from "query-string";
|
||||
|
||||
type MethodTypes = "get" | "delete" | "head" | "options";
|
||||
type MethodTypesWithBody = "post" | "put" | "patch";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DatePicker } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DateField, TextField } from "@refinedev/antd";
|
||||
import { Field, FieldType } from "../utils/queryFields";
|
||||
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
|
||||
import { enrichText } from "../utils/parsing";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { Checkbox, Form, Input, InputNumber, Select, Typography } from "antd";
|
||||
import { FormItemProps, Rule } from "antd/es/form";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { enrichText } from "../utils/parsing";
|
||||
import { Field, FieldType } from "../utils/queryFields";
|
||||
import { DateTimePicker } from "./dateTimePicker";
|
||||
import { InputNumberRange } from "./inputNumberRange";
|
||||
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
|
||||
20
client/src/components/favicon.tsx
Normal file
20
client/src/components/favicon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Renders a favicon element in the head of the HTML document with the specified URL.
|
||||
*
|
||||
* @param {string} props.url - The URL of the favicon image.
|
||||
* @return {JSX.Element} - An empty JSX element.
|
||||
*/
|
||||
export function Favicon(props: { url: string }) {
|
||||
useEffect(() => {
|
||||
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!link) {
|
||||
link = document.createElement("link") as HTMLLinkElement;
|
||||
link.rel = "icon";
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
}
|
||||
link.href = props.url;
|
||||
}, [props.url]);
|
||||
return <></>;
|
||||
}
|
||||
72
client/src/components/filamentImportModal.tsx
Normal file
72
client/src/components/filamentImportModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Form, Modal, Select } from "antd";
|
||||
import { Trans } from "react-i18next";
|
||||
import { formatFilamentLabel } from "../pages/spools/functions";
|
||||
import { searchMatches } from "../utils/filtering";
|
||||
import { ExternalFilament, useGetExternalDBFilaments } from "../utils/queryExternalDB";
|
||||
|
||||
export function FilamentImportModal(props: {
|
||||
isOpen: boolean;
|
||||
onImport: (filament: ExternalFilament) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const t = useTranslate();
|
||||
|
||||
const externalFilaments = useGetExternalDBFilaments();
|
||||
const filamentOptions =
|
||||
externalFilaments.data?.map((item) => {
|
||||
return {
|
||||
label: formatFilamentLabel(
|
||||
item.name,
|
||||
item.diameter,
|
||||
item.manufacturer,
|
||||
item.material,
|
||||
item.weight,
|
||||
item.spool_type
|
||||
),
|
||||
value: item.id,
|
||||
item: item,
|
||||
};
|
||||
}) ?? [];
|
||||
filamentOptions.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("filament.form.import_external")}
|
||||
open={props.isOpen}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => props.onClose()}
|
||||
>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onFinish={(values) => {
|
||||
const filament = filamentOptions.find((item) => item.value === values.filament)?.item;
|
||||
if (!filament) {
|
||||
throw new Error("Filament not found");
|
||||
}
|
||||
props.onImport(filament);
|
||||
props.onClose();
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={"filament.form.import_external_description"}
|
||||
components={{
|
||||
br: <br />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Form.Item name="filament" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={filamentOptions}
|
||||
showSearch
|
||||
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import type { RefineThemedLayoutV2HeaderProps } from "@refinedev/antd";
|
||||
import { useGetLocale, useSetLocale } from "@refinedev/core";
|
||||
import { Button, Dropdown, Layout as AntdLayout, MenuProps, Space, Switch, theme } from "antd";
|
||||
import { Layout as AntdLayout, Button, Dropdown, MenuProps, Space, Switch, theme } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { ColorModeContext } from "../../contexts/color-mode";
|
||||
|
||||
import "/node_modules/flag-icons/css/flag-icons.min.css";
|
||||
import { languages } from "../../i18n";
|
||||
import QRCodeScannerModal from "../qrCodeScanner";
|
||||
import "/node_modules/flag-icons/css/flag-icons.min.css";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd";
|
||||
import { Header } from "./header";
|
||||
import { Footer } from "antd/es/layout/layout";
|
||||
import { Version } from "./version";
|
||||
import { Button } from "antd";
|
||||
import Logo from "../icon.svg?react";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Button } from "antd";
|
||||
import { Footer } from "antd/es/layout/layout";
|
||||
import Logo from "../icon.svg?react";
|
||||
import { getBasePath } from "../utils/url";
|
||||
import { Header } from "./header";
|
||||
import { Version } from "./version";
|
||||
|
||||
const SpoolmanFooter: React.FC = () => {
|
||||
const t = useTranslate();
|
||||
@@ -27,7 +28,7 @@ const SpoolmanFooter: React.FC = () => {
|
||||
<Button
|
||||
icon={
|
||||
<img
|
||||
src="/kofi_s_logo_nolabel.png"
|
||||
src={getBasePath() + "/kofi_s_logo_nolabel.png"}
|
||||
style={{
|
||||
height: "1.6em",
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { LiveEvent } from "@refinedev/core";
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
import liveProvider from "./liveProvider";
|
||||
|
||||
const liveProviderInstance = liveProvider(import.meta.env.VITE_APIURL);
|
||||
const liveProviderInstance = liveProvider(getAPIURL());
|
||||
|
||||
/**
|
||||
* Hook that subscribes to live updates for the items in the dataSource
|
||||
@@ -15,29 +16,23 @@ export function useLiveify<Data extends { id: number }>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformPayload: (payload: any) => Data
|
||||
) {
|
||||
// TODO: The hooks in this function is quite janky, and should be refactored to be more efficient
|
||||
// New state that holds the dataSource with updated values from the live provider
|
||||
const [updatedDataSource, setUpdatedDataSource] = React.useState<Data[]>(dataSource);
|
||||
const [updatedDataSource, setUpdatedDataSource] = useState<Data[]>(dataSource);
|
||||
|
||||
// If the original dataSource changes, update the updatedDataSource
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setUpdatedDataSource(dataSource);
|
||||
}, [dataSource]);
|
||||
|
||||
// Create a constant reference to itemIds. This is to prevent the useEffect below from triggering extra times.
|
||||
const itemIds = dataSource.map((item) => item.id);
|
||||
const [prevItemIds, setPrevItemIds] = React.useState<number[]>(itemIds);
|
||||
if (JSON.stringify(itemIds) !== JSON.stringify(prevItemIds)) {
|
||||
setPrevItemIds(itemIds);
|
||||
}
|
||||
|
||||
// Subscribe to changes for all items in the dataSource
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const itemIds = dataSource.map((item) => item.id);
|
||||
|
||||
const subscription = liveProviderInstance?.subscribe({
|
||||
channel: `${resource}-list`,
|
||||
params: {
|
||||
resource: resource,
|
||||
ids: prevItemIds,
|
||||
ids: itemIds,
|
||||
subscriptionType: "useList",
|
||||
},
|
||||
types: ["update"],
|
||||
@@ -56,7 +51,7 @@ export function useLiveify<Data extends { id: number }>(
|
||||
liveProviderInstance?.unsubscribe(subscription);
|
||||
}
|
||||
};
|
||||
}, [resource, prevItemIds, transformPayload]);
|
||||
}, [resource, dataSource, transformPayload]);
|
||||
|
||||
return updatedDataSource;
|
||||
}
|
||||
|
||||
83
client/src/components/multiColorPicker.tsx
Normal file
83
client/src/components/multiColorPicker.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { CloseOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Badge, Button, ColorPicker, Space } from "antd";
|
||||
|
||||
function generateRandomColor() {
|
||||
return "000000".replace(/0/g, function () {
|
||||
return (~~(Math.random() * 16)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function generateInitialColors(num: number) {
|
||||
const colors = [];
|
||||
for (let i = 0; i < num; i++) {
|
||||
colors.push(generateRandomColor());
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* An Ant Design compatible form input for multiple color pickers
|
||||
* The value is a comma separated list of hex values, without hashtags
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export function MultiColorPicker(props: {
|
||||
value?: string | null | undefined;
|
||||
onChange?: (value: string | null | undefined) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
const values = props.value ? props.value.split(",") : generateInitialColors(props.min ?? 0);
|
||||
if (!props.value && props.onChange) {
|
||||
// Update value immediately
|
||||
props.onChange(values.join(","));
|
||||
}
|
||||
const pickers = values.map((value, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
count={
|
||||
values.length > (props.min ?? 0) ? (
|
||||
<span className="ant-badge-count">
|
||||
<CloseOutlined
|
||||
onClick={() => {
|
||||
// Remove this picker
|
||||
if (props.onChange) {
|
||||
props.onChange(values.filter((v, i) => i !== idx).join(","));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ColorPicker
|
||||
value={value}
|
||||
onChange={(clr) => {
|
||||
if (props.onChange) {
|
||||
props.onChange(values.map((v, i) => (i === idx ? clr.toHex() : v)).join(","));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space direction="horizontal" size="middle" style={{ marginTop: "1em" }}>
|
||||
{pickers}
|
||||
{values.length < (props.max ?? Infinity) && (
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
if (props.onChange) {
|
||||
props.onChange(values.concat(generateRandomColor()).join(","));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,40 @@
|
||||
import { NotificationProvider } from "@refinedev/core";
|
||||
import { message } from "antd";
|
||||
import { UndoOutlined } from "@ant-design/icons";
|
||||
import { NotificationProvider, OpenNotificationParams } from "@refinedev/core";
|
||||
import { Button, Progress, message } from "antd";
|
||||
|
||||
import { UndoableNotification } from "@refinedev/antd/src/components/undoableNotification";
|
||||
type UndoableNotificationProps = {
|
||||
notificationKey: OpenNotificationParams["key"];
|
||||
message: OpenNotificationParams["message"];
|
||||
cancelMutation: OpenNotificationParams["cancelMutation"];
|
||||
undoableTimeout: OpenNotificationParams["undoableTimeout"];
|
||||
};
|
||||
|
||||
const UndoableNotification: React.FC<UndoableNotificationProps> = ({ message, cancelMutation, undoableTimeout }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "-7px",
|
||||
}}
|
||||
>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={(undoableTimeout ?? 0) * 20}
|
||||
format={(time) => time && time / 20}
|
||||
size={50}
|
||||
strokeColor="#1890ff"
|
||||
status="normal"
|
||||
/>
|
||||
<span style={{ marginLeft: 8, width: "100%" }}>{message}</span>
|
||||
<Button
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={cancelMutation}
|
||||
disabled={undoableTimeout === 0}
|
||||
icon={<UndoOutlined />}
|
||||
></Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SpoolmanNotificationProvider: NotificationProvider = {
|
||||
open: ({ key, message: content, type, cancelMutation, undoableTimeout }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Typography } from "antd";
|
||||
import { NumberFieldProps } from "@refinedev/antd/dist/components/fields/types";
|
||||
import { Typography } from "antd";
|
||||
import React from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Tooltip } from "antd";
|
||||
import { ColumnFilterItem } from "antd/es/table/interface";
|
||||
import { IFilament } from "../pages/filaments/model";
|
||||
import { IVendor } from "../pages/vendors/model";
|
||||
import { ColumnFilterItem } from "antd/es/table/interface";
|
||||
import { Tooltip } from "antd";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
|
||||
export function useSpoolmanFilamentFilter(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<IFilament[], unknown, ColumnFilterItem[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["filaments"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/filament");
|
||||
const response = await fetch(getAPIURL() + "/filament");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -86,12 +86,11 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanFilamentNames(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<IFilament[], unknown, string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["filaments"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/filament");
|
||||
const response = await fetch(getAPIURL() + "/filament");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -115,12 +114,11 @@ export function useSpoolmanFilamentNames(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanVendors(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<IVendor[], unknown, string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["vendors"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/vendor");
|
||||
const response = await fetch(getAPIURL() + "/vendor");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -137,12 +135,11 @@ export function useSpoolmanVendors(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanMaterials(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["materials"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/material");
|
||||
const response = await fetch(getAPIURL() + "/material");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -155,12 +152,11 @@ export function useSpoolmanMaterials(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanArticleNumbers(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["articleNumbers"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/article-number");
|
||||
const response = await fetch(getAPIURL() + "/article-number");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -173,12 +169,11 @@ export function useSpoolmanArticleNumbers(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanLotNumbers(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["lotNumbers"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/lot-number");
|
||||
const response = await fetch(getAPIURL() + "/lot-number");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
@@ -191,12 +186,11 @@ export function useSpoolmanLotNumbers(enabled: boolean = false) {
|
||||
}
|
||||
|
||||
export function useSpoolmanLocations(enabled: boolean = false) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<string[]>({
|
||||
enabled: enabled,
|
||||
queryKey: ["locations"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/location");
|
||||
const response = await fetch(getAPIURL() + "/location");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Col, Form, InputNumber, QRCode, Row, Slider, Switch } from "antd";
|
||||
import { useSavedState } from "../../utils/saveload";
|
||||
import PrintingDialog from "./printingDialog";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
|
||||
interface QRCodeData {
|
||||
value: string;
|
||||
label?: JSX.Element;
|
||||
errorLevel?: "L" | "M" | "Q" | "H";
|
||||
}
|
||||
|
||||
interface QRCodePrintingDialogProps {
|
||||
visible: boolean;
|
||||
items: QRCodeData[];
|
||||
onCancel: () => void;
|
||||
extraSettings?: JSX.Element;
|
||||
}
|
||||
|
||||
const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({ visible, items, onCancel, extraSettings }) => {
|
||||
const t = useTranslate();
|
||||
|
||||
const [showContent, setShowContent] = useSavedState("print-showContent", true);
|
||||
const [textSize, setTextSize] = useSavedState("print-textSize", 3);
|
||||
const [showSpoolmanIcon, setShowSpoolmanIcon] = useSavedState("print-showSpoolmanIcon", true);
|
||||
|
||||
const elements = items.map((item) => {
|
||||
return (
|
||||
<div className="print-qrcode-item">
|
||||
<div className="print-qrcode-container">
|
||||
<QRCode
|
||||
className="print-qrcode"
|
||||
icon={showSpoolmanIcon ? "/favicon.svg" : undefined}
|
||||
value={item.value}
|
||||
errorLevel={item.errorLevel}
|
||||
type="svg"
|
||||
color="#000"
|
||||
/>
|
||||
</div>
|
||||
{showContent && <div className="print-qrcode-title">{item.label ?? item.value}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<PrintingDialog
|
||||
visible={visible}
|
||||
title={t("printing.qrcode.title")}
|
||||
items={elements}
|
||||
extraSettings={
|
||||
<>
|
||||
<Form.Item label={t("printing.qrcode.showContent")}>
|
||||
<Switch checked={showContent} onChange={(checked) => setShowContent(checked)} />
|
||||
</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) => {
|
||||
setTextSize(value);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<InputNumber
|
||||
disabled={!showContent}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={textSize}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setTextSize(value ?? 5);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showSpoolmanIcon")}>
|
||||
<Switch checked={showSpoolmanIcon} onChange={(checked) => setShowSpoolmanIcon(checked)} />
|
||||
</Form.Item>
|
||||
{extraSettings}
|
||||
</>
|
||||
}
|
||||
style={`
|
||||
.print-page .print-qrcode-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
`}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCodePrintingDialog;
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Form, Switch } from "antd";
|
||||
import { IFilament } from "../../pages/filaments/model";
|
||||
import { ISpool } from "../../pages/spools/model";
|
||||
import QRCodePrintingDialog from "./qrCodePrintingDialog";
|
||||
import { useSavedState } from "../../utils/saveload";
|
||||
import { useGetSetting } from "../../utils/querySettings";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
|
||||
interface SpoolQRCodePrintingDialog {
|
||||
visible: boolean;
|
||||
items: ISpool[];
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SpoolQRCodePrintingDialog: React.FC<SpoolQRCodePrintingDialog> = ({ visible, items, onCancel }) => {
|
||||
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 [showVendor, setShowVendor] = useSavedState("print-showVendor", true);
|
||||
const [showLotNr, setShowLotNr] = useSavedState("print-showLotNr", true);
|
||||
const [showSpoolWeight, setShowSpoolWeight] = useSavedState("print-showSpoolWeight", true);
|
||||
const [showTemperatures, setShowTemperatures] = useSavedState("print-showTemperatures", true);
|
||||
const [showSpoolComment, setShowSpoolComment] = useSavedState("print-showSpoolComment", false);
|
||||
const [showFilamentComment, setShowFilamentComment] = useSavedState("print-showFilamentComment", false);
|
||||
const [showVendorComment, setShowVendorComment] = useSavedState("print-showVendorComment", false);
|
||||
const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", baseUrlSetting.data?.value !== "");
|
||||
|
||||
const formatFilament = (filament: IFilament) => {
|
||||
let vendorPrefix = "";
|
||||
if (showVendor && filament.vendor) {
|
||||
vendorPrefix = `${filament.vendor.name} - `;
|
||||
}
|
||||
let name = filament.name;
|
||||
if (!name) {
|
||||
name = `Filament #${filament.id}`;
|
||||
}
|
||||
return `${vendorPrefix}${name}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<QRCodePrintingDialog
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
items={items.map(function (spool) {
|
||||
const temps = [];
|
||||
if (spool.filament.settings_extruder_temp) {
|
||||
temps.push(t("printing.qrcode.extruderTemp", { temp: `${spool.filament.settings_extruder_temp} °C` }));
|
||||
}
|
||||
if (spool.filament.settings_bed_temp) {
|
||||
temps.push(t("printing.qrcode.bedTemp", { temp: `${spool.filament.settings_bed_temp} °C` }));
|
||||
}
|
||||
const tempLine = temps.join(" - ");
|
||||
|
||||
return {
|
||||
value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `web+spoolman:s-${spool.id}`,
|
||||
label: (
|
||||
<p
|
||||
style={{
|
||||
padding: "1mm 1mm 1mm 0",
|
||||
}}
|
||||
>
|
||||
<b>{formatFilament(spool.filament)}</b>
|
||||
<br />
|
||||
<b>
|
||||
#{spool.id}
|
||||
{spool.filament.material && <> - {spool.filament.material}</>}
|
||||
</b>
|
||||
{showSpoolWeight && (
|
||||
<>
|
||||
<br />
|
||||
{t("printing.qrcode.spoolWeight", { weight: `${spool.filament.spool_weight ?? "?"} g` })}
|
||||
</>
|
||||
)}
|
||||
{showTemperatures && tempLine && (
|
||||
<>
|
||||
<br />
|
||||
{tempLine}
|
||||
</>
|
||||
)}
|
||||
{showLotNr && (
|
||||
<>
|
||||
<br />
|
||||
{t("printing.qrcode.lotNr", { lot: spool.lot_nr ?? "?" })}
|
||||
</>
|
||||
)}
|
||||
{showSpoolComment && spool.comment && (
|
||||
<>
|
||||
<br />
|
||||
{spool.comment}
|
||||
</>
|
||||
)}
|
||||
{showFilamentComment && spool.filament.comment && (
|
||||
<>
|
||||
<br />
|
||||
{spool.filament.comment}
|
||||
</>
|
||||
)}
|
||||
{showVendorComment && spool.filament.vendor?.comment && (
|
||||
<>
|
||||
<br />
|
||||
{spool.filament.vendor.comment}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
),
|
||||
errorLevel: "H",
|
||||
};
|
||||
})}
|
||||
extraSettings={
|
||||
<>
|
||||
<Form.Item label={t("printing.qrcode.showVendor")}>
|
||||
<Switch checked={showVendor} onChange={(checked) => setShowVendor(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showSpoolWeight")}>
|
||||
<Switch checked={showSpoolWeight} onChange={(checked) => setShowSpoolWeight(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showTemperatures")}>
|
||||
<Switch checked={showTemperatures} onChange={(checked) => setShowTemperatures(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showLotNr")}>
|
||||
<Switch checked={showLotNr} onChange={(checked) => setShowLotNr(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showSpoolComment")}>
|
||||
<Switch checked={showSpoolComment} onChange={(checked) => setShowSpoolComment(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showFilamentComment")}>
|
||||
<Switch checked={showFilamentComment} onChange={(checked) => setShowFilamentComment(checked)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.showVendorComment")}>
|
||||
<Switch checked={showVendorComment} onChange={(checked) => setShowVendorComment(checked)} />
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoolQRCodePrintingDialog;
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import { FloatButton, Modal, Space } from "antd";
|
||||
import { QrScanner } from "@yudiel/react-qr-scanner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CameraOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { QrScanner } from "@yudiel/react-qr-scanner";
|
||||
import { FloatButton, Modal, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const QRCodeScannerModal: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// React FC that combines the functionality of first selecting what spools to print QR code for, and then opens up the QR code printing dialog.
|
||||
|
||||
import React from "react";
|
||||
import SpoolSelectModal from "./spoolSelectModal";
|
||||
import { Button } from "antd";
|
||||
import { ISpool } from "../pages/spools/model";
|
||||
import { PrinterOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import SpoolQRCodePrintingDialog from "./printing/spoolQrCodePrintingDialog";
|
||||
|
||||
const SelectAndPrint: React.FC = () => {
|
||||
const t = useTranslate();
|
||||
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [selectedSpools, setSelectedSpools] = React.useState<ISpool[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => {
|
||||
setStep(1);
|
||||
}}
|
||||
>
|
||||
{t("printing.qrcode.button")}
|
||||
</Button>
|
||||
<SpoolSelectModal
|
||||
visible={step === 1}
|
||||
description={t("printing.spoolSelect.description")}
|
||||
onCancel={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
onContinue={(spools) => {
|
||||
setSelectedSpools(spools);
|
||||
setStep(2);
|
||||
}}
|
||||
/>
|
||||
<SpoolQRCodePrintingDialog
|
||||
visible={step === 2}
|
||||
onCancel={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
items={selectedSpools}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectAndPrint;
|
||||
41
client/src/components/spoolIcon.css
Normal file
41
client/src/components/spoolIcon.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.spool-icon {
|
||||
display: flex;
|
||||
width: 1.5em;
|
||||
height: 2.5em;
|
||||
gap: 2px;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.spool-icon.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spool-icon.large {
|
||||
width: 4em;
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
.spool-icon * {
|
||||
flex: 1 1 0px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.spool-icon.vertical *:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.spool-icon.vertical *:last-child {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.spool-icon.horizontal *:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.spool-icon.horizontal *:last-child {
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
32
client/src/components/spoolIcon.tsx
Normal file
32
client/src/components/spoolIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import "./spoolIcon.css";
|
||||
|
||||
interface Props {
|
||||
color: string | { colors: string[]; vertical: boolean };
|
||||
size?: "small" | "large";
|
||||
}
|
||||
|
||||
export default function SpoolIcon(props: Props) {
|
||||
let dirClass = "vertical";
|
||||
let cols = [];
|
||||
let size = props.size ? props.size : "small";
|
||||
|
||||
if (typeof props.color === "string") {
|
||||
cols = [props.color];
|
||||
} else {
|
||||
dirClass = props.color.vertical ? "vertical" : "horizontal";
|
||||
cols = props.color.colors;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"spool-icon " + dirClass + " " + size}>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col}
|
||||
style={{
|
||||
backgroundColor: "#" + col.replace("#", ""),
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Spin, Typography } from "antd";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -15,12 +16,10 @@ interface IInfo {
|
||||
}
|
||||
|
||||
export const Version: React.FC = () => {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
|
||||
const infoResult = useQuery<IInfo>({
|
||||
queryKey: ["info"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(apiEndpoint + "/info");
|
||||
const response = await fetch(getAPIURL() + "/info");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import i18n from "i18next";
|
||||
import detector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-xhr-backend";
|
||||
import Backend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { getBasePath } from "./utils/url";
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
@@ -97,6 +98,11 @@ export const languages: { [key: string]: Language } = {
|
||||
countryCode: "dk",
|
||||
fullCode: "da-DK",
|
||||
},
|
||||
["pt"]: {
|
||||
name: "Português",
|
||||
countryCode: "pt",
|
||||
fullCode: "pt-PT",
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
@@ -106,8 +112,9 @@ i18n
|
||||
.init({
|
||||
supportedLngs: Object.keys(languages),
|
||||
backend: {
|
||||
loadPath: "/locales/{{lng}}/{{ns}}.json",
|
||||
loadPath: getBasePath() + "/locales/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
ns: "common",
|
||||
defaultNS: "common",
|
||||
fallbackLng: "en",
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
|
||||
width="100%" height="100%" viewBox="0 0 235 500" xml:space="preserve">
|
||||
<desc>Created with Fabric.js 5.3.0</desc>
|
||||
<defs>
|
||||
</defs>
|
||||
<g transform="matrix(0.5875748337 0 0 3.9476902309 197.1354865303 250.0467513161)" id="z-gD78pOtWjQii31Q-6Ze" >
|
||||
<path style="stroke: rgb(110,11,48); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(52,52,52); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(0, 0)" d="M 0 -63.26949 C 34.92476 -63.26949 63.26949 -34.92476 63.26949 0 C 63.26949 34.92476 34.92476 63.26949 0 63.26949 C -34.92476 63.26949 -63.26949 34.92476 -63.26949 0 C -63.26949 -34.92476 -34.92476 -63.26949 0 -63.26949 z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(0.3815761198 0 0 3.4623174046 197.1354865303 250.0467382769)" id="qCAOML5j7kpAVIP3Df73i" >
|
||||
<path style="stroke: rgb(110,11,48); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4;fill-rule: nonzero; opacity: 1;" fill="current" vector-effect="non-scaling-stroke" transform=" translate(0, 0)" d="M 0 -63.26949 C 34.92476 -63.26949 63.26949 -34.92476 63.26949 0 C 63.26949 34.92476 34.92476 63.26949 0 63.26949 C -34.92476 63.26949 -63.26949 34.92476 -63.26949 0 C -63.26949 -34.92476 -34.92476 -63.26949 0 -63.26949 z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(2.0736420266 0 0 3.3577085856 117.2947215155 250.0467440227)" id="DwEIaQUSPW4DgKfkdPWoC" >
|
||||
<path style="stroke: rgb(0,0,0); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4;fill-rule: nonzero; opacity: 1;" fill="current" vector-effect="non-scaling-stroke" transform=" translate(-0.000005, 0)" d="M -38.50267 -65.24064 L 38.502680000000005 -65.24064 L 38.502680000000005 65.24064 L -38.50267 65.24064 z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(0.5875748337 0 0 3.9476902309 37.4539565008 250.0467440227)" id="OvgM38-6CZm9R47a1TDIX" >
|
||||
<filter id="SVGID_90" y="-24.74162191%" height="149.48324381999998%" x="-40.54702828%" width="181.09405656%" >
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="20"></feGaussianBlur>
|
||||
<feOffset dx="30" dy="0" result="oBlur" ></feOffset>
|
||||
<feFlood flood-color="rgb(0,0,0)" flood-opacity="0.8"/>
|
||||
<feComposite in2="oBlur" operator="in" />
|
||||
<feMerge>
|
||||
<feMergeNode></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<path style="stroke: rgb(110,11,48); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(52,52,52); fill-rule: nonzero; opacity: 1;filter: url(#SVGID_90);" vector-effect="non-scaling-stroke" transform=" translate(0, 0)" d="M 0 -63.26949 C 34.92476 -63.26949 63.26949 -34.92476 63.26949 0 C 63.26949 34.92476 34.92476 63.26949 0 63.26949 C -34.92476 63.26949 -63.26949 34.92476 -63.26949 0 C -63.26949 -34.92476 -34.92476 -63.26949 0 -63.26949 z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(0.2435597948 0 0 1.6363849628 37.4539565008 250.0467440227)" id="woBV3Wt7ijQ6puNeMgAQD" >
|
||||
<path style="stroke: rgb(110,11,48); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(17,17,17); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(0, 0)" d="M 0 -63.26949 C 34.92476 -63.26949 63.26949 -34.92476 63.26949 0 C 63.26949 34.92476 34.92476 63.26949 0 63.26949 C -34.92476 63.26949 -63.26949 34.92476 -63.26949 0 C -63.26949 -34.92476 -34.92476 -63.26949 0 -63.26949 z" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
50
client/src/pages/filaments/functions.ts
Normal file
50
client/src/pages/filaments/functions.ts
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
72
client/src/pages/printing/index.tsx
Normal file
72
client/src/pages/printing/index.tsx
Normal 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;
|
||||
106
client/src/pages/printing/printing.tsx
Normal file
106
client/src/pages/printing/printing.tsx
Normal 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)}</>;
|
||||
}
|
||||
@@ -1,29 +1,33 @@
|
||||
import React, { useRef } from "react";
|
||||
import { FileImageOutlined, PrinterOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import {
|
||||
Modal,
|
||||
Slider,
|
||||
Button,
|
||||
Select,
|
||||
Row,
|
||||
Col,
|
||||
Form,
|
||||
Divider,
|
||||
RadioChangeEvent,
|
||||
Radio,
|
||||
InputNumber,
|
||||
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 { useTranslate } from "@refinedev/core";
|
||||
import { PrintSettings } from "./printing";
|
||||
|
||||
interface PrintingDialogProps {
|
||||
items: JSX.Element[];
|
||||
printSettings: PrintSettings;
|
||||
setPrintSettings: (setPrintSettings: PrintSettings) => void;
|
||||
style?: string;
|
||||
extraSettings?: JSX.Element;
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
title?: string;
|
||||
extraSettingsStart?: JSX.Element;
|
||||
extraButtons?: JSX.Element;
|
||||
}
|
||||
|
||||
interface PaperDimensions {
|
||||
@@ -58,79 +62,77 @@ const paperDimensions: { [key: string]: PaperDimensions } = {
|
||||
},
|
||||
};
|
||||
|
||||
const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSettings, visible, onCancel, title }) => {
|
||||
const PrintingDialog: React.FC<PrintingDialogProps> = ({
|
||||
items,
|
||||
printSettings,
|
||||
setPrintSettings,
|
||||
style,
|
||||
extraSettings,
|
||||
extraSettingsStart,
|
||||
extraButtons,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
|
||||
const [collapseState, setCollapseState] = useSavedState<string[]>("print-collapseState", []);
|
||||
const [marginLeft, setMarginLeft] = useSavedState("print-marginLeft", 10);
|
||||
const [marginTop, setMarginTop] = useSavedState("print-marginTop", 10);
|
||||
const [marginRight, setMarginRight] = useSavedState("print-marginRight", 10);
|
||||
const [marginBottom, setMarginBottom] = useSavedState("print-marginBottom", 10);
|
||||
const [horizontalSpacing, setHorizontalSpacing] = useSavedState("print-horizontalSpacing", 0);
|
||||
const [verticalSpacing, setVerticalSpacing] = useSavedState("print-verticalSpacing", 0);
|
||||
const [printerMarginLeft, setPrinterMarginLeft] = useSavedState("print-printerMarginLeft", 5);
|
||||
const [printerMarginTop, setPrinterMarginTop] = useSavedState("print-printerMarginTop", 5);
|
||||
const [printerMarginRight, setPrinterMarginRight] = useSavedState("print-printerMarginRight", 5);
|
||||
const [printerMarginBottom, setPrinterMarginBottom] = useSavedState("print-printerMarginBottom", 5);
|
||||
const [paperColumns, setPaperColumns] = useSavedState("print-itemsPerRow", 3);
|
||||
const [paperRows, setPaperRows] = useSavedState("print-rowsPerPage", 8);
|
||||
const [skipItems, setSkipItems] = useSavedState("print-skipItems", 0);
|
||||
const [paperSize, setPaperSize] = useSavedState("print-paperSize", "A4");
|
||||
const [customPaperWidth, setCustomPaperWidth] = useSavedState("print-customPaperWidth", 210);
|
||||
const [customPaperHeight, setCustomPaperHeight] = useSavedState("print-customPaperHeight", 297);
|
||||
const [previewScale, setPreviewScale] = useSavedState("print-previewScale", 0.6);
|
||||
const [borderShowMode, setBorderShowMode] = useSavedState<"none" | "border" | "grid">("print-borderShowMode", "grid");
|
||||
|
||||
const paperWidth = paperSize === "custom" ? customPaperWidth : paperDimensions[paperSize].width;
|
||||
const paperHeight = paperSize === "custom" ? customPaperHeight : paperDimensions[paperSize].height;
|
||||
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 - marginLeft - marginRight - horizontalSpacing) / paperColumns - horizontalSpacing;
|
||||
const itemHeight = (paperHeight - marginTop - marginBottom - verticalSpacing) / paperRows - verticalSpacing;
|
||||
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 rowsPerPage = paperRows;
|
||||
const itemsPerPage = itemsPerRow * paperRows;
|
||||
|
||||
const itemsIncludingSkipped = [...Array(skipItems).fill(<></>), ...items];
|
||||
|
||||
const rowsOfItems = [];
|
||||
for (let row_idx = 0; row_idx < itemsIncludingSkipped.length / itemsPerRow; row_idx += 1) {
|
||||
rowsOfItems.push(itemsIncludingSkipped.slice(row_idx * itemsPerRow, (row_idx + 1) * itemsPerRow));
|
||||
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 < rowsOfItems.length / rowsPerPage; page_idx += 1) {
|
||||
pageBlocks.push(rowsOfItems.slice(page_idx * rowsPerPage, (page_idx + 1) * rowsPerPage));
|
||||
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 (rows, pageIdx) {
|
||||
const itemRows = rows.map((row, rowIdx) => {
|
||||
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 (
|
||||
<tr key={rowIdx}>
|
||||
{row.map(function (item, colIdx) {
|
||||
return (
|
||||
<td>
|
||||
<div
|
||||
key={colIdx}
|
||||
style={{
|
||||
width: `${itemWidth}mm`,
|
||||
height: `${itemHeight}mm`,
|
||||
border: borderShowMode === "grid" ? "1px solid #000" : "none",
|
||||
paddingLeft: colIdx === 0 ? `${Math.max(printerMarginLeft - marginLeft, 0)}mm` : 0,
|
||||
paddingRight:
|
||||
colIdx === paperColumns - 1 ? `${Math.max(printerMarginRight - marginRight, 0)}mm` : 0,
|
||||
paddingTop: rowIdx === 0 ? `${Math.max(printerMarginTop - marginTop, 0)}mm` : 0,
|
||||
paddingBottom:
|
||||
rowIdx === paperRows - 1 ? `${Math.max(printerMarginBottom - marginBottom, 0)}mm` : 0,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,57 +150,75 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<div
|
||||
className="print-page-area"
|
||||
style={{
|
||||
border: borderShowMode !== "none" ? "1px solid #000" : "none",
|
||||
height: `${paperHeight - marginTop - marginBottom}mm`,
|
||||
width: `${paperWidth - marginLeft - marginRight}mm`,
|
||||
marginTop: `calc(${marginTop}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
|
||||
marginLeft: `calc(${marginLeft}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
|
||||
marginRight: `calc(${marginRight}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
|
||||
marginBottom: `calc(${marginBottom}mm - ${borderShowMode !== "none" ? "1px" : "0px"})`,
|
||||
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"})`,
|
||||
}}
|
||||
>
|
||||
<table
|
||||
<div
|
||||
style={{
|
||||
alignContent: "flex-start",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
rowGap: `${spacing.vertical}mm`,
|
||||
columnGap: `${spacing.horizontal}mm`,
|
||||
paddingTop: `${spacing.vertical}mm`,
|
||||
paddingLeft: `${spacing.horizontal}mm`,
|
||||
}}
|
||||
>
|
||||
{itemRows}
|
||||
</table>
|
||||
{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 (
|
||||
<Modal
|
||||
open={visible}
|
||||
title={title ?? t("printing.generic.title")}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<ReactToPrint
|
||||
key="print-button"
|
||||
trigger={() => <Button type="primary">{t("printing.generic.print")}</Button>}
|
||||
content={() => printRef.current}
|
||||
/>,
|
||||
]}
|
||||
width={1200} // Set the modal width to accommodate the preview
|
||||
>
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col
|
||||
span={24}
|
||||
span={14}
|
||||
style={{
|
||||
whiteSpace: "pre-line",
|
||||
marginBottom: "1em",
|
||||
// This magic makes this column take the height of the sibling column
|
||||
// https://stackoverflow.com/a/49065029/2911165
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{t("printing.generic.description")}
|
||||
</Col>
|
||||
<Col span={14}>
|
||||
<div
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
overflow: "hidden scroll",
|
||||
height: "70vh",
|
||||
overflow: "auto",
|
||||
flexBasis: "0px",
|
||||
flexGrow: "1",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -207,38 +227,44 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
style={{
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: "top left",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.print-container {
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
.print-page {
|
||||
page-break-before: auto;
|
||||
}
|
||||
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 {
|
||||
margin-top: 10mm;
|
||||
}
|
||||
}
|
||||
|
||||
.print-page table {
|
||||
border-spacing: ${horizontalSpacing}mm ${verticalSpacing}mm;
|
||||
border-collapse: separate;
|
||||
.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 td {
|
||||
padding: 0;
|
||||
.print-page * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
${style ?? ""}
|
||||
`}
|
||||
</style>
|
||||
@@ -248,6 +274,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
</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}>
|
||||
@@ -256,7 +284,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={30}
|
||||
value={skipItems}
|
||||
onChange={(value) => {
|
||||
setSkipItems(value);
|
||||
printSettings.skipItems = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -266,7 +295,34 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
style={{ margin: "0 16px" }}
|
||||
value={skipItems}
|
||||
onChange={(value) => {
|
||||
setSkipItems(value ?? 1);
|
||||
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>
|
||||
@@ -283,7 +339,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
{ label: t("printing.generic.borders.grid"), value: "grid" },
|
||||
]}
|
||||
onChange={(e: RadioChangeEvent) => {
|
||||
setBorderShowMode(e.target.value);
|
||||
printSettings.borderShowMode = e.target.value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
value={borderShowMode}
|
||||
optionType="button"
|
||||
@@ -295,7 +352,7 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
min={0.1}
|
||||
max={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={previewScale}
|
||||
onChange={(value) => {
|
||||
@@ -306,7 +363,7 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<Col span={12}>
|
||||
<InputNumber
|
||||
min={0.1}
|
||||
max={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={previewScale}
|
||||
@@ -332,8 +389,16 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
{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) => setPaperSize(value)}>
|
||||
<Select
|
||||
value={paperSize}
|
||||
onChange={(value) => {
|
||||
printSettings.paperSize = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
>
|
||||
{Object.keys(paperDimensions).map((key) => (
|
||||
<Select.Option key={key} value={key}>
|
||||
{key}
|
||||
@@ -346,10 +411,14 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<Row align="middle">
|
||||
<Col span={11}>
|
||||
<InputNumber
|
||||
value={customPaperWidth}
|
||||
value={customPaperSize.width}
|
||||
min={0.1}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => setCustomPaperWidth(value ?? 0)}
|
||||
onChange={(value) => {
|
||||
customPaperSize.width = value ?? 0;
|
||||
printSettings.customPaperSize = customPaperSize;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2} style={{ textAlign: "center" }}>
|
||||
@@ -357,10 +426,14 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<InputNumber
|
||||
value={customPaperHeight}
|
||||
value={customPaperSize.height}
|
||||
min={0.1}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => setCustomPaperHeight(value ?? 0)}
|
||||
onChange={(value) => {
|
||||
customPaperSize.height = value ?? 0;
|
||||
printSettings.customPaperSize = customPaperSize;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -373,7 +446,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={5}
|
||||
value={paperColumns}
|
||||
onChange={(value) => {
|
||||
setPaperColumns(value);
|
||||
printSettings.columns = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -383,7 +457,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
style={{ margin: "0 16px" }}
|
||||
value={paperColumns}
|
||||
onChange={(value) => {
|
||||
setPaperColumns(value ?? 1);
|
||||
printSettings.columns = value ?? 1;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -397,7 +472,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={15}
|
||||
value={paperRows}
|
||||
onChange={(value) => {
|
||||
setPaperRows(value);
|
||||
printSettings.rows = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -407,7 +483,8 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
style={{ margin: "0 16px" }}
|
||||
value={paperRows}
|
||||
onChange={(value) => {
|
||||
setPaperRows(value ?? 1);
|
||||
printSettings.rows = value ?? 1;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -423,9 +500,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={marginLeft}
|
||||
value={margin.left}
|
||||
onChange={(value) => {
|
||||
setMarginLeft(value);
|
||||
margin.left = value;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -433,10 +512,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={marginLeft}
|
||||
value={margin.left}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setMarginLeft(value ?? 0);
|
||||
margin.left = value ?? 0;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -450,9 +531,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={marginTop}
|
||||
value={margin.top}
|
||||
onChange={(value) => {
|
||||
setMarginTop(value);
|
||||
margin.top = value;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -460,10 +543,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={marginTop}
|
||||
value={margin.top}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setMarginTop(value ?? 0);
|
||||
margin.top = value ?? 0;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -477,9 +562,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={marginRight}
|
||||
value={margin.right}
|
||||
onChange={(value) => {
|
||||
setMarginRight(value);
|
||||
margin.right = value;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -487,10 +574,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={marginRight}
|
||||
value={margin.right}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setMarginRight(value ?? 0);
|
||||
margin.right = value ?? 0;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -504,9 +593,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={marginBottom}
|
||||
value={margin.bottom}
|
||||
onChange={(value) => {
|
||||
setMarginBottom(value);
|
||||
margin.bottom = value;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -514,10 +605,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={marginBottom}
|
||||
value={margin.bottom}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setMarginBottom(value ?? 0);
|
||||
margin.bottom = value ?? 0;
|
||||
printSettings.margin = margin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -533,9 +626,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={printerMarginLeft}
|
||||
value={printerMargin.left}
|
||||
onChange={(value) => {
|
||||
setPrinterMarginLeft(value);
|
||||
printerMargin.left = value;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -543,10 +638,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={printerMarginLeft}
|
||||
value={printerMargin.left}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setPrinterMarginLeft(value ?? 0);
|
||||
printerMargin.left = value ?? 0;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -560,9 +657,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={printerMarginTop}
|
||||
value={printerMargin.top}
|
||||
onChange={(value) => {
|
||||
setPrinterMarginTop(value);
|
||||
printerMargin.top = value;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -570,10 +669,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={printerMarginTop}
|
||||
value={printerMargin.top}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setPrinterMarginTop(value ?? 0);
|
||||
printerMargin.top = value ?? 0;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -587,9 +688,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={printerMarginRight}
|
||||
value={printerMargin.right}
|
||||
onChange={(value) => {
|
||||
setPrinterMarginRight(value);
|
||||
printerMargin.right = value;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -597,10 +700,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={printerMarginRight}
|
||||
value={printerMargin.right}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setPrinterMarginRight(value ?? 0);
|
||||
printerMargin.right = value ?? 0;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -614,9 +719,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={50}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={printerMarginBottom}
|
||||
value={printerMargin.bottom}
|
||||
onChange={(value) => {
|
||||
setPrinterMarginBottom(value);
|
||||
printerMargin.bottom = value;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -624,10 +731,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={printerMarginBottom}
|
||||
value={printerMargin.bottom}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setPrinterMarginBottom(value ?? 0);
|
||||
printerMargin.bottom = value ?? 0;
|
||||
printSettings.printerMargin = printerMargin;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -642,9 +751,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={20}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={horizontalSpacing}
|
||||
value={spacing.horizontal}
|
||||
onChange={(value) => {
|
||||
setHorizontalSpacing(value);
|
||||
spacing.horizontal = value;
|
||||
printSettings.spacing = spacing;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -652,10 +763,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={horizontalSpacing}
|
||||
value={spacing.horizontal}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setHorizontalSpacing(value ?? 0);
|
||||
spacing.horizontal = value ?? 0;
|
||||
printSettings.spacing = spacing;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -669,9 +782,11 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
max={20}
|
||||
step={0.1}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
value={verticalSpacing}
|
||||
value={spacing.vertical}
|
||||
onChange={(value) => {
|
||||
setVerticalSpacing(value);
|
||||
spacing.vertical = value;
|
||||
printSettings.spacing = spacing;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -679,10 +794,12 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
<InputNumber
|
||||
step={0.1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={verticalSpacing}
|
||||
value={spacing.vertical}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
setVerticalSpacing(value ?? 0);
|
||||
spacing.vertical = value ?? 0;
|
||||
printSettings.spacing = spacing;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -693,7 +810,26 @@ const PrintingDialog: React.FC<PrintingDialogProps> = ({ items, style, extraSett
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
174
client/src/pages/printing/qrCodePrintingDialog.tsx
Normal file
174
client/src/pages/printing/qrCodePrintingDialog.tsx
Normal 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;
|
||||
367
client/src/pages/printing/spoolQrCodePrintingDialog.tsx
Normal file
367
client/src/pages/printing/spoolQrCodePrintingDialog.tsx
Normal 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;
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd";
|
||||
import { ISpool } from "../pages/spools/model";
|
||||
import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "./column";
|
||||
import { TableState } from "../utils/saveload";
|
||||
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 { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels";
|
||||
import { removeUndefined } from "../utils/filtering";
|
||||
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 {
|
||||
visible: boolean;
|
||||
description?: string;
|
||||
onCancel: () => void;
|
||||
onContinue: (selectedSpools: ISpool[]) => void;
|
||||
}
|
||||
|
||||
@@ -37,13 +36,14 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||
};
|
||||
}
|
||||
|
||||
const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onContinue }) => {
|
||||
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,
|
||||
@@ -79,7 +79,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
};
|
||||
|
||||
// Collapse the dataSource to a mutable list and add a filament_name field
|
||||
const dataSource: ISpoolCollapsed[] = React.useMemo(
|
||||
const dataSource: ISpoolCollapsed[] = useMemo(
|
||||
() => (tableProps.dataSource || []).map((record) => ({ ...record })),
|
||||
[tableProps.dataSource]
|
||||
);
|
||||
@@ -116,22 +116,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("printing.spoolSelect.title")}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={() => {
|
||||
if (selectedItems.length === 0) {
|
||||
messageApi.open({
|
||||
type: "error",
|
||||
content: t("printing.spoolSelect.noSpoolsSelected"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onContinue(dataSource.filter((spool) => selectedItems.includes(spool.id)));
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{description && <div>{description}</div>}
|
||||
@@ -171,7 +156,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
}),
|
||||
])}
|
||||
/>
|
||||
<Row>
|
||||
<Row gutter={[10, 10]}>
|
||||
<Col span={12}>
|
||||
<Checkbox
|
||||
checked={isAllFilteredSelected}
|
||||
@@ -208,9 +193,28 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
|
||||
{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>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
238
client/src/pages/spools/functions.tsx
Normal file
238
client/src/pages/spools/functions.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) : ""}
|
||||
|
||||
33
client/src/pages/vendors/create.tsx
vendored
33
client/src/pages/vendors/create.tsx
vendored
@@ -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} />
|
||||
|
||||
27
client/src/pages/vendors/edit.tsx
vendored
27
client/src/pages/vendors/edit.tsx
vendored
@@ -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
48
client/src/pages/vendors/functions.ts
vendored
Normal 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();
|
||||
}
|
||||
51
client/src/pages/vendors/list.tsx
vendored
51
client/src/pages/vendors/list.tsx
vendored
@@ -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>
|
||||
|
||||
2
client/src/pages/vendors/model.tsx
vendored
2
client/src/pages/vendors/model.tsx
vendored
@@ -3,6 +3,8 @@ export interface IVendor {
|
||||
registered: string;
|
||||
name: string;
|
||||
comment?: string;
|
||||
empty_spool_weight?: number;
|
||||
external_id?: string;
|
||||
extra: { [key: string]: string };
|
||||
}
|
||||
|
||||
|
||||
12
client/src/pages/vendors/show.tsx
vendored
12
client/src/pages/vendors/show.tsx
vendored
@@ -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]} />
|
||||
|
||||
@@ -35,3 +35,12 @@ export function getFiltersForField<Obj, Field extends keyof Obj>(
|
||||
export function removeUndefined<T>(array: (T | undefined)[]): T[] {
|
||||
return array.filter((value) => value !== undefined) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a case-insensitive search for the given query in the given string.
|
||||
* The query is broken down into words and the search is performed on each word.
|
||||
*/
|
||||
export function searchMatches(query: string, test: string): boolean {
|
||||
const words = query.toLowerCase().split(" ");
|
||||
return words.every((word) => test.toLowerCase().includes(word));
|
||||
}
|
||||
|
||||
@@ -84,3 +84,35 @@ export function enrichText(text: string | undefined) {
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the weight in grams to either kilograms or grams based on the provided precision.
|
||||
*
|
||||
* @param {number} weightInGrams - The weight in grams to be formatted.
|
||||
* @param {number} [precision=2] - The precision of the formatting (number of decimal places).
|
||||
* @return {string} The formatted weight with the appropriate unit (kg or g).
|
||||
*/
|
||||
export function formatWeight(weightInGrams: number, precision: number = 2): string {
|
||||
if (weightInGrams >= 1000) {
|
||||
const kilograms = (weightInGrams / 1000).toFixed(precision).replace(/\.?0+$/, ""); // Remove trailing zeros
|
||||
return `${kilograms} kg`;
|
||||
} else {
|
||||
return `${weightInGrams} g`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the length in millimeters to either meters or millimeters based on the provided precision.
|
||||
*
|
||||
* @param {number} lengthInMillimeter - The length in millimeters to be formatted.
|
||||
* @param {number} [precision=2] - The precision of the formatting (number of decimal places).
|
||||
* @return {string} The formatted length with the appropriate unit (m or mm).
|
||||
*/
|
||||
export function formatLength(lengthInMillimeter: number, precision: number = 2): string {
|
||||
if (lengthInMillimeter >= 1000) {
|
||||
const meters = (lengthInMillimeter / 1000).toFixed(precision).replace(/\.?0+$/, ""); // Remove trailing zeros
|
||||
return `${meters} m`;
|
||||
} else {
|
||||
return `${lengthInMillimeter} mm`;
|
||||
}
|
||||
}
|
||||
|
||||
73
client/src/utils/queryExternalDB.ts
Normal file
73
client/src/utils/queryExternalDB.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAPIURL } from "./url";
|
||||
|
||||
export enum SpoolType {
|
||||
PLASTIC = "plastic",
|
||||
CARDBOARD = "cardboard",
|
||||
METAL = "metal",
|
||||
}
|
||||
|
||||
export enum Finish {
|
||||
MATTE = "matte",
|
||||
GLOSSY = "glossy",
|
||||
}
|
||||
|
||||
export enum MultiColorDirection {
|
||||
COAXIAL = "coaxial",
|
||||
LONGITUDINAL = "longitudinal",
|
||||
}
|
||||
|
||||
export enum Pattern {
|
||||
MARBLE = "marble",
|
||||
SPARKLE = "sparkle",
|
||||
}
|
||||
|
||||
export interface ExternalFilament {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
material: string;
|
||||
density: number;
|
||||
weight: number;
|
||||
spool_weight?: number;
|
||||
spool_type?: SpoolType;
|
||||
diameter: number;
|
||||
color_hex?: string;
|
||||
color_hexes?: string[];
|
||||
extruder_temp?: number;
|
||||
bed_temp?: number;
|
||||
finish?: Finish;
|
||||
multi_color_direction?: MultiColorDirection;
|
||||
pattern?: Pattern;
|
||||
translucent: boolean;
|
||||
glow: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalMaterial {
|
||||
material: string;
|
||||
density: number;
|
||||
extruder_temp: number | null;
|
||||
bed_temp: number | null;
|
||||
}
|
||||
|
||||
export function useGetExternalDBFilaments() {
|
||||
return useQuery<ExternalFilament[]>({
|
||||
queryKey: ["external", "filaments"],
|
||||
staleTime: 60,
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${getAPIURL()}/external/filament`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetExternalDBMaterials() {
|
||||
return useQuery<ExternalMaterial[]>({
|
||||
queryKey: ["external", "materials"],
|
||||
staleTime: 60,
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${getAPIURL()}/external/material`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { getAPIURL } from "./url";
|
||||
|
||||
export enum FieldType {
|
||||
text = "text",
|
||||
@@ -34,11 +35,10 @@ export interface Field extends FieldParameters {
|
||||
}
|
||||
|
||||
export function useGetFields(entity_type: EntityType) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<Field[]>({
|
||||
queryKey: ["fields", entity_type],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}`);
|
||||
const response = await fetch(`${getAPIURL()}/field/${entity_type}`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
@@ -47,10 +47,9 @@ export function useGetFields(entity_type: EntityType) {
|
||||
export function useSetField(entity_type: EntityType) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useMutation<Field[], unknown, { key: string; params: FieldParameters }, { previousFields?: Field[] }>({
|
||||
mutationFn: async ({ key, params }) => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
|
||||
const response = await fetch(`${getAPIURL()}/field/${entity_type}/${key}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -110,10 +109,9 @@ export function useSetField(entity_type: EntityType) {
|
||||
export function useDeleteField(entity_type: EntityType) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useMutation<Field[], unknown, string>({
|
||||
mutationFn: async (key) => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
|
||||
const response = await fetch(`${getAPIURL()}/field/${entity_type}/${key}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getAPIURL } from "./url";
|
||||
|
||||
interface SettingResponseValue {
|
||||
value: string;
|
||||
@@ -11,39 +12,36 @@ interface SettingsResponse {
|
||||
}
|
||||
|
||||
export function useGetSettings() {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<SettingsResponse>({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiEndpoint}/setting`);
|
||||
const response = await fetch(`${getAPIURL()}/setting/`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSetting(key: string) {
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useQuery<SettingResponseValue>({
|
||||
queryKey: ["settings", key],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiEndpoint}/setting/${key}`);
|
||||
const response = await fetch(`${getAPIURL()}/setting/${key}`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSetting() {
|
||||
export function useSetSetting<T>(key: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useMutation<SettingResponseValue, unknown, { key: string; value: unknown }>({
|
||||
mutationFn: async ({ key, value }) => {
|
||||
const response = await fetch(`${apiEndpoint}/setting/${key}`, {
|
||||
return useMutation<SettingResponseValue, unknown, T, SettingResponseValue | undefined>({
|
||||
mutationFn: async (value) => {
|
||||
const response = await fetch(`${getAPIURL()}/setting/${key}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(value),
|
||||
body: JSON.stringify(JSON.stringify(value)),
|
||||
});
|
||||
|
||||
// Throw error if response is not ok
|
||||
@@ -53,7 +51,18 @@ export function useSetSetting() {
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_data, { key }) => {
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(["settings", key]);
|
||||
const previousValue = queryClient.getQueryData<SettingResponseValue>(["settings", key]);
|
||||
queryClient.setQueryData<SettingResponseValue>(["settings", key], (old) =>
|
||||
old ? { ...old, value: JSON.stringify(value) } : undefined
|
||||
);
|
||||
return previousValue;
|
||||
},
|
||||
onError: (_error, _value, context) => {
|
||||
queryClient.setQueryData<SettingResponseValue>(["settings", key], context);
|
||||
},
|
||||
onSuccess: (_data, _value) => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["settings", key]);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { CrudFilter, CrudSort } from "@refinedev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isLocalStorageAvailable } from "./support";
|
||||
interface Pagination {
|
||||
current: number;
|
||||
@@ -14,7 +14,7 @@ export interface TableState {
|
||||
}
|
||||
|
||||
export function useInitialTableState(tableId: string): TableState {
|
||||
const [initialState] = React.useState(() => {
|
||||
const [initialState] = useState(() => {
|
||||
const savedSorters = hasHashProperty("sorters")
|
||||
? getHashProperty("sorters")
|
||||
: isLocalStorageAvailable
|
||||
@@ -42,7 +42,7 @@ export function useInitialTableState(tableId: string): TableState {
|
||||
}
|
||||
|
||||
export function useStoreInitialState(tableId: string, state: TableState) {
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (state.sorters.length > 0 && JSON.stringify(state.sorters) != JSON.stringify([{ field: "id", order: "asc" }])) {
|
||||
if (isLocalStorageAvailable) {
|
||||
localStorage.setItem(`${tableId}-sorters`, JSON.stringify(state.sorters));
|
||||
@@ -54,7 +54,7 @@ export function useStoreInitialState(tableId: string, state: TableState) {
|
||||
}
|
||||
}, [tableId, state.sorters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const filters = state.filters.filter((f) => f.value.length != 0);
|
||||
if (filters.length > 0) {
|
||||
if (isLocalStorageAvailable) {
|
||||
@@ -67,7 +67,7 @@ export function useStoreInitialState(tableId: string, state: TableState) {
|
||||
}
|
||||
}, [tableId, state.filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (JSON.stringify(state.pagination) != JSON.stringify({ current: 1, pageSize: 20 })) {
|
||||
if (isLocalStorageAvailable) {
|
||||
localStorage.setItem(`${tableId}-pagination`, JSON.stringify(state.pagination));
|
||||
@@ -79,7 +79,7 @@ export function useStoreInitialState(tableId: string, state: TableState) {
|
||||
}
|
||||
}, [tableId, state.pagination]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (isLocalStorageAvailable) {
|
||||
if (state.showColumns === undefined) {
|
||||
localStorage.removeItem(`${tableId}-showColumns`);
|
||||
@@ -91,12 +91,12 @@ export function useStoreInitialState(tableId: string, state: TableState) {
|
||||
}
|
||||
|
||||
export function useSavedState<T>(id: string, defaultValue: T) {
|
||||
const [state, setState] = React.useState<T>(() => {
|
||||
const [state, setState] = useState<T>(() => {
|
||||
const savedState = isLocalStorageAvailable ? localStorage.getItem(`savedStates-${id}`) : null;
|
||||
return savedState ? JSON.parse(savedState) : defaultValue;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (isLocalStorageAvailable) {
|
||||
localStorage.setItem(`savedStates-${id}`, JSON.stringify(state));
|
||||
}
|
||||
|
||||
35
client/src/utils/url.ts
Normal file
35
client/src/utils/url.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
SPOOLMAN_BASE_PATH: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base path of the application.
|
||||
*
|
||||
* If a base path is set, this returns e.g. "/spoolman". If none is set, it returns "".
|
||||
*
|
||||
* @return {string} The base path of the application. If the `SPOOLMAN_BASE_PATH`
|
||||
* window variable is set and not empty, it is returned. Otherwise, the
|
||||
* default base path "" is returned.
|
||||
*/
|
||||
export function getBasePath(): string {
|
||||
if (window.SPOOLMAN_BASE_PATH && window.SPOOLMAN_BASE_PATH.length > 0) {
|
||||
return window.SPOOLMAN_BASE_PATH;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that returns the Spoolman API URL
|
||||
* This returns e.g. "/spoolman/api/v1" if the base path is "/spoolman"
|
||||
*
|
||||
* @return {string} The API URL
|
||||
*/
|
||||
export function getAPIURL(): string {
|
||||
if (!import.meta.env.VITE_APIURL) {
|
||||
throw new Error("VITE_APIURL is not set");
|
||||
}
|
||||
return getBasePath() + import.meta.env.VITE_APIURL;
|
||||
}
|
||||
Reference in New Issue
Block a user