diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 88ff2ae..1931964 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -243,6 +243,7 @@
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
"material": "E.g. PLA, ABS, PETG, etc.",
"price": "Price of a full spool.",
+ "density": "Auto-filled based on material. Standard: PLA 1.24, ABS 1.04, PETG 1.27 g/cm³.",
"weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.",
"spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.",
"article_number": "E.g. EAN, UPC, etc.",
@@ -259,7 +260,10 @@
"form": {
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"import_external": "Import from External",
- "import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.
A Manufacturer object will be created automatically when you click Ok, if necessary."
+ "import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.
A Manufacturer object will be created automatically when you click Ok, if necessary.",
+ "created_success": "Created filament \"{{name}}\"",
+ "vendor_placeholder": "Select a manufacturer",
+ "name_placeholder": "E.g. Matte Black"
},
"buttons": {
"add_spool": "Add Spool"
@@ -287,7 +291,9 @@
"show_title": "[Manufacturer #{{id}}] {{name}}"
},
"form": {
- "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!"
+ "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
+ "created_success": "Created manufacturer \"{{name}}\"",
+ "name_required": "Name is required"
}
},
"home": {
diff --git a/client/src/components/filamentCreateModal.tsx b/client/src/components/filamentCreateModal.tsx
new file mode 100644
index 0000000..1e763ba
--- /dev/null
+++ b/client/src/components/filamentCreateModal.tsx
@@ -0,0 +1,212 @@
+import { useSelect } from "@refinedev/antd";
+import { useInvalidate, useTranslate } from "@refinedev/core";
+import { Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import { useState } from "react";
+import { Color } from "antd/es/color-picker";
+import { IFilament } from "../pages/filaments/model";
+import { IVendor } from "../pages/vendors/model";
+import { getAPIURL } from "../utils/url";
+import { VendorCreateModal } from "./vendorCreateModal";
+
+export interface FilamentCreateModalProps {
+ isOpen: boolean;
+ onSuccess: (filament: IFilament) => void;
+ onClose: () => void;
+}
+
+// Default values for common filament properties
+const DEFAULT_DIAMETER = 1.75;
+const DEFAULT_DENSITY = 1.24; // PLA
+
+/**
+ * Modal for quickly creating a filament inline without navigating away from the current page.
+ * Includes essential fields and the ability to create a vendor inline.
+ */
+export function FilamentCreateModal(props: FilamentCreateModalProps) {
+ const [form] = Form.useForm();
+ const t = useTranslate();
+ const invalidate = useInvalidate();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
+
+ // Vendor select
+ const { selectProps: vendorSelectProps } = useSelect({
+ resource: "vendor",
+ optionLabel: "name",
+ });
+
+ const handleVendorCreated = async (vendor: IVendor) => {
+ // Invalidate vendor cache so select options refresh
+ await invalidate({
+ resource: "vendor",
+ invalidates: ["list"],
+ });
+ form.setFieldValue("vendor_id", vendor.id);
+ setIsVendorModalOpen(false);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ setIsSubmitting(true);
+
+ // Convert ColorPicker value to hex string if present
+ let colorHex: string | undefined = undefined;
+ if (values.color_hex) {
+ const color = values.color_hex as Color;
+ colorHex = typeof color === "string" ? color : color.toHexString();
+ }
+
+ const body = {
+ name: values.name || undefined,
+ vendor_id: values.vendor_id || undefined,
+ material: values.material || undefined,
+ density: values.density,
+ diameter: values.diameter,
+ weight: values.weight || undefined,
+ color_hex: colorHex,
+ };
+
+ const response = await fetch(getAPIURL() + "/filament", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.message || "Failed to create filament");
+ }
+
+ const filament: IFilament = await response.json();
+
+ // Invalidate filament cache so lists refresh
+ await invalidate({
+ resource: "filament",
+ invalidates: ["list"],
+ });
+
+ message.success(t("filament.form.created_success", { name: filament.name || `#${filament.id}` }));
+
+ props.onSuccess(filament);
+ form.resetFields();
+ props.onClose();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "Failed to create filament");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCancel = () => {
+ form.resetFields();
+ props.onClose();
+ };
+
+ // Set default values when modal opens
+ const handleAfterOpenChange = (open: boolean) => {
+ if (open) {
+ form.setFieldsValue({
+ diameter: DEFAULT_DIAMETER,
+ density: DEFAULT_DENSITY,
+ });
+ }
+ };
+
+ return (
+ <>
+ setIsVendorModalOpen(false)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/components/vendorCreateModal.tsx b/client/src/components/vendorCreateModal.tsx
new file mode 100644
index 0000000..7c62d54
--- /dev/null
+++ b/client/src/components/vendorCreateModal.tsx
@@ -0,0 +1,98 @@
+import { useTranslate } from "@refinedev/core";
+import { Form, Input, InputNumber, Modal, message } from "antd";
+import TextArea from "antd/es/input/TextArea";
+import { useState } from "react";
+import { IVendor } from "../pages/vendors/model";
+import { getAPIURL } from "../utils/url";
+
+export interface VendorCreateModalProps {
+ isOpen: boolean;
+ onSuccess: (vendor: IVendor) => void;
+ onClose: () => void;
+}
+
+/**
+ * Modal for quickly creating a vendor inline without navigating away from the current page.
+ * Only requires the vendor name - all other fields are optional.
+ */
+export function VendorCreateModal(props: VendorCreateModalProps) {
+ const [form] = Form.useForm();
+ const t = useTranslate();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ setIsSubmitting(true);
+
+ const body: Omit = {
+ name: values.name,
+ comment: values.comment || undefined,
+ empty_spool_weight: values.empty_spool_weight || undefined,
+ };
+
+ const response = await fetch(getAPIURL() + "/vendor", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.message || "Failed to create vendor");
+ }
+
+ const vendor: IVendor = await response.json();
+ message.success(t("vendor.form.created_success", { name: vendor.name }));
+
+ props.onSuccess(vendor);
+ form.resetFields();
+ props.onClose();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "Failed to create vendor");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCancel = () => {
+ form.resetFields();
+ props.onClose();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx
index a67962c..83f9e98 100644
--- a/client/src/pages/filaments/create.tsx
+++ b/client/src/pages/filaments/create.tsx
@@ -1,12 +1,14 @@
import { Create, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
-import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
+import { Button, ColorPicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
import TextArea from "antd/es/input/TextArea";
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 { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { ExternalFilament } from "../../utils/queryExternalDB";
@@ -31,6 +33,7 @@ export const FilamentCreate: React.FC("single");
@@ -66,6 +69,16 @@ export const FilamentCreate: React.FC {
+ // Invalidate vendor cache so select options refresh
+ await invalidate({
+ resource: "vendor",
+ invalidates: ["list"],
+ });
+ form.setFieldValue("vendor_id", vendor.id);
+ setIsVendorModalOpen(false);
+ };
+
const importFilament = async (filament: ExternalFilament) => {
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
await invalidate({
@@ -132,6 +145,11 @@ export const FilamentCreate: React.FC setIsImportExtOpen(false)}
/>
+ setIsVendorModalOpen(false)}
+ />
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
}
+ dropdownRender={(menu) => (
+ <>
+ {menu}
+
+ }
+ onClick={() => setIsVendorModalOpen(true)}
+ style={{ width: "100%", textAlign: "left" }}
+ >
+ {t("vendor.titles.create")}
+
+ >
+ )}
/>
diff --git a/client/src/pages/spools/create.tsx b/client/src/pages/spools/create.tsx
index 53d8e5b..d1ddda7 100644
--- a/client/src/pages/spools/create.tsx
+++ b/client/src/pages/spools/create.tsx
@@ -1,12 +1,13 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd";
-import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
+import { HttpError, IResourceComponentsProps, useInvalidate, 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 { FilamentCreateModal } from "../../components/filamentCreateModal";
import { useSpoolmanLocations } from "../../components/otherModels";
import { searchMatches } from "../../utils/filtering";
import "../../utils/overrides.css";
@@ -14,6 +15,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { createFilamentFromExternal } from "../filaments/functions";
+import { IFilament } from "../filaments/model";
import { useGetFilamentSelectOptions } from "./functions";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
@@ -31,6 +33,8 @@ export const SpoolCreate: React.FC {
+ // Invalidate filament cache so the select options refresh
+ await invalidate({
+ resource: "filament",
+ invalidates: ["list"],
+ });
+ // Select the newly created filament
+ form.setFieldValue("filament_id", filament.id);
+ setIsFilamentModalOpen(false);
+ };
+
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.
@@ -268,6 +283,11 @@ export const SpoolCreate: React.FC
)}
>
+ setIsFilamentModalOpen(false)}
+ />
typeof option?.label === "string" && searchMatches(input, option?.label)}
+ dropdownRender={(menu) => (
+ <>
+ {menu}
+
+ }
+ onClick={() => setIsFilamentModalOpen(true)}
+ style={{ width: "100%", textAlign: "left" }}
+ >
+ {t("filament.titles.create")}
+
+ >
+ )}
/>
{selectedFilament?.is_internal === false && (