Added a basic web UI

This commit is contained in:
Donkie
2023-05-10 21:11:10 +02:00
parent 1c59e0efd5
commit 0269518175
39 changed files with 10950 additions and 1 deletions

View File

@@ -1,2 +1,10 @@
.git .git
.github
.venv
.vscode
build
Dockerfile Dockerfile
tests
client
!client/dist

16
client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,16 @@
/* eslint-env node */
module.exports = {
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": "warn",
},
};

23
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

2
client/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
legacy-peer-deps=true
strict-peer-dependencies=false

3
client/README.MD Normal file
View File

@@ -0,0 +1,3 @@
# Spoolman UI
A simple [refine](https://github.com/refinedev/refine) based UI for manipulating the data in the Spoolman database.

22
client/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Spoolman UI" />
<meta name="robots" content="noindex,nofollow" />
<title>
Spoolman
</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

9244
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
client/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "spoolman-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@refinedev/cli": "^2.5.1",
"@refinedev/core": "^4.5.8",
"@refinedev/inferencer": "^3.1.4",
"@refinedev/kbar": "^1.1.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.8.1",
"@refinedev/simple-rest": "^4.0.0",
"@refinedev/antd": "^5.7.0",
"antd": "^5.0.5",
"@ant-design/icons": "^5.0.1",
"i18next": "^20.1.0",
"react-i18next": "^11.8.11",
"i18next-browser-languagedetector": "^6.1.1",
"i18next-xhr-backend": "^3.2.2",
"@refinedev/react-router-v6": "^4.1.0"
},
"devDependencies": {
"@types/node": "^18.16.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"typescript": "^4.7.4",
"vite": "^4.3.1"
},
"scripts": {
"dev": "refine dev",
"build": "tsc && refine build",
"preview": "refine start",
"refine": "refine"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480"><defs><clipPath id="a"><path fill-opacity=".67" d="M-85.333 0h682.67v512h-682.67z"/></clipPath></defs><g clip-path="url(#a)" transform="translate(80) scale(.94)"><g stroke-width="1pt"><path fill="#006" d="M-256 0H768.02v512.01H-256z"/><path fill="#fff" d="M-256 0v57.244l909.535 454.768H768.02V454.77L-141.515 0H-256zM768.02 0v57.243L-141.515 512.01H-256v-57.243L653.535 0H768.02z"/><path fill="#fff" d="M170.675 0v512.01h170.67V0h-170.67zM-256 170.67v170.67H768.02V170.67H-256z"/><path fill="#c00" d="M-256 204.804v102.402H768.02V204.804H-256zM204.81 0v512.01h102.4V0h-102.4zM-256 512.01L85.34 341.34h76.324l-341.34 170.67H-256zM-256 0L85.34 170.67H9.016L-256 38.164V0zm606.356 170.67L691.696 0h76.324L426.68 170.67h-76.324zM768.02 512.01L426.68 341.34h76.324L768.02 473.848v38.162z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 887 B

View File

@@ -0,0 +1,145 @@
{
"pages": {
"login": {
"title": "Sign in to your account",
"signin": "Sign in",
"signup": "Sign up",
"divider": "or",
"fields": {
"email": "Email",
"password": "Password"
},
"errors": {
"validEmail": "Invalid email address"
},
"buttons": {
"submit": "Login",
"forgotPassword": "Forgot password?",
"noAccount": "Dont have an account?",
"rememberMe": "Remember me"
}
},
"forgotPassword": {
"title": "Forgot your password?",
"fields": {
"email": "Email"
},
"errors": {
"validEmail": "Invalid email address"
},
"buttons": {
"submit": "Send reset instructions"
}
},
"register": {
"title": "Sign up for your account",
"fields": {
"email": "Email",
"password": "Password"
},
"errors": {
"validEmail": "Invalid email address"
},
"buttons": {
"submit": "Register",
"haveAccount": "Have an account?"
}
},
"updatePassword": {
"title": "Update password",
"fields": {
"password": "New Password",
"confirmPassword": "Confirm new password"
},
"errors": {
"confirmPasswordNotMatch": "Passwords do not match"
},
"buttons": {
"submit": "Update"
}
},
"error": {
"info": "You may have forgotten to add the {{action}} component to {{resource}} resource.",
"404": "Sorry, the page you visited does not exist.",
"resource404": "Are you sure you have created the {{resource}} resource.",
"backHome": "Back Home"
}
},
"actions": {
"list": "List",
"create": "Create",
"edit": "Edit",
"show": "Show"
},
"buttons": {
"create": "Create",
"save": "Save",
"logout": "Logout",
"delete": "Delete",
"edit": "Edit",
"cancel": "Cancel",
"confirm": "Are you sure?",
"filter": "Filter",
"clear": "Clear",
"refresh": "Refresh",
"show": "Show",
"undo": "Undo",
"import": "Import",
"clone": "Clone",
"notAccessTitle": "You don't have permission to access"
},
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
"notifications": {
"success": "Successful",
"error": "Error (status code: {{statusCode}})",
"undoable": "You have {{seconds}} seconds to undo",
"createSuccess": "Successfully created {{resource}}",
"createError": "There was an error creating {{resource}} (status code: {{statusCode}})",
"deleteSuccess": "Successfully deleted {{resource}}",
"deleteError": "Error when deleting {{resource}} (status code: {{statusCode}})",
"editSuccess": "Successfully edited {{resource}}",
"editError": "Error when editing {{resource}} (status code: {{statusCode}})",
"importProgress": "Importing: {{processed}}/{{total}}"
},
"loading": "Loading",
"tags": {
"clone": "Clone"
},
"dashboard": {
"title": "Dashboard"
},
"blog_posts": {
"blog_posts": "Blog Posts",
"fields": {
"id": "Id",
"title": "Title",
"content": "Content",
"status": "Status",
"category": "Category",
"createdAt": "Created At"
},
"titles": {
"create": "Create Blog Post",
"edit": "Edit Blog Post",
"list": "Blog Posts",
"show": "Show Blog Post"
}
},
"categories": {
"categories": "Categories",
"fields": {
"id": "Id",
"title": "Title",
"createdAt": "Created At"
},
"titles": {
"create": "Create Category",
"edit": "Edit Category",
"list": "Categories",
"show": "Show Category"
}
},
"table": {
"actions": "Actions"
}
}

145
client/src/App.tsx Normal file
View File

@@ -0,0 +1,145 @@
import { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
ErrorComponent,
notificationProvider,
ThemedLayoutV2,
ThemedSiderV2,
ThemedTitleV2,
} from "@refinedev/antd";
import "@refinedev/antd/dist/reset.css";
import routerBindings, {
NavigateToResource,
UnsavedChangesNotifier,
} from "@refinedev/react-router-v6";
import dataProvider from "@refinedev/simple-rest";
import { useTranslation } from "react-i18next";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { AppIcon } from "./components/app-icon";
import { Header } from "./components/header";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { SpoolCreate, SpoolEdit, SpoolList, SpoolShow } from "./pages/spools";
import {
FilamentCreate,
FilamentEdit,
FilamentList,
FilamentShow,
} from "./pages/filaments";
import {
VendorCreate,
VendorEdit,
VendorList,
VendorShow,
} from "./pages/vendors";
function App() {
const { t, i18n } = useTranslation();
const i18nProvider = {
translate: (key: string, params: object) => t(key, params),
changeLocale: (lang: string) => i18n.changeLanguage(lang),
getLocale: () => i18n.language,
};
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider("/api/v1")}
notificationProvider={notificationProvider}
i18nProvider={i18nProvider}
routerProvider={routerBindings}
resources={[
{
name: "spool",
list: "/spool",
create: "/spool/create",
edit: "/spool/edit/:id",
show: "/spool/show/:id",
meta: {
canDelete: true,
},
},
{
name: "filament",
list: "/filament",
create: "/filament/create",
edit: "/filament/edit/:id",
show: "/filament/show/:id",
meta: {
canDelete: true,
},
},
{
name: "vendor",
list: "/vendor",
create: "/vendor/create",
edit: "/vendor/edit/:id",
show: "/vendor/show/:id",
meta: {
canDelete: true,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2
Header={() => <Header sticky />}
Sider={() => <ThemedSiderV2 fixed />}
Title={({ collapsed }) => (
<ThemedTitleV2
collapsed={collapsed}
text="Spoolman"
icon={<AppIcon />}
/>
)}
>
<Outlet />
</ThemedLayoutV2>
}
>
<Route
index
element={<NavigateToResource resource="spool" />}
/>
<Route path="/spool">
<Route index element={<SpoolList />} />
<Route path="create" element={<SpoolCreate />} />
<Route path="edit/:id" element={<SpoolEdit />} />
<Route path="show/:id" element={<SpoolShow />} />
</Route>
<Route path="/filament">
<Route index element={<FilamentList />} />
<Route path="create" element={<FilamentCreate />} />
<Route path="edit/:id" element={<FilamentEdit />} />
<Route path="show/:id" element={<FilamentShow />} />
</Route>
<Route path="/vendor">
<Route index element={<VendorList />} />
<Route path="create" element={<VendorCreate />} />
<Route path="edit/:id" element={<VendorEdit />} />
<Route path="show/:id" element={<VendorShow />} />
</Route>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,19 @@
import React from "react";
export const AppIcon: React.FC = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 16 16"
fill="none"
>
<g fill="currentColor">
<path d="M8 6c4.418 0 8-1.343 8-3s-3.582-3-8-3-8 1.343-8 3 3.582 3 8 3z" />
<path d="M8 8c4.418 0 8-1.343 8-3v3c0 1.657-3.582 3-8 3S0 9.657 0 8V5c0 1.657 3.582 3 8 3z" />
<path d="M8 13c4.418 0 8-1.343 8-3v3c0 1.657-3.582 3-8 3s-8-1.343-8-3v-3c0 1.657 3.582 3 8 3z" />
</g>
</svg>
);
};

View File

@@ -0,0 +1,98 @@
import { DownOutlined } from "@ant-design/icons";
import type { RefineThemedLayoutV2HeaderProps } from "@refinedev/antd";
import { useGetIdentity, useGetLocale, useSetLocale } from "@refinedev/core";
import {
Avatar,
Button,
Dropdown,
Layout as AntdLayout,
MenuProps,
Space,
Switch,
theme,
Typography,
} from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { ColorModeContext } from "../../contexts/color-mode";
const { Text } = Typography;
const { useToken } = theme;
type IUser = {
id: number;
name: string;
avatar: string;
};
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
sticky,
}) => {
const { token } = useToken();
const { i18n } = useTranslation();
const locale = useGetLocale();
const changeLanguage = useSetLocale();
const { data: user } = useGetIdentity<IUser>();
const { mode, setMode } = useContext(ColorModeContext);
const currentLocale = locale();
const menuItems: MenuProps["items"] = [...(i18n.languages || [])]
.sort()
.map((lang: string) => ({
key: lang,
onClick: () => changeLanguage(lang),
icon: (
<span style={{ marginRight: 8 }}>
<Avatar size={16} src={`/images/flags/${lang}.svg`} />
</span>
),
label: lang === "en" ? "English" : "German",
}));
const headerStyles: React.CSSProperties = {
backgroundColor: token.colorBgElevated,
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "0px 24px",
height: "64px",
};
if (sticky) {
headerStyles.position = "sticky";
headerStyles.top = 0;
headerStyles.zIndex = 1;
}
return (
<AntdLayout.Header style={headerStyles}>
<Space>
<Dropdown
menu={{
items: menuItems,
selectedKeys: currentLocale ? [currentLocale] : [],
}}
>
<Button type="text">
<Space>
<Avatar size={16} src={`/images/flags/${currentLocale}.svg`} />
{currentLocale === "en" ? "English" : "German"}
<DownOutlined />
</Space>
</Button>
</Dropdown>
<Switch
checkedChildren="🌛"
unCheckedChildren="🔆"
onChange={() => setMode(mode === "light" ? "dark" : "light")}
defaultChecked={mode === "dark"}
/>
<Space style={{ marginLeft: "8px" }} size="middle">
{user?.name && <Text strong>{user.name}</Text>}
{user?.avatar && <Avatar src={user?.avatar} alt={user?.name} />}
</Space>
</Space>
</AntdLayout.Header>
);
};

View File

@@ -0,0 +1 @@
export { Header } from "./header";

View File

@@ -0,0 +1,59 @@
import { RefineThemes } from "@refinedev/antd";
import { ConfigProvider, theme } from "antd";
import { createContext, PropsWithChildren, useEffect, useState } from "react";
type ColorModeContextType = {
mode: string;
setMode: (mode: string) => void;
};
export const ColorModeContext = createContext<ColorModeContextType>(
{} as ColorModeContextType
);
export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const colorModeFromLocalStorage = localStorage.getItem("colorMode");
const isSystemPreferenceDark = window?.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const systemPreference = isSystemPreferenceDark ? "dark" : "light";
const [mode, setMode] = useState(
colorModeFromLocalStorage || systemPreference
);
useEffect(() => {
window.localStorage.setItem("colorMode", mode);
}, [mode]);
const setColorMode = () => {
if (mode === "light") {
setMode("dark");
} else {
setMode("light");
}
};
const { darkAlgorithm, defaultAlgorithm } = theme;
return (
<ColorModeContext.Provider
value={{
setMode: setColorMode,
mode,
}}
>
<ConfigProvider
// you can change the theme colors here. example: ...RefineThemes.Magenta,
theme={{
...RefineThemes.Yellow,
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
}}
>
{children}
</ConfigProvider>
</ColorModeContext.Provider>
);
};

19
client/src/i18n.ts Normal file
View File

@@ -0,0 +1,19 @@
import i18n from "i18next";
import detector from "i18next-browser-languagedetector";
import Backend from "i18next-xhr-backend";
import { initReactI18next } from "react-i18next";
i18n
.use(Backend)
.use(detector)
.use(initReactI18next)
.init({
supportedLngs: ["en"],
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
defaultNS: "common",
fallbackLng: ["en"],
});
export default i18n;

16
client/src/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./i18n";
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);
root.render(
<React.StrictMode>
<React.Suspense fallback="loading">
<App />
</React.Suspense>
</React.StrictMode>
);

View File

@@ -0,0 +1,133 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const FilamentCreate: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
return (
<Create saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
help="Filament name, to distinguish this filament type among others from the same vendor. Should contain the color for example."
name={["name"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Vendor"
name={["vendor", "name"]}
rules={[
{
required: false,
},
]}
>
{/* <Select {...vendorSelectProps} /> */}
</Form.Item>
<Form.Item
label="Material"
help="E.g. PLA, ABS, PETG, etc."
name={["material"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Price"
help="Price of a full spool in the system configured currency."
name={["price"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label="Density"
name={["density"]}
rules={[
{
required: true,
},
]}
>
<InputNumber min={0} addonAfter="g/cm³" />
</Form.Item>
<Form.Item
label="Diameter"
name={["diameter"]}
rules={[
{
required: true,
},
]}
>
<InputNumber min={0} addonAfter="mm" />
</Form.Item>
<Form.Item
label="Weight"
help="The filament weight of a full spool (net weight)."
name={["weight"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Spool Weight"
help="The weight of an empty spool."
name={["spool_weight"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Article Number"
name={["article_number"]}
help="E.g. EAN, UPC, etc."
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Create>
);
};

View File

@@ -0,0 +1,160 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
const filamentData = queryResult?.data?.data;
return (
<Edit saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Id"
name={["id"]}
rules={[
{
required: true,
},
]}
>
<Input readOnly disabled />
</Form.Item>
<Form.Item
label="Registered"
name={["registered"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker disabled format="lll" />
</Form.Item>
<Form.Item
label="Name"
help="Filament name, to distinguish this filament type among others from the same vendor. Should contain the color for example."
name={["name"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Vendor"
name={["vendor", "name"]}
rules={[
{
required: false,
},
]}
>
{/* <Select {...vendorSelectProps} /> */}
</Form.Item>
<Form.Item
label="Material"
help="E.g. PLA, ABS, PETG, etc."
name={["material"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Price"
help="Price of a full spool in the system configured currency."
name={["price"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label="Density"
name={["density"]}
rules={[
{
required: true,
},
]}
>
<InputNumber min={0} addonAfter="g/cm³" />
</Form.Item>
<Form.Item
label="Diameter"
name={["diameter"]}
rules={[
{
required: true,
},
]}
>
<InputNumber min={0} addonAfter="mm" />
</Form.Item>
<Form.Item
label="Weight"
help="The filament weight of a full spool (net weight)."
name={["weight"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Spool Weight"
help="The weight of an empty spool."
name={["spool_weight"]}
rules={[
{
required: false,
},
]}
>
<InputNumber min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Article Number"
name={["article_number"]}
help="E.g. EAN, UPC, etc."
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Edit>
);
};

View File

@@ -0,0 +1,4 @@
export { FilamentCreate } from "./create";
export { FilamentEdit } from "./edit";
export { FilamentList } from "./list";
export { FilamentShow } from "./show";

View File

@@ -0,0 +1,106 @@
import React from "react";
import { IResourceComponentsProps, BaseRecord } from "@refinedev/core";
import {
useTable,
List,
EditButton,
ShowButton,
DateField,
NumberField,
} from "@refinedev/antd";
import { Table, Space } from "antd";
export const FilamentList: React.FC<IResourceComponentsProps> = () => {
const { tableProps } = useTable({
syncWithLocation: true,
});
return (
<List>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="id" title="Id" />
<Table.Column dataIndex={["vendor", "name"]} title="Vendor" />
<Table.Column dataIndex="name" title="Name" />
<Table.Column dataIndex="material" title="Material" />
<Table.Column dataIndex="price" title="Price" />
<Table.Column
dataIndex="density"
title="Density"
render={(value: any) => (
<NumberField
value={value}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 2,
}}
/>
)}
/>
<Table.Column
dataIndex="diameter"
title="Diameter"
render={(value: any) => (
<NumberField
value={value}
options={{
unitDisplay: "short",
unit: "millimeter",
style: "unit",
maximumFractionDigits: 2,
}}
/>
)}
/>
<Table.Column
dataIndex="weight"
title="Weight"
render={(value: any) => (
<NumberField
value={value}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
)}
/>
<Table.Column
dataIndex="spool_weight"
title="Spool Weight"
render={(value: any) => (
<NumberField
value={value}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
)}
/>
<Table.Column dataIndex="article_number" title="Article Number" />
<Table.Column
dataIndex={["registered"]}
title="Registered"
render={(value: any) => <DateField value={value} />}
/>
<Table.Column dataIndex={["comment"]} title="Comment" />
<Table.Column
title="Actions"
dataIndex="actions"
render={(_, record: BaseRecord) => (
<Space>
<EditButton hideText size="small" recordItemId={record.id} />
<ShowButton hideText size="small" recordItemId={record.id} />
</Space>
)}
/>
</Table>
</List>
);
};

View File

@@ -0,0 +1,80 @@
import React from "react";
import { IResourceComponentsProps, useShow } from "@refinedev/core";
import {
Show,
NumberField,
DateField,
TagField,
TextField,
} from "@refinedev/antd";
import { Typography } from "antd";
const { Title } = Typography;
export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
const { queryResult } = useShow();
const { data, isLoading } = queryResult;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Title level={5}>Id</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>Registered</Title>
<DateField value={record?.registered} format="lll" />
<Title level={5}>Name</Title>
<TextField value={record?.name} />
<Title level={5}>Vendor</Title>
{/* {vendorIsLoading ? <>Loading...</> : <>{vendorData?.data?.id}</>} */}
<Title level={5}>Material</Title>
<TextField value={record?.material} />
<Title level={5}>Price</Title>
<NumberField value={record?.price ?? ""} />
<Title level={5}>Density</Title>
<NumberField
value={record?.density ?? ""}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 2,
}}
/>
<Title level={5}>Diameter</Title>
<NumberField
value={record?.diameter ?? ""}
options={{
unitDisplay: "short",
unit: "millimeter",
style: "unit",
maximumFractionDigits: 2,
}}
/>
<Title level={5}>Weight</Title>
<NumberField
value={record?.weight ?? ""}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
<Title level={5}>Spool Weight</Title>
<NumberField
value={record?.spool_weight ?? ""}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
<Title level={5}>Article Number</Title>
<TextField value={record?.article_number} />
<Title level={5}>Comment</Title>
<TextField value={record?.comment} />
</Show>
);
};

View File

@@ -0,0 +1,100 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const SpoolCreate: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
return (
<Create saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="First Used"
name={["first_used"]}
rules={[
{
required: false,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker format="lll" />
</Form.Item>
<Form.Item
label="Last Used"
name={["last_used"]}
rules={[
{
required: false,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker format="lll" />
</Form.Item>
<Form.Item
label="Filament"
name={["filament", "name"]}
rules={[
{
required: true,
},
]}
>
{/* <Select {...filamentSelectProps} /> */}
</Form.Item>
<Form.Item
label="Used Weight"
name={["used_weight"]}
rules={[
{
required: true,
},
]}
>
<InputNumber min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Location"
name={["location"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Lot Nr"
name={["lot_nr"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Create>
);
};

View File

@@ -0,0 +1,127 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
const spoolData = queryResult?.data?.data;
return (
<Edit saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Id"
name={["id"]}
rules={[
{
required: true,
},
]}
>
<Input readOnly disabled />
</Form.Item>
<Form.Item
label="Registered"
name={["registered"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker disabled format="lll" />
</Form.Item>
<Form.Item
label="First Used"
name={["first_used"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker format="lll" />
</Form.Item>
<Form.Item
label="Last Used"
name={["last_used"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker format="lll" />
</Form.Item>
<Form.Item
label="Filament"
name={["filament", "name"]}
rules={[
{
required: true,
},
]}
>
{/* <Select {...filamentSelectProps} /> */}
</Form.Item>
<Form.Item
label="Used Weight"
name={["used_weight"]}
rules={[
{
required: true,
},
]}
>
<Input min={0} addonAfter="g" />
</Form.Item>
<Form.Item
label="Location"
name={["location"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Lot Nr"
name={["lot_nr"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Edit>
);
};

View File

@@ -0,0 +1,4 @@
export { SpoolCreate } from "./create";
export { SpoolEdit } from "./edit";
export { SpoolList } from "./list";
export { SpoolShow } from "./show";

View File

@@ -0,0 +1,68 @@
import React from "react";
import { IResourceComponentsProps, BaseRecord } from "@refinedev/core";
import {
useTable,
List,
EditButton,
ShowButton,
DateField,
NumberField,
} from "@refinedev/antd";
import { Table, Space } from "antd";
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const { tableProps } = useTable({
syncWithLocation: true,
});
return (
<List>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="id" title="Id" />
<Table.Column
dataIndex={["filament", "vendor", "name"]}
title="Vendor"
/>
<Table.Column dataIndex={["filament", "name"]} title="Filament Name" />
<Table.Column
dataIndex="remaining_weight"
title="Estimated Remaining Weight"
render={(value: any) => (
<NumberField
value={value}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
)}
/>
<Table.Column
dataIndex={["first_used"]}
title="First Used"
render={(value: any) => <DateField value={value} format="lll" />}
/>
<Table.Column
dataIndex={["last_used"]}
title="Last Used"
render={(value: any) => <DateField value={value} format="lll" />}
/>
<Table.Column dataIndex="location" title="Location" />
<Table.Column dataIndex="lot_nr" title="Lot Nr" />
<Table.Column dataIndex={["comment"]} title="Comment" />
<Table.Column
title="Actions"
dataIndex="actions"
render={(_, record: BaseRecord) => (
<Space>
<EditButton hideText size="small" recordItemId={record.id} />
<ShowButton hideText size="small" recordItemId={record.id} />
</Space>
)}
/>
</Table>
</List>
);
};

View File

@@ -0,0 +1,64 @@
import React from "react";
import { IResourceComponentsProps, useShow } from "@refinedev/core";
import {
Show,
NumberField,
DateField,
TagField,
TextField,
} from "@refinedev/antd";
import { Typography } from "antd";
const { Title } = Typography;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const { queryResult } = useShow();
const { data, isLoading } = queryResult;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Title level={5}>Id</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>Registered</Title>
<DateField value={record?.registered} format="lll" />
<Title level={5}>First Used</Title>
<DateField value={record?.first_used} format="lll" />
<Title level={5}>Last Used</Title>
<DateField value={record?.last_used} format="lll" />
<Title level={5}>Filament</Title>
{/* {filamentIsLoading ? (
<>Loading...</>
) : (
<>{filamentData?.data?.id}</>
)} */}
<Title level={5}>Remaining Weight</Title>
<NumberField
value={record?.remaining_weight ?? ""}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
<Title level={5}>Used Weight</Title>
<NumberField
value={record?.used_weight ?? ""}
options={{
unitDisplay: "short",
unit: "gram",
style: "unit",
maximumFractionDigits: 1,
}}
/>
<Title level={5}>Location</Title>
<TextField value={record?.location} />
<Title level={5}>Lot Nr</Title>
<TextField value={record?.lot_nr} />
<Title level={5}>Comment</Title>
<TextField value={record?.comment} />
</Show>
);
};

39
client/src/pages/vendors/create.tsx vendored Normal file
View File

@@ -0,0 +1,39 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const VendorCreate: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
return (
<Create saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
name={["name"]}
rules={[
{
required: true,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Create>
);
};

66
client/src/pages/vendors/edit.tsx vendored Normal file
View File

@@ -0,0 +1,66 @@
import React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker } from "antd";
import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea";
export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps, queryResult } = useForm();
const vendorData = queryResult?.data?.data;
return (
<Edit saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Id"
name={["id"]}
rules={[
{
required: true,
},
]}
>
<Input readOnly disabled />
</Form.Item>
<Form.Item
label="Registered"
name={["registered"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker disabled format="lll" />
</Form.Item>
<Form.Item
label="Name"
name={["name"]}
rules={[
{
required: true,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label="Comment"
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
</Form>
</Edit>
);
};

4
client/src/pages/vendors/index.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export { VendorCreate } from "./create";
export { VendorEdit } from "./edit";
export { VendorList } from "./list";
export { VendorShow } from "./show";

41
client/src/pages/vendors/list.tsx vendored Normal file
View File

@@ -0,0 +1,41 @@
import React from "react";
import { IResourceComponentsProps, BaseRecord } from "@refinedev/core";
import {
useTable,
List,
EditButton,
ShowButton,
DateField,
} from "@refinedev/antd";
import { Table, Space } from "antd";
export const VendorList: React.FC<IResourceComponentsProps> = () => {
const { tableProps } = useTable({
syncWithLocation: true,
});
return (
<List>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="id" title="Id" />
<Table.Column dataIndex="name" title="Name" />
<Table.Column
dataIndex={["registered"]}
title="Registered"
render={(value: any) => <DateField value={value} format="lll" />}
/>
<Table.Column dataIndex={["comment"]} title="Comment" />
<Table.Column
title="Actions"
dataIndex="actions"
render={(_, record: BaseRecord) => (
<Space>
<EditButton hideText size="small" recordItemId={record.id} />
<ShowButton hideText size="small" recordItemId={record.id} />
</Space>
)}
/>
</Table>
</List>
);
};

32
client/src/pages/vendors/show.tsx vendored Normal file
View File

@@ -0,0 +1,32 @@
import React from "react";
import { IResourceComponentsProps, useShow } from "@refinedev/core";
import {
Show,
NumberField,
DateField,
TagField,
TextField,
} from "@refinedev/antd";
import { Typography } from "antd";
const { Title } = Typography;
export const VendorShow: React.FC<IResourceComponentsProps> = () => {
const { queryResult } = useShow();
const { data, isLoading } = queryResult;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Title level={5}>Id</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>Registered</Title>
<DateField value={record?.registered} format="lll" />
<Title level={5}>Name</Title>
<TextField value={record?.name} />
<Title level={5}>Comment</Title>
<TextField value={record?.comment} />
</Show>
);
};

1
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
client/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

6
client/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});

View File

@@ -10,6 +10,9 @@ dynamic = ["dependencies"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]} dependencies = {file = ["requirements.txt"]}
[tool.setuptools.packages.find]
where = ["spoolman"]
[tool.ruff] [tool.ruff]
select = ["ALL"] select = ["ALL"]
ignore = ["A003", "D101", "D104", "D406", "D407", "S104", "TRY201", "TRY003", "EM101", "EM102", "DTZ003"] ignore = ["A003", "D101", "D104", "D406", "D407", "S104", "TRY201", "TRY003", "EM101", "EM102", "DTZ003"]

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from platformdirs import user_data_dir from platformdirs import user_data_dir
from sqlalchemy import URL from sqlalchemy import URL
@@ -42,6 +43,7 @@ logger = logging.getLogger(__name__)
# Setup FastAPI # Setup FastAPI
app = FastAPI(debug=True) app = FastAPI(debug=True)
app.mount("/api/v1", v1_app) app.mount("/api/v1", v1_app)
app.mount("/", StaticFiles(directory="client/dist", html=True), name="client")
def get_connection_url() -> URL: def get_connection_url() -> URL: