Add Print Queue UI and slicer post-processing script
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Create Print Queue page showing pending/completed/cancelled jobs - Add needs_weighing filter to spool API for flagged spools - Add IPrintJob interface and needs_weighing to ISpool model - Add slicer_post_process.py for Elegoo/Orca Slicer integration - Add translations for print queue feature - Bump version to 0.23C.3 The workflow: 1. Slicer post-processing script creates pending print job 2. After print, user confirms (deducts filament) or cancels (flags for weighing) 3. Spools needing weigh-in are shown in Print Queue UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
FileOutlined,
|
||||
HighlightOutlined,
|
||||
HomeOutlined,
|
||||
PrinterOutlined,
|
||||
QuestionOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
@@ -142,6 +143,14 @@ function App() {
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "print-queue",
|
||||
list: "/print-queue",
|
||||
meta: {
|
||||
canDelete: false,
|
||||
icon: <PrinterOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locations",
|
||||
list: "/locations",
|
||||
@@ -225,6 +234,7 @@ function App() {
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
456
client/src/pages/print-queue/index.tsx
Normal file
456
client/src/pages/print-queue/index.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FileOutlined,
|
||||
ReloadOutlined,
|
||||
ToolOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core";
|
||||
import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd";
|
||||
import { Content } from "antd/es/layout/layout";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { IPrintJob, ISpool } from "../spools/model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { useToken } = theme;
|
||||
const { confirm } = Modal;
|
||||
|
||||
export const PrintQueue: React.FC<IResourceComponentsProps> = () => {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const invalidate = useInvalidate();
|
||||
const { mutate: customMutate } = useCustomMutation();
|
||||
|
||||
// Fetch print jobs
|
||||
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
|
||||
resource: "print-job",
|
||||
pagination: { mode: "off" },
|
||||
sorters: [{ field: "created", order: "desc" }],
|
||||
});
|
||||
|
||||
// Fetch spools that need weighing
|
||||
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
|
||||
resource: "spool",
|
||||
filters: [{ field: "needs_weighing", operator: "eq", value: true }],
|
||||
pagination: { mode: "off" },
|
||||
});
|
||||
|
||||
// Categorize jobs
|
||||
const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => {
|
||||
const jobs = printJobs?.data || [];
|
||||
return {
|
||||
pendingJobs: jobs.filter((j) => j.status === "pending"),
|
||||
completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10),
|
||||
cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10),
|
||||
};
|
||||
}, [printJobs]);
|
||||
|
||||
const handleComplete = (job: IPrintJob) => {
|
||||
confirm({
|
||||
title: t("print_queue.confirm_complete"),
|
||||
content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }),
|
||||
icon: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
|
||||
okText: t("buttons.confirm"),
|
||||
okType: "primary",
|
||||
cancelText: t("buttons.cancel"),
|
||||
onOk: () => {
|
||||
customMutate({
|
||||
url: `print-job/${job.id}/complete`,
|
||||
method: "post",
|
||||
values: {},
|
||||
successNotification: {
|
||||
message: t("print_queue.job_completed"),
|
||||
type: "success",
|
||||
},
|
||||
errorNotification: {
|
||||
message: t("print_queue.job_complete_error"),
|
||||
type: "error",
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (job: IPrintJob) => {
|
||||
confirm({
|
||||
title: t("print_queue.confirm_cancel"),
|
||||
content: t("print_queue.confirm_cancel_description"),
|
||||
icon: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
|
||||
okText: t("print_queue.cancel_job"),
|
||||
okType: "danger",
|
||||
cancelText: t("buttons.cancel"),
|
||||
onOk: () => {
|
||||
customMutate({
|
||||
url: `print-job/${job.id}/cancel`,
|
||||
method: "post",
|
||||
values: {},
|
||||
successNotification: {
|
||||
message: t("print_queue.job_cancelled"),
|
||||
type: "success",
|
||||
},
|
||||
errorNotification: {
|
||||
message: t("print_queue.job_cancel_error"),
|
||||
type: "error",
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (job: IPrintJob) => {
|
||||
confirm({
|
||||
title: t("print_queue.confirm_delete"),
|
||||
content: t("print_queue.confirm_delete_description"),
|
||||
icon: <DeleteOutlined style={{ color: token.colorError }} />,
|
||||
okText: t("buttons.delete"),
|
||||
okType: "danger",
|
||||
cancelText: t("buttons.cancel"),
|
||||
onOk: () => {
|
||||
customMutate({
|
||||
url: `print-job/${job.id}`,
|
||||
method: "delete",
|
||||
values: {},
|
||||
successNotification: {
|
||||
message: t("print_queue.job_deleted"),
|
||||
type: "success",
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearWeighingFlag = (spool: ISpool) => {
|
||||
customMutate({
|
||||
url: `spool/${spool.id}`,
|
||||
method: "patch",
|
||||
values: { needs_weighing: false },
|
||||
successNotification: {
|
||||
message: t("print_queue.weighing_cleared"),
|
||||
type: "success",
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getSpoolName = (job: IPrintJob) => {
|
||||
if (job.spool?.filament) {
|
||||
const fil = job.spool.filament;
|
||||
if (fil.vendor?.name) {
|
||||
return `${fil.vendor.name} - ${fil.name || fil.material}`;
|
||||
}
|
||||
return fil.name || fil.material || `Filament #${fil.id}`;
|
||||
}
|
||||
return `Spool #${job.spool_id}`;
|
||||
};
|
||||
|
||||
const pendingColumns = [
|
||||
{
|
||||
title: t("print_queue.fields.filename"),
|
||||
dataIndex: "filename",
|
||||
key: "filename",
|
||||
render: (filename: string) => (
|
||||
<Space>
|
||||
<FileOutlined />
|
||||
{filename}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.spool"),
|
||||
key: "spool",
|
||||
render: (_: unknown, record: IPrintJob) => (
|
||||
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.filament_used"),
|
||||
key: "filament_used",
|
||||
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.created"),
|
||||
dataIndex: "created",
|
||||
key: "created",
|
||||
render: (created: string) => (
|
||||
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||
{dayjs.utc(created).fromNow()}
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("table.actions"),
|
||||
key: "actions",
|
||||
render: (_: unknown, record: IPrintJob) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleComplete(record)}
|
||||
>
|
||||
{t("print_queue.complete")}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => handleCancel(record)}
|
||||
>
|
||||
{t("print_queue.cancel")}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const historyColumns = [
|
||||
{
|
||||
title: t("print_queue.fields.filename"),
|
||||
dataIndex: "filename",
|
||||
key: "filename",
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.spool"),
|
||||
key: "spool",
|
||||
render: (_: unknown, record: IPrintJob) => (
|
||||
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.filament_used"),
|
||||
key: "filament_used",
|
||||
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: string) => (
|
||||
<Tag color={status === "completed" ? "success" : "error"}>
|
||||
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("print_queue.fields.finished"),
|
||||
dataIndex: "finished",
|
||||
key: "finished",
|
||||
render: (finished: string) =>
|
||||
finished ? (
|
||||
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||
{dayjs.utc(finished).fromNow()}
|
||||
</Tooltip>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("table.actions"),
|
||||
key: "actions",
|
||||
render: (_: unknown, record: IPrintJob) => (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const weighingColumns = [
|
||||
{
|
||||
title: t("spool.fields.id"),
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.filament_name"),
|
||||
key: "filament",
|
||||
render: (_: unknown, record: ISpool) => {
|
||||
const fil = record.filament;
|
||||
if (fil.vendor?.name) {
|
||||
return `${fil.vendor.name} - ${fil.name || fil.material}`;
|
||||
}
|
||||
return fil.name || fil.material || `Filament #${fil.id}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.location"),
|
||||
dataIndex: "location",
|
||||
key: "location",
|
||||
},
|
||||
{
|
||||
title: t("table.actions"),
|
||||
key: "actions",
|
||||
render: (_: unknown, record: ISpool) => (
|
||||
<Space>
|
||||
<Link to={`/spool/edit/${record.id}`}>
|
||||
<Button type="primary" icon={<ToolOutlined />}>
|
||||
{t("print_queue.weigh_in")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
|
||||
<Button onClick={() => handleClearWeighingFlag(record)}>
|
||||
{t("print_queue.clear_flag")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: "2em 20px",
|
||||
minHeight: 280,
|
||||
maxWidth: 1200,
|
||||
margin: "0 auto",
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
{t("print_queue.title")}
|
||||
</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||
}}
|
||||
>
|
||||
{t("buttons.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t("print_queue.stats.pending")}
|
||||
value={pendingJobs.length}
|
||||
valueStyle={{ color: token.colorWarning }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t("print_queue.stats.needs_weighing")}
|
||||
value={spoolsNeedingWeighing?.data?.length || 0}
|
||||
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t("print_queue.stats.completed_today")}
|
||||
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
|
||||
valueStyle={{ color: token.colorSuccess }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t("print_queue.stats.total_jobs")}
|
||||
value={printJobs?.data?.length || 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Pending Jobs */}
|
||||
<Card
|
||||
title={
|
||||
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
|
||||
<span>{t("print_queue.pending_jobs")}</span>
|
||||
</Badge>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
{pendingJobs.length > 0 ? (
|
||||
<Table
|
||||
dataSource={pendingJobs}
|
||||
columns={pendingColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<Empty description={t("print_queue.no_pending_jobs")} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Spools Needing Weighing */}
|
||||
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
|
||||
<span>{t("print_queue.needs_weighing")}</span>
|
||||
</Badge>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Table
|
||||
dataSource={spoolsNeedingWeighing?.data || []}
|
||||
columns={weighingColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent History */}
|
||||
<Card title={t("print_queue.recent_history")}>
|
||||
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
|
||||
<Table
|
||||
dataSource={[...completedJobs, ...cancelledJobs].sort(
|
||||
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
|
||||
).slice(0, 10)}
|
||||
columns={historyColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<Empty description={t("print_queue.no_history")} />
|
||||
)}
|
||||
</Card>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrintQueue;
|
||||
@@ -23,9 +23,22 @@ export interface ISpool {
|
||||
lot_nr?: string;
|
||||
comment?: string;
|
||||
archived: boolean;
|
||||
needs_weighing: boolean;
|
||||
extra: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface IPrintJob {
|
||||
id: number;
|
||||
spool_id: number;
|
||||
spool?: ISpool;
|
||||
created: string;
|
||||
finished?: string;
|
||||
filename: string;
|
||||
filament_used_g: number;
|
||||
filament_used_mm?: number;
|
||||
status: "pending" | "completed" | "cancelled";
|
||||
}
|
||||
|
||||
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
|
||||
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user