diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 88ff2ae85..870e9d5bc 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -356,6 +356,35 @@
"delete_confirm_description": "This will delete the field and all associated data for all entities."
}
},
+ "applications": {
+ "tab": "Applications",
+ "description": "
Applications add optional, pre-defined behaviors to your Spoolman instance.
Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.
",
+ "empty": "No applications are currently available.",
+ "all": "All",
+ "status": {
+ "active": "Active",
+ "partial": "Partial",
+ "inactive": "Inactive"
+ },
+ "columns": {
+ "name": "Name",
+ "description": "Feature",
+ "enable_description": "What Enabling Adds",
+ "surfaces": "Surfaces",
+ "enabled": "Enabled"
+ },
+ "surfaces": {
+ "show": "Show",
+ "edit": "Edit",
+ "list": "List",
+ "action": "Action",
+ "derived": "Derived"
+ },
+ "messages": {
+ "enabled": "Enabled {{name}}.",
+ "disabled": "Disabled {{name}}."
+ }
+ },
"documentTitle": {
"default": "Spoolman",
"suffix": " | Spoolman",
@@ -368,6 +397,9 @@
"locations": {
"list": "Locations | Spoolman"
},
+ "applications": {
+ "list": "Applications | Spoolman"
+ },
"filament": {
"list": "Filaments | Spoolman",
"show": "#{{id}} Show Filament | Spoolman",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index d907b8ee1..f1c1c5b03 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -6,6 +6,7 @@ import { ErrorComponent } from "@refinedev/antd";
import "@refinedev/antd/dist/reset.css";
import {
+ AppstoreOutlined,
FileOutlined,
HighlightOutlined,
HomeOutlined,
@@ -150,6 +151,14 @@ function App() {
icon: ,
},
},
+ {
+ name: "applications",
+ list: "/applications",
+ meta: {
+ canDelete: false,
+ icon: ,
+ },
+ },
{
name: "settings",
list: "/settings",
@@ -225,6 +234,8 @@ function App() {
} />
} />
} />
+ } />
+ } />
} />
diff --git a/client/src/components/applicationCard.tsx b/client/src/components/applicationCard.tsx
new file mode 100644
index 000000000..74526948a
--- /dev/null
+++ b/client/src/components/applicationCard.tsx
@@ -0,0 +1,87 @@
+import { useTranslate } from "@refinedev/core";
+import { Avatar, Badge, Button, Card, Space, Switch, Tag, Typography } from "antd";
+import { useNavigate } from "react-router";
+import { ApplicationState, EntityType } from "../utils/queryFields";
+
+interface ApplicationCardProps {
+ appKey: string;
+ name: string;
+ description: string;
+ icon: string | null;
+ states: ApplicationState[];
+ onToggle: (entityType: EntityType, key: string, enabled: boolean) => Promise;
+ toggling: { entityType: EntityType; key: string } | null;
+}
+
+const ENTITY_COLORS: Record = {
+ [EntityType.spool]: "blue",
+ [EntityType.filament]: "green",
+ [EntityType.vendor]: "orange",
+};
+
+export function ApplicationCard({ appKey, name, description, icon, states, onToggle, toggling }: ApplicationCardProps) {
+ const t = useTranslate();
+ const navigate = useNavigate();
+
+ const anyEnabled = states.some((s) => s.enabled);
+ const allEnabled = states.length > 0 && states.every((s) => s.enabled);
+
+ const badgeStatus = allEnabled ? "success" : anyEnabled ? "warning" : "default";
+ const badgeText = allEnabled
+ ? t("applications.status.active")
+ : anyEnabled
+ ? t("applications.status.partial")
+ : t("applications.status.inactive");
+
+ const avatarContent = icon ?? name.charAt(0).toUpperCase();
+
+ return (
+
+ navigate(`/applications/${appKey}`)}>
+ Configure
+ ,
+ ]}
+ >
+
+ {avatarContent}
+
+ }
+ title={{name} }
+ description={
+
+ {description}
+
+ }
+ />
+
+ {states.map((state) => (
+
+ {t(`${state.entity_type}.${state.entity_type}`)}
+ onToggle(state.entity_type, state.key, checked)}
+ onClick={(_, e) => e.stopPropagation()}
+ style={{ marginLeft: 4 }}
+ />
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/pages/applications/detail/index.tsx b/client/src/pages/applications/detail/index.tsx
new file mode 100644
index 000000000..f32f7ea98
--- /dev/null
+++ b/client/src/pages/applications/detail/index.tsx
@@ -0,0 +1,176 @@
+import { LeftOutlined } from "@ant-design/icons";
+import { useTranslate } from "@refinedev/core";
+import { Avatar, Button, Col, Empty, Form, Row, Space, Switch, Table, Tag, Typography, message, theme } from "antd";
+import { ColumnType } from "antd/es/table";
+import { useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router";
+import {
+ ApplicationState,
+ ApplicationSurface,
+ EntityType,
+ useGetApplications,
+ useSetApplicationEnabled,
+} from "../../../utils/queryFields";
+
+const ENTITY_COLORS: Record = {
+ [EntityType.spool]: "blue",
+ [EntityType.filament]: "green",
+ [EntityType.vendor]: "orange",
+};
+
+export const ApplicationDetail = () => {
+ const { appKey } = useParams<{ appKey: string }>();
+ const t = useTranslate();
+ const { token } = theme.useToken();
+ const navigate = useNavigate();
+ const [messageApi, contextHolder] = message.useMessage();
+ const [pendingKey, setPendingKey] = useState(null);
+
+ const spoolApps = useGetApplications(EntityType.spool);
+ const filamentApps = useGetApplications(EntityType.filament);
+ const vendorApps = useGetApplications(EntityType.vendor);
+
+ const setSpoolEnabled = useSetApplicationEnabled(EntityType.spool);
+ const setFilamentEnabled = useSetApplicationEnabled(EntityType.filament);
+ const setVendorEnabled = useSetApplicationEnabled(EntityType.vendor);
+
+ const states = useMemo(() => {
+ const all: ApplicationState[] = [
+ ...(spoolApps.data ?? []),
+ ...(filamentApps.data ?? []),
+ ...(vendorApps.data ?? []),
+ ];
+ return all.filter((s) => (s.app_key ?? s.key) === appKey);
+ }, [spoolApps.data, filamentApps.data, vendorApps.data, appKey]);
+
+ const isLoading = spoolApps.isLoading || filamentApps.isLoading || vendorApps.isLoading;
+ const first = states[0];
+
+ const handleToggle = async (record: ApplicationState, enabled: boolean) => {
+ setPendingKey(`${record.entity_type}:${record.key}`);
+ try {
+ const setter =
+ record.entity_type === EntityType.spool
+ ? setSpoolEnabled
+ : record.entity_type === EntityType.filament
+ ? setFilamentEnabled
+ : setVendorEnabled;
+ await setter.mutateAsync({ key: record.key, enabled });
+ messageApi.success(
+ t(enabled ? "applications.messages.enabled" : "applications.messages.disabled", { name: record.name }),
+ );
+ } catch (err) {
+ if (err instanceof Error) {
+ messageApi.error(err.message);
+ }
+ } finally {
+ setPendingKey(null);
+ }
+ };
+
+ const columns: ColumnType[] = [
+ {
+ title: t("applications.columns.name"),
+ dataIndex: "name",
+ key: "name",
+ width: "18%",
+ },
+ {
+ title: "Entity",
+ dataIndex: "entity_type",
+ key: "entity_type",
+ width: "12%",
+ render: (et: EntityType) => {t(`${et}.${et}`)} ,
+ },
+ {
+ title: t("applications.columns.description"),
+ dataIndex: "description",
+ key: "description",
+ width: "24%",
+ render: (value: string) => (
+ {value}
+ ),
+ },
+ {
+ title: t("applications.columns.enable_description"),
+ dataIndex: "enable_description",
+ key: "enable_description",
+ width: "28%",
+ render: (value: string) => (
+ {value}
+ ),
+ },
+ {
+ title: t("applications.columns.surfaces"),
+ dataIndex: "surfaces",
+ key: "surfaces",
+ width: "10%",
+ render: (surfaces: ApplicationSurface[]) => (
+
+ {surfaces.map((s) => (
+ {t(`applications.surfaces.${s}`)}
+ ))}
+
+ ),
+ },
+ {
+ title: t("applications.columns.enabled"),
+ dataIndex: "enabled",
+ key: "enabled",
+ align: "center",
+ width: "8%",
+ render: (enabled: boolean, record) => (
+ handleToggle(record, checked)}
+ />
+ ),
+ },
+ ];
+
+ return (
+
+ {contextHolder}
+
}
+ onClick={() => navigate("/applications")}
+ style={{ marginBottom: 16 }}
+ >
+ {t("applications.tab")}
+
+
+ {first && (
+
+
+
+ {first.icon ?? first.name.charAt(0).toUpperCase()}
+
+
+
+
+ {first.name}
+
+ {first.description}
+
+
+ )}
+
+