Merge branch 'locationspage' into master
This commit is contained in:
2205
client/package-lock.json
generated
2205
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,49 +7,53 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@loadable/component": "^5.16.4",
|
||||
"@refinedev/antd": "^5.42.0",
|
||||
"@refinedev/core": "^4.53.0",
|
||||
"@refinedev/antd": "^5.44.0",
|
||||
"@refinedev/core": "^4.56.0",
|
||||
"@refinedev/kbar": "^1.3.12",
|
||||
"@refinedev/react-router-v6": "^4.5.11",
|
||||
"@refinedev/react-router-v6": "^4.6.0",
|
||||
"@refinedev/simple-rest": "^5.0.8",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query-devtools": "^4.36.1",
|
||||
"@types/loadable__component": "^5.13.9",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@yudiel/react-qr-scanner": "^1.2.10",
|
||||
"antd": "^5.20.0",
|
||||
"antd": "^5.22.1",
|
||||
"axios": "^1.7.7",
|
||||
"flag-icons": "^7.2.3",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^23.12.2",
|
||||
"i18next": "^23.16.6",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.5.2",
|
||||
"i18next-http-backend": "^2.6.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-to-print": "^2.15.1",
|
||||
"uuid": "^10.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
"uuid": "^11.0.3",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@refinedev/cli": "^2.16.36",
|
||||
"@types/node": "^20.14.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@refinedev/cli": "^2.16.39",
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6",
|
||||
"vite-plugin-mkcert": "^1.17.5"
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-mkcert": "^1.17.6"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "refine dev",
|
||||
|
||||
@@ -208,6 +208,9 @@
|
||||
},
|
||||
"messages": {
|
||||
"archive": "Are you sure you want to archive this spool?"
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Last used {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -356,6 +359,9 @@
|
||||
"help": {
|
||||
"list": "Help | Spoolman"
|
||||
},
|
||||
"locations": {
|
||||
"list": "Locations | Spoolman"
|
||||
},
|
||||
"filament": {
|
||||
"list": "Filaments | Spoolman",
|
||||
"show": "#{{id}} Show Filament | Spoolman",
|
||||
@@ -377,5 +383,11 @@
|
||||
"create": "Create Manufacturer | Spoolman",
|
||||
"clone": "#{{id}} Clone Manufacturer | Spoolman"
|
||||
}
|
||||
},
|
||||
"locations": {
|
||||
"locations": "Locations",
|
||||
"new_location": "New Location",
|
||||
"no_location": "No Location",
|
||||
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
"measurement_type_label": "Mättyp",
|
||||
"adjust_filament_value": "Mängd att konsumera",
|
||||
"adjust_filament_help": "Här kan du direkt lägga till eller subtrahera filament från spolen. Ett positivt värde kommer att konsumera filament, ett negativt värde kommer att lägga till det."
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Senast använd {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -376,5 +379,9 @@
|
||||
"description": "<title>Hjälp</title><p>Här är några tips för att komma igång.</p><p>Spoolman innehåller 3 olika typer av data:</p><itemsHelp/><p>För att lägga till en ny spole i databasen måste du först skapa ett <filamentCreateLink>Filament</filamentCreateLink>-objekt för den. När det är klart kan du sedan skapa ett <spoolCreateLink>Spol</spoolCreateLink>-objekt för den individuella spolen. Om du sedan köper ytterligare spolar av samma filament kan du bara skapa ytterligare Spol-objekt och återanvända samma Filament-objekt.</p><p>Du kan valfritt också skapa ett <vendorCreateLink>Tillverkar</vendorCreateLink>-objekt för företaget som tillverkar filamentet om du vill spåra den informationen.</p><p>Du kan koppla andra 3D-skrivartjänster till Spoolman, som Moonraker, som sedan automatiskt kan spåra filamentanvändning och uppdatera Spol-objekten åt dig. Se <readmeLink>Spoolman README</readmeLink> för hur du gör det.</p>",
|
||||
"help": "Hjälp"
|
||||
},
|
||||
"kofi": "Dricksa mig på Ko-fi"
|
||||
"kofi": "Dricksa mig på Ko-fi",
|
||||
"locations": {
|
||||
"new_location": "Ny plats",
|
||||
"no_location": "Ingen plats"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HighlightOutlined,
|
||||
HomeOutlined,
|
||||
QuestionOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
@@ -148,6 +149,14 @@ function App() {
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locations",
|
||||
list: "/locations",
|
||||
meta: {
|
||||
canDelete: false,
|
||||
icon: <TableOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
list: "/settings",
|
||||
@@ -222,6 +231,7 @@ function App() {
|
||||
</Route>
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -61,8 +61,6 @@ const dataProvider = (
|
||||
params: queryParams,
|
||||
});
|
||||
|
||||
// console.log(url, requestMethod, queryParams, data, headers)
|
||||
|
||||
return {
|
||||
data,
|
||||
total: parseInt(headers["x-total-count"]) ?? 100,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import i18n from "i18next";
|
||||
import detector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-http-backend";
|
||||
@@ -8,6 +9,7 @@ interface Language {
|
||||
name: string;
|
||||
countryCode: string;
|
||||
fullCode: string;
|
||||
djs: () => Promise<ILocale>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,107 +18,128 @@ interface Language {
|
||||
* name: Name of the language in the list
|
||||
* countryCode: Country code of the country's flag to display for this language
|
||||
* fullCode: Full language code, used for Ant Design's locale
|
||||
* djs: Function to load the dayjs locale, see https://github.com/iamkun/dayjs/tree/dev/src/locale for list of locales
|
||||
*/
|
||||
export const languages: { [key: string]: Language } = {
|
||||
["en"]: {
|
||||
name: "English",
|
||||
countryCode: "gb",
|
||||
fullCode: "en-GB",
|
||||
djs: () => import("dayjs/locale/en"),
|
||||
},
|
||||
["sv"]: {
|
||||
name: "Svenska",
|
||||
countryCode: "se",
|
||||
fullCode: "sv-SE",
|
||||
djs: () => import("dayjs/locale/sv"),
|
||||
},
|
||||
["de"]: {
|
||||
name: "Deutsch",
|
||||
countryCode: "de",
|
||||
fullCode: "de-DE",
|
||||
djs: () => import("dayjs/locale/de"),
|
||||
},
|
||||
["es"]: {
|
||||
name: "Español",
|
||||
countryCode: "es",
|
||||
fullCode: "es-ES",
|
||||
djs: () => import("dayjs/locale/es"),
|
||||
},
|
||||
["zh"]: {
|
||||
name: "简体中文",
|
||||
countryCode: "cn",
|
||||
fullCode: "zh-CN",
|
||||
djs: () => import("dayjs/locale/zh-cn"),
|
||||
},
|
||||
["zh-Hant"]: {
|
||||
name: "繁體中文",
|
||||
countryCode: "cn",
|
||||
fullCode: "zh-TW",
|
||||
djs: () => import("dayjs/locale/zh-hk"),
|
||||
},
|
||||
["pl"]: {
|
||||
name: "Polski",
|
||||
countryCode: "pl",
|
||||
fullCode: "pl-PL",
|
||||
djs: () => import("dayjs/locale/pl"),
|
||||
},
|
||||
["ru"]: {
|
||||
name: "Русский",
|
||||
countryCode: "ru",
|
||||
fullCode: "ru-RU",
|
||||
djs: () => import("dayjs/locale/ru"),
|
||||
},
|
||||
["cs"]: {
|
||||
name: "Česky",
|
||||
countryCode: "cz",
|
||||
fullCode: "cs-CZ",
|
||||
djs: () => import("dayjs/locale/cs"),
|
||||
},
|
||||
["nb-NO"]: {
|
||||
name: "Norsk bokmål",
|
||||
countryCode: "no",
|
||||
fullCode: "nb-NO",
|
||||
djs: () => import("dayjs/locale/nb"),
|
||||
},
|
||||
["nl"]: {
|
||||
name: "Nederlands",
|
||||
countryCode: "nl",
|
||||
fullCode: "nl-NL",
|
||||
djs: () => import("dayjs/locale/nl"),
|
||||
},
|
||||
["fr"]: {
|
||||
name: "Français",
|
||||
countryCode: "fr",
|
||||
fullCode: "fr-FR",
|
||||
djs: () => import("dayjs/locale/fr"),
|
||||
},
|
||||
["hu"]: {
|
||||
name: "Magyar",
|
||||
countryCode: "hu",
|
||||
fullCode: "hu-HU",
|
||||
djs: () => import("dayjs/locale/hu"),
|
||||
},
|
||||
["it"]: {
|
||||
name: "Italiano",
|
||||
countryCode: "it",
|
||||
fullCode: "it-IT",
|
||||
djs: () => import("dayjs/locale/it"),
|
||||
},
|
||||
["uk"]: {
|
||||
name: "Українська",
|
||||
countryCode: "ua",
|
||||
fullCode: "uk-UA",
|
||||
djs: () => import("dayjs/locale/uk"),
|
||||
},
|
||||
["el"]: {
|
||||
name: "Ελληνικά",
|
||||
countryCode: "gr",
|
||||
fullCode: "el-GR",
|
||||
djs: () => import("dayjs/locale/el"),
|
||||
},
|
||||
["da"]: {
|
||||
name: "Dansk",
|
||||
countryCode: "dk",
|
||||
fullCode: "da-DK",
|
||||
djs: () => import("dayjs/locale/da"),
|
||||
},
|
||||
["pt"]: {
|
||||
name: "Português",
|
||||
countryCode: "pt",
|
||||
fullCode: "pt-PT",
|
||||
djs: () => import("dayjs/locale/pt"),
|
||||
},
|
||||
["fa"]: {
|
||||
name: "فارسی",
|
||||
countryCode: "ir",
|
||||
fullCode: "fa-IR",
|
||||
djs: () => import("dayjs/locale/fa"),
|
||||
},
|
||||
["ro"]: {
|
||||
name: "Român",
|
||||
countryCode: "ro",
|
||||
fullCode: "ro-RO",
|
||||
djs: () => import("dayjs/locale/ro"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,4 +157,8 @@ i18n
|
||||
fallbackLng: "en",
|
||||
});
|
||||
|
||||
i18n.on("languageChanged", function (lng) {
|
||||
languages[lng].djs().then((djs) => dayjs.locale(djs.name));
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -52,7 +52,6 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
|
||||
// Update colorType state
|
||||
useEffect(() => {
|
||||
console.log(formProps.initialValues?.multi_color_hexes);
|
||||
if (formProps.initialValues?.multi_color_hexes) {
|
||||
setColorType("multi");
|
||||
} else {
|
||||
@@ -325,13 +324,13 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.external_id")}
|
||||
name={["external_id"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
label={t("filament.fields.external_id")}
|
||||
name={["external_id"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
|
||||
181
client/src/pages/locations/components/location.tsx
Normal file
181
client/src/pages/locations/components/location.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Button, Input, theme } from "antd";
|
||||
import type { Identifier, XYCoord } from "dnd-core";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { useTranslate, useUpdate } from "@refinedev/core";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { DragItem, ItemTypes, SpoolDragItem } from "../dnd";
|
||||
import { EMPTYLOC } from "../functions";
|
||||
import { SpoolList } from "./spoolList";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function Location({
|
||||
index,
|
||||
title,
|
||||
spools,
|
||||
showDelete,
|
||||
onDelete,
|
||||
moveLocation,
|
||||
onEditTitle,
|
||||
locationSpoolOrder,
|
||||
setLocationSpoolOrder,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
spools: ISpool[];
|
||||
showDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
moveLocation: (dragIndex: number, hoverIndex: number) => void;
|
||||
onEditTitle: (newTitle: string) => void;
|
||||
locationSpoolOrder: number[];
|
||||
setLocationSpoolOrder: (spoolOrder: number[]) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const [editTitle, setEditTitle] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState(title);
|
||||
const { mutate: updateSpool } = useUpdate({
|
||||
resource: "spool",
|
||||
mutationMode: "optimistic",
|
||||
successNotification: false,
|
||||
});
|
||||
|
||||
const moveSpoolLocation = (spool_id: number, location: string) => {
|
||||
updateSpool({
|
||||
id: spool_id,
|
||||
values: {
|
||||
location: location,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const dropTypes = title == EMPTYLOC ? [ItemTypes.SPOOL] : [ItemTypes.CONTAINER, ItemTypes.SPOOL];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: dropTypes,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!ref.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("spool" in item) {
|
||||
// Only allow dropping spools on the container if it's empty.
|
||||
if (spools.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spoolitem = item as SpoolDragItem;
|
||||
if (spoolitem.spool.location !== title) {
|
||||
moveSpoolLocation(spoolitem.spool.id, title);
|
||||
spoolitem.spool.location = title;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get horizontal middle
|
||||
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the left
|
||||
const hoverClientX = (clientOffset as XYCoord).x - hoverBoundingRect.left;
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveLocation(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CONTAINER,
|
||||
canDrag: !editTitle && title != EMPTYLOC,
|
||||
item: () => {
|
||||
return { title, index };
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const displayTitle = title == EMPTYLOC ? t("locations.no_location") : title;
|
||||
|
||||
const opacity = isDragging ? 0 : 1;
|
||||
drag(drop(ref));
|
||||
|
||||
const canEditTitle = title != EMPTYLOC;
|
||||
|
||||
const titleStyle = {
|
||||
color: canEditTitle ? undefined : token.colorTextTertiary,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"loc-container " + (title != EMPTYLOC ? "grabable" : "")}
|
||||
ref={ref}
|
||||
style={{ opacity }}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
<h3>
|
||||
{editTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
variant="borderless"
|
||||
value={newTitle}
|
||||
onBlur={() => setEditTitle(false)}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setEditTitle(false);
|
||||
return onEditTitle(newTitle);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={canEditTitle ? "editable" : ""}
|
||||
onClick={() => {
|
||||
if (!canEditTitle) return;
|
||||
setNewTitle(title);
|
||||
setEditTitle(true);
|
||||
}}
|
||||
style={titleStyle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
{showDelete && <Button icon={<DeleteOutlined />} size="small" type="text" onClick={onDelete} />}
|
||||
</h3>
|
||||
<SpoolList spools={spools} spoolOrder={locationSpoolOrder} setSpoolOrder={setLocationSpoolOrder} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
client/src/pages/locations/components/locationContainer.tsx
Normal file
200
client/src/pages/locations/components/locationContainer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useList, useTranslate } from "@refinedev/core";
|
||||
import { Button } from "antd";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSetSetting } from "../../../utils/querySettings";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { EMPTYLOC, useLocations, useLocationsSpoolOrders, useRenameSpoolLocation } from "../functions";
|
||||
import { Location } from "./location";
|
||||
|
||||
export function LocationContainer() {
|
||||
const t = useTranslate();
|
||||
const renameSpoolLocation = useRenameSpoolLocation();
|
||||
|
||||
const settingsLocations = useLocations();
|
||||
const setLocationsSetting = useSetSetting<string[]>("locations");
|
||||
|
||||
const locationsSpoolOrders = useLocationsSpoolOrders();
|
||||
const setLocationsSpoolOrders = useSetSetting<Record<string, number[]>>("locations_spoolorders");
|
||||
|
||||
const {
|
||||
data: spoolData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useList<ISpool>({
|
||||
resource: "spool",
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: false,
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
mode: "off",
|
||||
},
|
||||
});
|
||||
|
||||
// Group spools by location
|
||||
const spoolLocations = (() => {
|
||||
const spools = spoolData?.data ?? [];
|
||||
spools.sort((a, b) => a.id - b.id);
|
||||
|
||||
const grouped: Record<string, ISpool[]> = {};
|
||||
spools.forEach((spool) => {
|
||||
const loc = spool.location ?? EMPTYLOC;
|
||||
if (!grouped[loc]) {
|
||||
grouped[loc] = [];
|
||||
}
|
||||
grouped[loc].push(spool);
|
||||
});
|
||||
|
||||
// Sort spools in the locations by the spool order
|
||||
for (const loc of Object.keys(grouped)) {
|
||||
if (!locationsSpoolOrders[loc]) {
|
||||
continue;
|
||||
}
|
||||
grouped[loc].sort((a, b) => {
|
||||
let aidx = locationsSpoolOrders[loc].indexOf(a.id);
|
||||
if (aidx === -1) {
|
||||
aidx = 999999;
|
||||
}
|
||||
let bidx = locationsSpoolOrders[loc].indexOf(b.id);
|
||||
if (bidx === -1) {
|
||||
bidx = 999999;
|
||||
}
|
||||
return aidx - bidx;
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
})();
|
||||
|
||||
// Create list of locations that's sorted
|
||||
const locationsList = useMemo(() => {
|
||||
// Start with the default loc
|
||||
let allLocs = [];
|
||||
if (EMPTYLOC in spoolLocations) {
|
||||
allLocs.push(EMPTYLOC);
|
||||
}
|
||||
|
||||
// Add from the locations setting
|
||||
if (settingsLocations) allLocs.push(...settingsLocations);
|
||||
|
||||
// Add any missing locations from the spools
|
||||
for (const loc of Object.keys(spoolLocations)) {
|
||||
if (loc != EMPTYLOC && !allLocs.includes(loc)) {
|
||||
allLocs.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
return allLocs;
|
||||
}, [spoolLocations, settingsLocations]);
|
||||
|
||||
const moveLocation = (dragIndex: number, hoverIndex: number) => {
|
||||
const newLocs = [...locationsList];
|
||||
newLocs.splice(dragIndex, 1);
|
||||
newLocs.splice(hoverIndex, 0, locationsList[dragIndex]);
|
||||
setLocationsSetting.mutate(newLocs.filter((loc) => loc != EMPTYLOC));
|
||||
};
|
||||
|
||||
const onEditTitle = async (location: string, newTitle: string) => {
|
||||
if (location == "") return; // Can't edit the default location
|
||||
if (newTitle == location) return; // No change
|
||||
if (newTitle == "") return; // Can't have an empty location
|
||||
if (locationsList.includes(newTitle)) return; // Location already exists
|
||||
|
||||
// Update all spool locations in the database
|
||||
if (spoolLocations[location] && spoolLocations[location].length > 0) {
|
||||
renameSpoolLocation.mutate({ old: location, new: newTitle });
|
||||
}
|
||||
|
||||
// Update the value in the settings
|
||||
const newLocs = [...locationsList].filter((loc) => loc != EMPTYLOC);
|
||||
newLocs[locationsList.indexOf(location)] = newTitle;
|
||||
setLocationsSetting.mutate(newLocs);
|
||||
};
|
||||
|
||||
const setLocationSpoolOrder = (location: string, spoolOrder: number[]) => {
|
||||
setLocationsSpoolOrders.mutate({
|
||||
...locationsSpoolOrders,
|
||||
[location]: spoolOrder,
|
||||
});
|
||||
};
|
||||
|
||||
// Create containers
|
||||
const containers = locationsList.map((loc, idx) => {
|
||||
const spools = spoolLocations[loc] ?? [];
|
||||
const spoolOrder = locationsSpoolOrders[loc] ?? [];
|
||||
|
||||
return (
|
||||
<Location
|
||||
key={loc}
|
||||
index={idx}
|
||||
title={loc}
|
||||
spools={spools}
|
||||
showDelete={spools.length == 0}
|
||||
onDelete={() => {
|
||||
setLocationsSetting.mutate(locationsList.filter((l) => l !== loc));
|
||||
}}
|
||||
moveLocation={moveLocation}
|
||||
onEditTitle={(newTitle: string) => onEditTitle(loc, newTitle)}
|
||||
locationSpoolOrder={spoolOrder}
|
||||
setLocationSpoolOrder={(spoolOrder: number[]) => setLocationSpoolOrder(loc, spoolOrder)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Update locations settings so it always includes all spool locations
|
||||
useEffect(() => {
|
||||
// Check if they're not the same
|
||||
const curLocList = locationsList.filter((l) => l != EMPTYLOC);
|
||||
if (settingsLocations != null && JSON.stringify(curLocList) !== JSON.stringify(settingsLocations)) {
|
||||
setLocationsSetting.mutate(curLocList);
|
||||
}
|
||||
}, [locationsList, settingsLocations, setLocationsSetting]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Failed to load spools</div>;
|
||||
}
|
||||
|
||||
const addNewLocation = () => {
|
||||
const baseLocationName = t("locations.new_location");
|
||||
let newLocationName = baseLocationName;
|
||||
|
||||
const newLocs = [...locationsList];
|
||||
let i = 1;
|
||||
while (newLocs.includes(newLocationName)) {
|
||||
newLocationName = baseLocationName + " " + i;
|
||||
i++;
|
||||
}
|
||||
newLocs.push(newLocationName);
|
||||
|
||||
setLocationsSetting.mutate(newLocs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isLoading && spoolData.data.length == 0 && (
|
||||
<div className="no-locations">{t("locations.no_locations_help")}</div>
|
||||
)}
|
||||
<div className="loc-metacontainer">
|
||||
{containers}
|
||||
<div className="newLocContainer">
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
style={{
|
||||
margin: "1em",
|
||||
}}
|
||||
onClick={addNewLocation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
client/src/pages/locations/components/spoolCard.tsx
Normal file
186
client/src/pages/locations/components/spoolCard.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useNavigation, useTranslate, useUpdate } from "@refinedev/core";
|
||||
import { Button, theme } from "antd";
|
||||
import type { Identifier, XYCoord } from "dnd-core";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
|
||||
import { EditOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import SpoolIcon from "../../../components/spoolIcon";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { ItemTypes, SpoolDragItem, useCurrentDraggedSpool } from "../dnd";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function SpoolCard({
|
||||
index,
|
||||
spool,
|
||||
moveSpoolOrder,
|
||||
}: {
|
||||
index: number;
|
||||
spool: ISpool;
|
||||
moveSpoolOrder: (dragIndex: number, hoverIndex: number) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const { showUrl } = useNavigation();
|
||||
|
||||
// Using a global state for this, because the drag handlers are reset when the spool changes location
|
||||
const { draggedSpoolId, setDraggedSpoolId } = useCurrentDraggedSpool();
|
||||
|
||||
const { mutate: updateSpool } = useUpdate({
|
||||
resource: "spool",
|
||||
mutationMode: "optimistic",
|
||||
successNotification: false,
|
||||
});
|
||||
|
||||
const moveSpoolLocation = (spool_id: number, location: string) => {
|
||||
updateSpool({
|
||||
id: spool_id,
|
||||
values: {
|
||||
location: location,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<SpoolDragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: ItemTypes.SPOOL,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!ref.current || item.spool.id === spool.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.spool.location !== spool.location && spool.location) {
|
||||
moveSpoolLocation(item.spool.id, spool.location);
|
||||
item.spool.location = spool.location;
|
||||
return;
|
||||
}
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get horizontal middle
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveSpoolOrder(item.spool.id, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.SPOOL,
|
||||
item: () => {
|
||||
return { spool, index };
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
end() {
|
||||
setDraggedSpoolId(-1);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setDraggedSpoolId(spool.id);
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const colorObj = spool.filament.multi_color_hexes
|
||||
? {
|
||||
colors: spool.filament.multi_color_hexes.split(","),
|
||||
vertical: spool.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: spool.filament.color_hex || "#000000";
|
||||
|
||||
let filament_name: string;
|
||||
if (spool.filament.vendor && "name" in spool.filament.vendor) {
|
||||
filament_name = `${spool.filament.vendor.name} - ${spool.filament.name}`;
|
||||
} else {
|
||||
filament_name = spool.filament.name ?? spool.filament.id.toString();
|
||||
}
|
||||
|
||||
const opacity = draggedSpoolId === spool.id ? 0 : 1;
|
||||
const style = {
|
||||
opacity,
|
||||
backgroundColor: token.colorBgContainerDisabled,
|
||||
};
|
||||
drag(drop(ref));
|
||||
|
||||
function formatSubtitle(spool: ISpool) {
|
||||
let str = "";
|
||||
if (spool.filament.material) str += spool.filament.material + " - ";
|
||||
if (spool.filament.weight) {
|
||||
const remaining_weight = spool.remaining_weight ?? spool.filament.weight;
|
||||
str += `${remaining_weight} / ${spool.filament.weight} g`;
|
||||
}
|
||||
if (spool.last_used) {
|
||||
// Format like "last used X time ago"
|
||||
const dt = dayjs(spool.last_used);
|
||||
str += ` - ${t("spool.formats.last_used", { date: dt.fromNow() })}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="spool" ref={ref} style={style} data-handler-id={handlerId}>
|
||||
<SpoolIcon color={colorObj} />
|
||||
<div className="info">
|
||||
<div className="title">
|
||||
<span>
|
||||
#{spool.id} {filament_name}
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/spool/edit/${spool.id}?return=` + encodeURIComponent(window.location.pathname)}>
|
||||
<Button icon={<EditOutlined />} title={t("buttons.edit")} size="small" type="text" />
|
||||
</Link>
|
||||
<Link to={showUrl("spool", spool.id)}>
|
||||
<Button icon={<EyeOutlined />} title={t("buttons.show")} size="small" type="text" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="subtitle"
|
||||
style={{
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{formatSubtitle(spool)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
client/src/pages/locations/components/spoolList.tsx
Normal file
54
client/src/pages/locations/components/spoolList.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { theme } from "antd";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { SpoolCard } from "./spoolCard";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function SpoolList({
|
||||
spools,
|
||||
spoolOrder,
|
||||
setSpoolOrder,
|
||||
}: {
|
||||
spools: ISpool[];
|
||||
spoolOrder: number[];
|
||||
setSpoolOrder: (spoolOrder: number[]) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
|
||||
// Make sure all spools are in the spoolOrders array
|
||||
const finalSpoolOrder = [...spoolOrder].filter((id) => spools.find((spool) => spool.id === id)); // Remove any spools that are not in the spools array
|
||||
spools.forEach((spool) => {
|
||||
if (!finalSpoolOrder.includes(spool.id)) finalSpoolOrder.push(spool.id);
|
||||
});
|
||||
|
||||
const moveSpoolOrder = (spool_id: number, hoverIndex: number) => {
|
||||
// Move spool spool_id to position hoverIndex
|
||||
let curIdx = finalSpoolOrder.indexOf(spool_id);
|
||||
if (curIdx === -1) {
|
||||
// Spool is missing from spool order array, add it to the end of the array
|
||||
finalSpoolOrder.push(spool_id);
|
||||
curIdx = finalSpoolOrder.length - 1;
|
||||
} else if (curIdx === hoverIndex) {
|
||||
// Spool is already in the right position
|
||||
return;
|
||||
}
|
||||
|
||||
const newSpoolOrder = [...finalSpoolOrder];
|
||||
newSpoolOrder.splice(curIdx, 1);
|
||||
newSpoolOrder.splice(hoverIndex, 0, finalSpoolOrder[curIdx]);
|
||||
setSpoolOrder(newSpoolOrder);
|
||||
};
|
||||
|
||||
const style = {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="loc-spools" style={style}>
|
||||
{spools.map((spool, idx) => (
|
||||
<SpoolCard key={spool.id} index={idx} spool={spool} moveSpoolOrder={moveSpoolOrder} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
client/src/pages/locations/dnd.ts
Normal file
29
client/src/pages/locations/dnd.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
import { ISpool } from "../spools/model";
|
||||
|
||||
export const ItemTypes = {
|
||||
SPOOL: "spool",
|
||||
CONTAINER: "spool-container",
|
||||
};
|
||||
|
||||
export interface DragItem {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SpoolDragItem extends DragItem {
|
||||
spool: ISpool;
|
||||
}
|
||||
|
||||
export interface ContainerDragItem extends DragItem {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CurrentDraggedSpool {
|
||||
draggedSpoolId: number;
|
||||
setDraggedSpoolId: (spoolid: number) => void;
|
||||
}
|
||||
|
||||
export const useCurrentDraggedSpool = create<CurrentDraggedSpool>((set) => ({
|
||||
draggedSpoolId: -1,
|
||||
setDraggedSpoolId: (spoolid: number) => set({ draggedSpoolId: spoolid }),
|
||||
}));
|
||||
109
client/src/pages/locations/functions.ts
Normal file
109
client/src/pages/locations/functions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { GetListResponse } from "@refinedev/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useGetSetting } from "../../utils/querySettings";
|
||||
import { getAPIURL } from "../../utils/url";
|
||||
import { ISpool } from "../spools/model";
|
||||
|
||||
export const EMPTYLOC = "";
|
||||
|
||||
interface LocationRename {
|
||||
old: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export function useRenameSpoolLocation() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["default", "spool"];
|
||||
const queryKeyList = ["default", "spool", "list"];
|
||||
|
||||
return useMutation<string, unknown, LocationRename, undefined>({
|
||||
mutationFn: async (value) => {
|
||||
const response = await fetch(getAPIURL() + "/location/" + value.old, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: value.new,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.text();
|
||||
},
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(queryKeyList);
|
||||
|
||||
// Optimistically update all spools with matching location to the new one
|
||||
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
|
||||
if (old) {
|
||||
return {
|
||||
data: old.data.map((spool) => {
|
||||
if (spool.location === value.old) {
|
||||
return { ...spool, location: value.new };
|
||||
}
|
||||
return spool;
|
||||
}),
|
||||
total: old.total,
|
||||
};
|
||||
}
|
||||
return old;
|
||||
});
|
||||
},
|
||||
onError: (_error, value, _context) => {
|
||||
// Mutation failed, reset spools with matching location to the old one
|
||||
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
|
||||
if (old) {
|
||||
return {
|
||||
data: old.data.map((spool) => {
|
||||
if (spool.location === value.new) {
|
||||
return { ...spool, location: value.old };
|
||||
}
|
||||
return spool;
|
||||
}),
|
||||
total: old.total,
|
||||
};
|
||||
}
|
||||
return old;
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, _value) => {
|
||||
// Mutation succeeded, refetch
|
||||
queryClient.invalidateQueries(queryKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocations(): string[] | null {
|
||||
const query = useGetSetting("locations");
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query.data) return null;
|
||||
|
||||
try {
|
||||
let data = (JSON.parse(query.data.value) ?? []) as string[];
|
||||
data = data.filter((location) => location != null && location.length > 0);
|
||||
return data;
|
||||
} catch {
|
||||
console.warn("Failed to parse locations", query.data.value);
|
||||
return null;
|
||||
}
|
||||
}, [query.data]);
|
||||
}
|
||||
|
||||
export function useLocationsSpoolOrders(): Record<string, number[]> {
|
||||
const query = useGetSetting("locations_spoolorders");
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query.data) return {};
|
||||
|
||||
try {
|
||||
return (JSON.parse(query.data.value) ?? {}) as Record<string, number[]>;
|
||||
} catch {
|
||||
console.warn("Failed to parse locations spool orders", query.data.value);
|
||||
return {};
|
||||
}
|
||||
}, [query.data]);
|
||||
}
|
||||
25
client/src/pages/locations/index.tsx
Normal file
25
client/src/pages/locations/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
import { LocationContainer } from "./components/locationContainer";
|
||||
import "./locations.css";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export const Locations: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("locations.locations")}</h1>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<LocationContainer />
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Locations;
|
||||
75
client/src/pages/locations/locations.css
Normal file
75
client/src/pages/locations/locations.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.loc-metacontainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loc-container {
|
||||
padding: 1em;
|
||||
width: 24em;
|
||||
}
|
||||
|
||||
.loc-container.grabable,
|
||||
.loc-container .spool {
|
||||
cursor: move;
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
|
||||
.loc-container h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 21px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container h3 span {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.loc-container h3 span.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.loc-container h3 input {
|
||||
font-size: 21px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.loc-container .loc-spools {
|
||||
padding: 0.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
min-height: 3em;
|
||||
max-height: 50em;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.loc-container .spool {
|
||||
padding: 0.5em 0.5em 0.5em 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.loc-container .spool .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container .spool .info .title {
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container .spool .info .subtitle {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
@@ -100,17 +100,13 @@ export function renderLabelContents(template: string, spool: ISpool): JSX.Elemen
|
||||
// Find all {tags} in the template string and loop over them
|
||||
// let matches = [...template.matchAll(/(?:{(.*?))?{(.*?)}(.*?)(?:}(.*?))?/gs)];
|
||||
let matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
|
||||
// console.log(matches){(?:[^}{]|{[^}{]*})*}
|
||||
let label_text = template;
|
||||
matches.forEach((match) => {
|
||||
// console.log(match)
|
||||
if ((match[0].match(/{/g)||[]).length == 1) {
|
||||
if ((match[0].match(/{/g) || []).length == 1) {
|
||||
let tag = match[0].replace(/[{}]/g, "");
|
||||
// console.log(tag)
|
||||
let tagValue = getTagValue(tag, spool)
|
||||
let tagValue = getTagValue(tag, spool);
|
||||
label_text = label_text.replace(match[0], tagValue);
|
||||
}
|
||||
else if ((match[0].match(/{/g)||[]).length == 2) {
|
||||
} else if ((match[0].match(/{/g) || []).length == 2) {
|
||||
let structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
|
||||
if (structure != null) {
|
||||
const tag = structure[2];
|
||||
|
||||
@@ -5,6 +5,7 @@ import TextArea from "antd/es/input/TextArea";
|
||||
import { message } from "antd/lib";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||
import { searchMatches } from "../../utils/filtering";
|
||||
@@ -12,6 +13,7 @@ import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { createFilamentFromExternal } from "../filaments/functions";
|
||||
import { useLocations } from "../locations/functions";
|
||||
import { useGetFilamentSelectOptions } from "./functions";
|
||||
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
|
||||
|
||||
@@ -32,6 +34,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
const extraFields = useGetFields(EntityType.spool);
|
||||
const currency = useCurrency();
|
||||
const [searchParams, _] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
|
||||
liveMode: "manual",
|
||||
@@ -40,6 +44,17 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
messageApi.warning(t("spool.form.spool_updated"));
|
||||
setHasChanged(true);
|
||||
},
|
||||
|
||||
// Custom redirect logic
|
||||
redirect: false,
|
||||
onMutationSuccess: () => {
|
||||
const returnUrl = searchParams.get("return");
|
||||
if (returnUrl) {
|
||||
navigate(returnUrl, { relative: "path" });
|
||||
} else {
|
||||
navigate("/spool");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initialWeightValue = Form.useWatch("initial_weight", form);
|
||||
@@ -118,7 +133,6 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
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);
|
||||
}
|
||||
@@ -135,9 +149,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
};
|
||||
|
||||
const locations = useSpoolmanLocations(true);
|
||||
const settingsLocation = useLocations();
|
||||
const [newLocation, setNewLocation] = useState("");
|
||||
|
||||
const allLocations = [...(locations.data || [])];
|
||||
const allLocations = [...(settingsLocation || [])];
|
||||
locations?.data?.forEach((loc) => {
|
||||
if (!allLocations.includes(loc)) {
|
||||
allLocations.push(loc);
|
||||
}
|
||||
});
|
||||
if (newLocation.trim() && !allLocations.includes(newLocation)) {
|
||||
allLocations.push(newLocation.trim());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {InboxOutlined, PrinterOutlined, ToTopOutlined} from "@ant-design/icons";
|
||||
import { InboxOutlined, PrinterOutlined, ToTopOutlined } from "@ant-design/icons";
|
||||
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
|
||||
import {IResourceComponentsProps, useInvalidate, useShow, useTranslate} from "@refinedev/core";
|
||||
import {Button, Modal, Typography} from "antd";
|
||||
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
|
||||
import { Button, Modal, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
@@ -12,8 +12,8 @@ import { enrichText } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { useCurrency } from "../../utils/settings";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { setSpoolArchived } from "./functions";
|
||||
import { ISpool } from "./model";
|
||||
import {setSpoolArchived} from "./functions";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -52,9 +52,9 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const archiveSpoolPopup = async (spool: ISpool|undefined) => {
|
||||
const archiveSpoolPopup = async (spool: ISpool | undefined) => {
|
||||
if (spool === undefined) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// If the spool has no remaining weight, archive it immediately since it's likely not a mistake
|
||||
if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) {
|
||||
@@ -116,27 +116,20 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
headerButtons={({ defaultButtons }) => (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
href={"/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
href={"/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
|
||||
>
|
||||
{t("printing.qrcode.button")}
|
||||
</Button>
|
||||
{record?.archived ? (
|
||||
<Button
|
||||
icon={<ToTopOutlined />}
|
||||
onClick={() => archiveSpool(record, false)}
|
||||
>
|
||||
{t("buttons.unArchive")}
|
||||
</Button>
|
||||
<Button icon={<ToTopOutlined />} onClick={() => archiveSpool(record, false)}>
|
||||
{t("buttons.unArchive")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
onClick={() => archiveSpoolPopup(record)}
|
||||
>
|
||||
{t("buttons.archive")}
|
||||
</Button>
|
||||
<Button danger icon={<InboxOutlined />} onClick={() => archiveSpoolPopup(record)}>
|
||||
{t("buttons.archive")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{defaultButtons}
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import filament, spool
|
||||
@@ -123,3 +124,25 @@ async def find_locations(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> list[str]:
|
||||
return await spool.find_locations(db=db)
|
||||
|
||||
|
||||
class RenameLocationBody(BaseModel):
|
||||
name: str = Field(description="The new name of the location.", min_length=1)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/location/{location}",
|
||||
name="Rename location",
|
||||
description="Rename a spool location. All spools in this location will be moved to the new location.",
|
||||
response_model_exclude_none=True,
|
||||
response_model=RootModel[str],
|
||||
)
|
||||
async def rename_location(
|
||||
location: str,
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
body: RenameLocationBody,
|
||||
) -> str:
|
||||
logger.info("Renaming location %s to %s", location, body.name)
|
||||
await spool.rename_location(db=db, current_name=location, new_name=body.name)
|
||||
return body.name
|
||||
|
||||
@@ -462,3 +462,15 @@ async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -
|
||||
await db.commit()
|
||||
await spool_changed(spool, EventType.UPDATED)
|
||||
return spool
|
||||
|
||||
|
||||
async def rename_location(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
current_name: str,
|
||||
new_name: str,
|
||||
) -> None:
|
||||
"""Rename all spools with the current location name to the new name."""
|
||||
await db.execute(
|
||||
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
|
||||
)
|
||||
|
||||
@@ -68,3 +68,6 @@ register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("base_url", SettingType.STRING, json.dumps(""))
|
||||
|
||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
|
||||
Reference in New Issue
Block a user