From fd457135c847421f573126c839201d1085648a71 Mon Sep 17 00:00:00 2001 From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:11:27 -0400 Subject: [PATCH 1/3] Prints: a print queue with priority, due dates and a drag-and-drop order A "Print queue" view in Prints (sidebar, ?view=queue): what's printing now, then what's up next. Each file gets a priority (urgent, high, normal, low) and an optional due date in its details; up next lines up by priority, then due date. Dragging a file (or Alt+Up/Down, or its menu's Move items) saves an order the whole workspace shares. Files nobody has placed slot in before the first placed file they beat, so an urgent new file still goes to the top. A new priority or due date, or marking a file printed, gives up its place; "Priority order" clears every place. - Migration 156: print_files.priority, due_date, queue_pos. - POST /api/prints/queue saves the order (manage_prints; only this workspace's files still to print take a place); PATCH takes priority and dueDate. --- migrations/versions/156-print-queue.sql | 16 + server/__tests__/prints.test.ts | 86 ++++- server/prints.ts | 54 +++- src/modern/__tests__/prints.test.tsx | 80 ++++- src/modern/pages/prints/PrintDetails.tsx | 25 +- src/modern/pages/prints/PrintQueue.tsx | 234 ++++++++++++++ src/modern/pages/prints/PrintsPage.tsx | 374 +++++++++++----------- src/modern/pages/prints/PrintsSidebar.tsx | 20 +- src/modern/pages/prints/printsUi.tsx | 32 +- src/modern/pages/prints/usePrints.ts | 2 + src/types/prints.ts | 80 +++++ 11 files changed, 800 insertions(+), 203 deletions(-) create mode 100644 migrations/versions/156-print-queue.sql create mode 100644 src/modern/pages/prints/PrintQueue.tsx diff --git a/migrations/versions/156-print-queue.sql b/migrations/versions/156-print-queue.sql new file mode 100644 index 000000000..0e9e8ca31 --- /dev/null +++ b/migrations/versions/156-print-queue.sql @@ -0,0 +1,16 @@ +-- 156-print-queue.sql — the Prints queue (server/prints.ts, src/types/prints.ts): +-- the files still to print, in the order the team will print them. +-- print_files.priority: 'urgent', 'high', 'normal' (default) or 'low'. +-- print_files.due_date: the day it's needed by, "YYYY-MM-DD", or NULL. +-- print_files.queue_pos: where a person dragged it in the queue (1 is next), +-- or NULL when nobody has placed it. Unplaced files slot in by priority, +-- then due date (queueOrder()). Changing a file's priority or due date, or +-- marking it printed, clears its place. +-- New columns only; nothing existing changes. +BEGIN; + +ALTER TABLE print_files ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'; +ALTER TABLE print_files ADD COLUMN due_date TEXT; +ALTER TABLE print_files ADD COLUMN queue_pos INTEGER; + +COMMIT; diff --git a/server/__tests__/prints.test.ts b/server/__tests__/prints.test.ts index 9af3b62e5..59db03053 100644 --- a/server/__tests__/prints.test.ts +++ b/server/__tests__/prints.test.ts @@ -15,7 +15,7 @@ import { PassThrough } from "node:stream"; import { writeFileSync } from "node:fs"; import { startTestServer, seedTeam, seedMember, type TestServer } from "./helpers/testServer"; import { withSession } from "./helpers/session"; -import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, type PrintFile } from "../../src/types/prints"; +import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, queueOrder, moveInOrder, cleanDueDate, dueState, type PrintFile } from "../../src/types/prints"; import { PrintStore, createPrints, controlKitPrintPath, printLimits, partForStepFile } from "../prints"; import { createHash } from "node:crypto"; import { seasonName } from "../../src/types/cadBom"; @@ -133,7 +133,7 @@ describe("names, paths and storage keys (pure)", () => { expect(printLimits({ PRINTS_MAX_FILE_MB: "50", PRINTS_QUOTA_MB: "-1", PRINTS_MIN_FREE_MB: "0" })).toEqual({ maxFile: 50 * MB, quota: 2048 * MB, minFree: 0 }); }); it("combines group filters and counts what's left to print", () => { - const f = (o: Partial): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, collections: [], hasThumb: false, source: "upload", ...o }); + const f = (o: Partial): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, priority: "normal", dueDate: null, queuePos: null, collections: [], hasThumb: false, source: "upload", ...o }); const files = [f({ subsystem: "Intake", material: "PETG" }), f({ subsystem: "intake", material: "PLA" }), f({ subsystem: "Intake", material: "PETG", status: "printed" }), f({ subsystem: "Lift", material: "PETG", status: "failed" })]; const hit = files.filter((x) => matchesFilters(x, { subsystem: "Intake", material: "PETG", status: "to_print" })); expect(hit).toHaveLength(1); @@ -361,6 +361,88 @@ describe("review fixes", () => { }); }); +describe("the print queue", () => { + const f = (id: number, o: Partial = {}): PrintFile => ({ id, folderId: null, name: `${id}.stl`, kind: "stl", size: 1, createdAt: `2026-10-0${id}T00:00:00Z`, updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, priority: "normal", dueDate: null, queuePos: null, collections: [], hasThumb: false, source: "upload", ...o }); + const order = (files: PrintFile[]) => queueOrder(files).map((x) => x.id); + + it("lines up by priority, then due date, then age, leaving out what's printing or printed", () => { + const files = [f(1), f(2, { priority: "low" }), f(3, { dueDate: "2026-11-02" }), f(4, { priority: "urgent" }), f(5, { dueDate: "2026-11-01", status: "failed" }), f(6, { status: "printing" }), f(7, { status: "printed" })]; + expect(order(files)).toEqual([4, 5, 3, 1, 2]); + }); + it("keeps the order people dragged, and slots unplaced files in before the first placed one they beat", () => { + // Dragged: low first, then high. + const placed = [f(1, { priority: "low", queuePos: 1 }), f(2, { priority: "high", queuePos: 2 })]; + expect(order(placed)).toEqual([1, 2]); + // An urgent new file goes to the top and a normal one ahead of the low one it beats; a newer low one beats neither. + expect(order([...placed, f(3, { priority: "urgent" }), f(4), f(5, { priority: "low" })])).toEqual([3, 4, 1, 2, 5]); + expect(moveInOrder([1, 2, 3, 4], 4, 0)).toEqual([4, 1, 2, 3]); + expect(moveInOrder([1, 2, 3, 4], 1, 2)).toEqual([2, 3, 1, 4]); + expect(moveInOrder([1, 2, 3], 3, 99)).toEqual([1, 2, 3]); + }); + it("takes only real calendar days as due dates", () => { + expect(cleanDueDate("2026-11-03")).toBe("2026-11-03"); + expect(cleanDueDate("")).toBeNull(); + expect(cleanDueDate(null)).toBeNull(); + for (const bad of ["2026-02-30", "2026-13-01", "0002-11-03", "11/03/2026", "2026-11-3", 20261103]) expect(cleanDueDate(bad)).toBeUndefined(); + expect(dueState("2026-10-29", "2026-10-30")).toBe("overdue"); + expect(dueState("2026-10-30", "2026-10-30")).toBe("today"); + expect(dueState("2026-11-02", "2026-10-30")).toBe("soon"); + expect(dueState("2026-11-10", "2026-10-30")).toBe("later"); + }); + + it("saves one shared order, and a new priority, due date or finished print gives up the file's place", async () => { + // One at a time, so they're created in this order. + const [a, b, c, d] = [await uploadFile(admin, { name: "q-a.stl" }), await uploadFile(admin, { name: "q-b.stl" }), await uploadFile(admin, { name: "q-c.stl" }), await uploadFile(admin, { name: "q-d.stl" })]; + const mine = [a, b, c, d].map((x) => x.body.id as number); + const theirs = await t.db.execute({ sql: "INSERT INTO print_files (team_id, name, kind, size, sha256, storage_key, created_at, updated_at) VALUES (?, 'theirs.stl', 'stl', 1, 'x', 'k', 'x', 'x')", args: [other] }); + const theirId = Number(theirs.lastInsertRowid); + const queue = async () => (await lib()).files.filter((x: PrintFile) => mine.includes(x.id)); + const ordered = async () => queueOrder(await queue()).map((x) => x.id); + + // Priority and due date are details. + expect((await t.patch(`/api/prints/files/${c.body.id}`, { priority: "urgent", dueDate: "2026-11-03" }, admin)).body).toMatchObject({ priority: "urgent", dueDate: "2026-11-03", queuePos: null }); + expect((await t.patch(`/api/prints/files/${c.body.id}`, { priority: "asap" }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/files/${c.body.id}`, { dueDate: "2026-02-30" }, admin)).status).toBe(400); + expect(await ordered()).toEqual([c.body.id, a.body.id, b.body.id, d.body.id]); + + // Members without manage_prints can't reorder. + expect((await t.post("/api/prints/queue", { ids: [d.body.id] }, student)).status).toBe(403); + expect((await t.post("/api/prints/queue", { ids: "nope" }, admin)).status).toBe(400); + // A drag saves the whole order; another workspace's file takes no place. + const r = await t.post("/api/prints/queue", { ids: [d.body.id, theirId, c.body.id, a.body.id, b.body.id] }, admin); + expect(r.status).toBe(200); + expect(r.body.files).toBe(4); + expect(await ordered()).toEqual([d.body.id, c.body.id, a.body.id, b.body.id]); + expect((await t.db.execute({ sql: "SELECT queue_pos FROM print_files WHERE id = ?", args: [theirId] })).rows[0].queue_pos).toBeNull(); + // Everyone sees it. + expect(queueOrder((await lib(student)).files.filter((x: PrintFile) => mine.includes(x.id))).map((x) => x.id)).toEqual([d.body.id, c.body.id, a.body.id, b.body.id]); + + // A new priority gives up the place: b (now urgent) slots in at the top, ahead of every placed file it beats. + expect((await t.patch(`/api/prints/files/${b.body.id}`, { priority: "urgent" }, admin)).body.queuePos).toBeNull(); + expect(await ordered()).toEqual([b.body.id, d.body.id, c.body.id, a.body.id]); + // Editing anything else keeps it. + expect((await t.patch(`/api/prints/files/${d.body.id}`, { notes: "0.2 mm layers" }, admin)).body.queuePos).not.toBeNull(); + // Printed files leave the queue and their place, whichever way they're marked. + expect((await t.patch(`/api/prints/files/${d.body.id}`, { status: "printed" }, admin)).body.queuePos).toBeNull(); + await t.post("/api/prints/files/bulk", { ids: [c.body.id], action: "status", status: "printed" }, admin); + expect((await queue()).find((x: PrintFile) => x.id === c.body.id)!.queuePos).toBeNull(); + // A printed file in an order takes no place. + await t.post("/api/prints/queue", { ids: [a.body.id, d.body.id, b.body.id] }, admin); + expect((await queue()).filter((x: PrintFile) => x.queuePos != null).map((x: PrintFile) => [x.id, x.queuePos])).toEqual([[a.body.id, 1], [b.body.id, 3]]); + expect(await ordered()).toEqual([a.body.id, b.body.id]); + + // An empty order goes back to priority order. + expect((await t.post("/api/prints/queue", { ids: [] }, admin)).status).toBe(200); + expect((await queue()).every((x: PrintFile) => x.queuePos == null)).toBe(true); + expect(await ordered()).toEqual([b.body.id, a.body.id]); + const act = (await get("/api/prints/activity", admin)).body.map((x: any) => x.action); + expect(act).toEqual(expect.arrayContaining(["queue_order", "queue_reset"])); + + await t.post("/api/prints/files/bulk", { ids: mine, action: "delete" }, admin); + await t.db.execute({ sql: "DELETE FROM print_files WHERE id = ?", args: [theirId] }); + }); +}); + describe("groups, details and downloads", () => { it("files one print in several collections without copying it, and marks a selection printed", async () => { const a = await uploadFile(admin, { name: "hub.stl" }); diff --git a/server/prints.ts b/server/prints.ts index 41c3756b9..46c7701f4 100644 --- a/server/prints.ts +++ b/server/prints.ts @@ -19,6 +19,11 @@ // // Control Kit: fileControlKitPrint() files a print sent for a robot under // "Season / Robot / Control Kit" (see the end of this file). +// +// The queue (migration 156): each file has a priority and an optional due +// date, and the files still to print can be dragged into an order the whole +// workspace shares (POST /api/prints/queue). See queueOrder() in +// src/types/prints.ts for how placed and unplaced files mix. import type { Express } from "express"; import multer from "multer"; import { createHash, randomBytes } from "node:crypto"; @@ -28,7 +33,7 @@ import path from "node:path"; import { seasonName, isSeason } from "../src/types/cadBom.js"; import { DEFAULT_SUBSYSTEMS, cleanSubsystems } from "../src/types/teamModules.js"; import { - canPreview, cleanMaterial, cleanName, cleanSubsystem, isPrintStatus, printKind, safeName, uniqueName, + canPreview, cleanDueDate, cleanMaterial, cleanName, cleanSubsystem, isPrintPriority, isPrintStatus, printKind, safeName, uniqueName, type PrintFile, type PrintKind, type PrintStatus, } from "../src/types/prints.js"; import { readZip, writeZip, uniqueZipNames, ZIP_MAX_BYTES, ZIP_MAX_ENTRIES, type ZipReadEntry } from "./zipStream.js"; @@ -49,6 +54,7 @@ export const MANAGE_PRINTS = "manage_prints"; const MB = 1024 * 1024; const THUMB_MAX = 512 * 1024; const MAX_BULK = 1000; +const MAX_QUEUE = 5000; const MAX_COLLECTIONS = 60; const MAX_FOLDERS = 2000; const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -235,7 +241,9 @@ export function createPrints(deps: PrintDeps) { material: r.material || null, status: isPrintStatus(r.status) ? r.status : "to_print", copiesNeeded: r.copies_needed != null ? Number(r.copies_needed) : cadQty, copiesNeededFrom: r.copies_needed != null ? "manual" : cadQty != null ? "cad" : null, - copiesPrinted: Number(r.copies_printed) || 0, collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key, + copiesPrinted: Number(r.copies_printed) || 0, + priority: isPrintPriority(r.priority) ? r.priority : "normal", dueDate: r.due_date || null, queuePos: r.queue_pos == null ? null : Number(r.queue_pos), + collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key, source: r.source === "control-kit" ? "control-kit" : "upload", }; } @@ -512,7 +520,7 @@ export function createPrints(deps: PrintDeps) { } catch (e) { sendError(res, e, "file"); } }); - // Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies. + // Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies, priority, due date. app.patch("/api/prints/files/:id", async (req, res) => { try { const a = await access(req, res, true); if (!a) return; @@ -593,6 +601,17 @@ export function createPrints(deps: PrintDeps) { : row.cad_part_id ? Number((await dbGet("SELECT quantity FROM cad_parts WHERE id = ? AND team_id = ?", row.cad_part_id, a.teamId))?.quantity ?? NaN) : NaN; if (need != null && Number.isFinite(need) && need > Number(row.copies_printed)) set("copies_printed", need, "copiesPrinted"); } + if (body.priority !== undefined) { + if (!isPrintPriority(body.priority)) return res.status(400).json({ error: "Priority is urgent, high, normal or low" }); + set("priority", body.priority); + } + if (body.dueDate !== undefined) { + const due = cleanDueDate(body.dueDate); + if (due === undefined) return res.status(400).json({ error: "Pick a due date" }); + set("due_date", due, "dueDate"); + } + // A new priority or due date, or a finished print, gives up its place in the queue: it slots in again by priority. + if (row.queue_pos != null && (changes.priority !== undefined || changes.dueDate !== undefined || changes.status === "printed")) sets.push("queue_pos = NULL"); if (!sets.length) return res.json(await oneFile(a.teamId, Number(row.id))); const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; const stmts: Stmt[] = [{ sql: `UPDATE print_files SET ${sets.join(", ")}, updated_at = ? WHERE id = ? AND team_id = ? AND ${FOLDER_OK}`, args: [...args, now(), row.id, a.teamId, folderId, folderId, a.teamId] }]; @@ -667,12 +686,13 @@ export function createPrints(deps: PrintDeps) { const status: PrintStatus = body.status; for (const r of rows.filter((x) => x.status !== status)) { stmts.push({ - // Marking printed counts every copy needed as printed (when the count is known). + // Marking printed counts every copy needed as printed (when the count is known), and gives up its queue place. sql: `UPDATE print_files SET status = ?, updated_at = ?, copies_printed = CASE WHEN ? = 'printed' THEN MAX(copies_printed, COALESCE(copies_needed, (SELECT p.quantity FROM cad_parts p WHERE p.id = print_files.cad_part_id AND p.team_id = print_files.team_id), copies_printed)) - ELSE copies_printed END + ELSE copies_printed END, + queue_pos = CASE WHEN ? = 'printed' THEN NULL ELSE queue_pos END WHERE id = ? AND team_id = ?`, - args: [status, t, status, r.id, a.teamId], + args: [status, t, status, status, r.id, a.teamId], }); stmts.push(logStmt(a.teamId, by, "status", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { from: r.status, to: status } })); } @@ -715,6 +735,28 @@ export function createPrints(deps: PrintDeps) { } catch (e) { sendError(res, e, "bulk"); } }); + // The queue's order: the files still to print as the person left it, next first. Those files are placed 1, 2, 3…; + // every other file gives up its place and slots in by priority and due date. An empty list puts the whole queue + // back in priority order. The last order sent wins. + app.post("/api/prints/queue", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + if (!Array.isArray(req.body?.ids)) return res.status(400).json({ error: "Send the queue's order" }); + const list = ids(req.body.ids); + if (list.length > MAX_QUEUE) return res.status(400).json({ error: `A queue holds at most ${MAX_QUEUE} files` }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const order = JSON.stringify(list); + const [, placed] = await deps.dbBatchResults([ + { sql: "UPDATE print_files SET queue_pos = NULL WHERE team_id = ? AND queue_pos IS NOT NULL", args: [a.teamId] }, + // Only this workspace's files that still need printing take a place. + { sql: `UPDATE print_files SET queue_pos = (SELECT j.key + 1 FROM json_each(?) j WHERE j.value = print_files.id) + WHERE team_id = ? AND status IN ('to_print', 'failed') AND id IN (SELECT value FROM json_each(?))`, args: [order, a.teamId, order] }, + logStmt(a.teamId, by, list.length ? "queue_order" : "queue_reset", { detail: { files: list.length } }), + ]); + res.json({ success: true, files: placed.changes }); + } catch (e) { sendError(res, e, "queue"); } + }); + app.get("/api/prints/files/:id/download", async (req, res) => { try { const a = await access(req, res, false); if (!a) return; diff --git a/src/modern/__tests__/prints.test.tsx b/src/modern/__tests__/prints.test.tsx index 2861c2eed..64c7d4363 100644 --- a/src/modern/__tests__/prints.test.tsx +++ b/src/modern/__tests__/prints.test.tsx @@ -23,7 +23,7 @@ const res = (body: any, ok = true, status = ok ? 200 : 400) => Promise.resolve({ const file = (id: number, name: string, over: Partial = {}): PrintFile => ({ id, folderId: null, name, kind: 'stl', size: 2048 * id, createdAt: '2026-10-20T10:00:00Z', updatedAt: `2026-10-2${id % 8}T10:00:00Z`, uploadedBy: 'Ada', notes: '', robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: 'to_print', copiesNeeded: null, copiesNeededFrom: null, - copiesPrinted: 0, collections: [], hasThumb: false, source: 'upload', ...over, + copiesPrinted: 0, priority: 'normal', dueDate: null, queuePos: null, collections: [], hasThumb: false, source: 'upload', ...over, }); let lib: PrintLibrary; const seed = (): PrintLibrary => ({ @@ -216,6 +216,17 @@ describe('Prints explorer', () => { expect(calls().filter((c) => c.url === '/api/prints/files/1')).toEqual([{ url: '/api/prints/files/1', method: 'PATCH', body: { status: 'printed' } }]); }); + it('sets a file\'s priority and due date in its details', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.click(item('Camera mount.step')); + const sheet = await screen.findByRole('dialog', { name: 'Camera mount.step' }); + fireEvent.change(within(sheet).getByRole('combobox', { name: 'Priority' }), { target: { value: 'high' } }); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/4', method: 'PATCH', body: { priority: 'high' } })); + fireEvent.change(within(sheet).getByLabelText('Due'), { target: { value: '2026-11-03' } }); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/4', method: 'PATCH', body: { dueDate: '2026-11-03' } })); + }); + it('is read-only for members without manage_prints', async () => { lib = { ...seed(), canEdit: false }; api.apiFetch.mockImplementation((url: string) => (url === '/api/prints/library' ? res(lib) : res([]))); @@ -229,3 +240,70 @@ describe('Prints explorer', () => { expect(within(sheet).getByRole('button', { name: /Download/ })).toBeTruthy(); }); }); + +describe('Print queue', () => { + // Up next: Camera mount (urgent), then Roller hub and Lift spool (failed) by age. Plate is printing; Roller spacer is printed. + const queueLib = (): PrintLibrary => { const l = seed(); l.files[3] = { ...l.files[3], priority: 'urgent', dueDate: '2020-01-02' }; return l; }; + const upNext = () => within(screen.getByRole('list', { name: 'Up next' })).getAllByRole('listitem').map((li) => within(li).getAllByRole('button').find((b) => !b.getAttribute('aria-label'))!.textContent); + const row = (name: string) => within(screen.getByRole('list', { name: 'Up next' })).getAllByRole('listitem').find((li) => li.textContent?.includes(name))!; + + it('opens from the sidebar: printing now, then up next by priority', async () => { + lib = queueLib(); + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.click(within(sidebar()).getByRole('button', { name: /^Print queue:/ })); + expect(where).toBe('/prints?view=queue'); + expect(await screen.findByRole('heading', { name: 'Print queue · 1 printing · 3 up next' })).toBeTruthy(); + expect(within(screen.getByRole('list', { name: 'Printing now' })).getByText('Plate.gcode')).toBeTruthy(); + expect(upNext()).toEqual([expect.stringContaining('Camera mount.step'), expect.stringContaining('Roller hub.stl'), expect.stringContaining('Lift spool.3mf')]); + expect(row('Camera mount.step').textContent).toContain('Overdue'); + expect(within(sidebar()).getByRole('button', { name: /^Print queue:/ }).textContent).toContain('1 overdue'); + }); + + it('saves the order a file is dragged into, for everyone', async () => { + lib = queueLib(); + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + const dataTransfer = { setData: () => {}, getData: () => '', types: ['text/plain'], effectAllowed: '', dropEffect: '' }; + fireEvent.dragStart(row('Lift spool.3mf'), { dataTransfer }); + fireEvent.dragOver(row('Roller hub.stl'), { dataTransfer }); + fireEvent.drop(row('Roller hub.stl'), { dataTransfer }); + // Shown at once, then saved as the whole order. + expect(upNext()[1]).toContain('Lift spool.3mf'); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/queue', method: 'POST', body: { ids: [4, 3, 1] } })); + }); + + it('moves a file with Alt+arrows and from its menu', async () => { + lib = queueLib(); + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + fireEvent.keyDown(screen.getByRole('button', { name: /^Roller hub\.stl, 2 of 3/ }), { key: 'ArrowUp', altKey: true }); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/queue', method: 'POST', body: { ids: [1, 4, 3] } })); + fireEvent.pointerDown(within(row('Camera mount.step')).getByRole('button', { name: 'Camera mount.step options' }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'To the bottom' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/queue', method: 'POST', body: { ids: [1, 3, 4] } })); + }); + + it('goes back to priority order after asking, once someone has placed a file', async () => { + lib = queueLib(); + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + expect(screen.queryByRole('button', { name: /Priority order/ })).toBeNull(); + cleanup(); + lib.files[0] = { ...lib.files[0], queuePos: 1 }; + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + fireEvent.click(screen.getByRole('button', { name: /Priority order/ })); + await waitFor(() => expect(dlg.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ title: 'Go back to priority order?' }))); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/queue', method: 'POST', body: { ids: [] } })); + }); + + it('is view-only for members without manage_prints', async () => { + lib = { ...queueLib(), canEdit: false }; + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + expect(row('Roller hub.stl').getAttribute('draggable')).toBe('false'); + expect(screen.queryByRole('button', { name: /Alt\+Up/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /Priority order/ })).toBeNull(); + }); +}); diff --git a/src/modern/pages/prints/PrintDetails.tsx b/src/modern/pages/prints/PrintDetails.tsx index 1e4b1ee94..3366638f3 100644 --- a/src/modern/pages/prints/PrintDetails.tsx +++ b/src/modern/pages/prints/PrintDetails.tsx @@ -1,7 +1,8 @@ // One print file's details, in a side sheet: what it is, where it lives, who // added it, and the details its smart groups come from (robot and season, CAD -// part, subsystem, material, status, copies), plus its collections, notes and -// history. People without manage_prints see the same details read-only. +// part, subsystem, material, status, copies), its priority and due date in +// the print queue, plus its collections, notes and history. People without +// manage_prints see the same details read-only. import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { Box, Download, ExternalLink, FolderInput, Minus, Pencil, Plus, Trash2 } from 'lucide-react'; import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Textarea } from '../../../components/ui-kit'; @@ -10,7 +11,8 @@ import { apiFetch } from '../../../services/api'; import { fmtDate } from '../../../components/cad/useCad'; import { currentSeason, seasonName } from '../../../types/cadBom'; import { - KIND_LABELS, PRINT_MATERIALS, PRINT_STATUSES, STATUS_LABELS, canPreview, folderTrail, formatBytes, type PrintFile, type PrintLibrary, + KIND_LABELS, PRINT_MATERIALS, PRINT_PRIORITIES, PRINT_STATUSES, PRIORITY_LABELS, STATUS_LABELS, canPreview, cleanDueDate, dueState, dueText, folderTrail, formatBytes, stillToPrint, + type PrintFile, type PrintLibrary, } from '../../../types/prints'; import { FileArt, STATUS_DOT, copiesText } from './printsUi'; import { download, fileUrl, type Prints } from './usePrints'; @@ -62,6 +64,8 @@ export function PrintDetails({ file, lib, prints, onClose, onPreview, onRename, const [parts, setParts] = useState<{ id: number; name: string; section: string; quantity: number; group: string }[] | null>(null); const [history, setHistory] = useState<{ id: number; by: string; action: string; detail: any; at: string }[] | null>(null); useEffect(() => { setNotes(file.notes); }, [file.id, file.notes]); + const [due, setDue] = useState(file.dueDate ?? ''); + useEffect(() => { setDue(file.dueDate ?? ''); }, [file.id, file.dueDate]); // The linked robot's parts, for the CAD part picker (made parts first). useEffect(() => { setParts(null); @@ -138,6 +142,21 @@ export function PrintDetails({ file, lib, prints, onClose, onPreview, onRename, ) :

{STATUS_LABELS[file.status]}

} +
+ + + + + {/* Saved once it's a whole date (typing a year passes through 0002, 0020…) or cleared. */} + { const v = e.target.value; setDue(v); if (v !== (file.dueDate ?? '') && (v === '' || cleanDueDate(v))) void save({ dueDate: v || null }); }} /> + +
+ {file.dueDate && stillToPrint(file.status) && dueState(file.dueDate) === 'overdue' &&

{dueText(file.dueDate)}

} +
{edit ? ( diff --git a/src/modern/pages/prints/PrintQueue.tsx b/src/modern/pages/prints/PrintQueue.tsx new file mode 100644 index 000000000..cb8b7bdf2 --- /dev/null +++ b/src/modern/pages/prints/PrintQueue.tsx @@ -0,0 +1,234 @@ +// The print queue: what's on a printer now, then what's up next in the order +// the team will print it. Up next starts in priority order (then due date); +// dragging a file, or Alt+↑/↓ on it, or its menu's Move items, saves an order +// the whole workspace shares. Files nobody has placed slot in by priority +// (queueOrder() in src/types/prints.ts). The view is ?view=queue. +import { useLayoutEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; +import { Check, GripVertical, ListFilter, ListOrdered, MoreHorizontal, RotateCcw, XCircle } from 'lucide-react'; +import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '../../../components/ui-kit'; +import { cn } from '../../../components/cn'; +import { confirmDialog } from '../../../components/dialog'; +import { + PRINT_PRIORITIES, PRIORITY_LABELS, byPriority, dueText, folderTrail, moveInOrder, queueOrder, type PrintFile, type PrintLibrary, type PrintPriority, type PrintStatus, +} from '../../../types/prints'; +import { EmptyState } from '../../ui/page'; +import { DueChip, FileArt, PriorityChip, StatusChip, copiesText } from './printsUi'; +import type { Prints } from './usePrints'; + +/** "3 printing · 5 up next" */ +export function queueSummary(files: readonly PrintFile[]): string { + const printing = files.filter((f) => f.status === 'printing').length; + const next = queueOrder(files).length; + return [printing ? `${printing} printing` : '', `${next} up next`].filter(Boolean).join(' · '); +} + +export function PrintQueue({ lib, prints, onOpen, onStatus, onError, onAnnounce, onBrowse }: { + lib: PrintLibrary; prints: Prints; + onOpen: (id: number) => void; + onStatus: (ids: number[], status: PrintStatus) => Promise; + onError: (e: unknown) => void; + onAnnounce: (message: string) => void; + /** Opens folders and groups on a phone. */ + onBrowse: () => void; +}) { + const canEdit = lib.canEdit; + // The order as the person just left it, shown until the saved one comes back. + const [pending, setPending] = useState(null); + const saving = useRef(0); + const [drag, setDrag] = useState(null); + const [dropAt, setDropAt] = useState<{ id: number; after: boolean } | null>(null); + const focusAfter = useRef(null); + const handles = useRef(new Map()); + + const computed = queueOrder(lib.files); + const byId = new Map(computed.map((f) => [f.id, f])); + const upNext = pending ? [...pending.map((id) => byId.get(id)).filter((f): f is PrintFile => !!f), ...computed.filter((f) => !pending.includes(f.id))] : computed; + const printing = lib.files.filter((f) => f.status === 'printing').sort(byPriority); + const placed = computed.some((f) => f.queuePos != null); + + // A row moved by keyboard keeps the focus. + useLayoutEffect(() => { + if (focusAfter.current == null) return; + handles.current.get(focusAfter.current)?.focus(); + focusAfter.current = null; + }); + + const reorder = async (id: number, to: number) => { + const ids = upNext.map((f) => f.id); + const next = moveInOrder(ids, id, to); + if (next.every((x, i) => x === ids[i])) return; + const name = byId.get(id)?.name ?? 'File'; + const seq = ++saving.current; + setPending(next); + onAnnounce(`${name} is now ${next.indexOf(id) + 1} of ${next.length}`); + try { await prints.setQueueOrder(next); } catch (e) { onError(e); } finally { if (saving.current === seq) setPending(null); } + }; + const resetOrder = async () => { + if (!(await confirmDialog({ title: 'Go back to priority order?', message: 'The order people dragged is cleared for everyone. Files line up by priority, then due date.', confirmLabel: 'Use priority order' }))) return; + try { await prints.setQueueOrder([]); onAnnounce('The queue is in priority order'); } catch (e) { onError(e); } + }; + const setPriority = async (f: PrintFile, priority: PrintPriority) => { + try { await prints.updateFile(f.id, { priority }); onAnnounce(`${f.name} is ${PRIORITY_LABELS[priority].toLowerCase()} priority`); } catch (e) { onError(e); } + }; + + // ---- Drag and drop (Up next only) ---- + const dropProps = (f: PrintFile, index: number) => ({ + onDragOver: (e: DragEvent) => { + if (drag == null) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const after = e.clientY > r.top + r.height / 2; + if (dropAt?.id !== f.id || dropAt.after !== after) setDropAt({ id: f.id, after }); + }, + onDragLeave: (e: DragEvent) => { if (!(e.currentTarget as Node).contains(e.relatedTarget as Node)) setDropAt((d) => (d?.id === f.id ? null : d)); }, + onDrop: (e: DragEvent) => { + if (drag == null) return; + e.preventDefault(); + e.stopPropagation(); + const from = upNext.findIndex((x) => x.id === drag); + let to = index + (dropAt?.id === f.id && dropAt.after ? 1 : 0); + if (from < to) to--; + setDrag(null); + setDropAt(null); + void reorder(drag, to); + }, + }); + const onRowKey = (f: PrintFile, index: number) => (e: KeyboardEvent) => { + if (!canEdit || !e.altKey) return; + const to = e.key === 'ArrowUp' ? index - 1 : e.key === 'ArrowDown' ? index + 1 : e.key === 'Home' ? 0 : e.key === 'End' ? upNext.length - 1 : null; + if (to === null) return; + e.preventDefault(); + focusAfter.current = f.id; + void reorder(f.id, to); + }; + + const where = (f: PrintFile) => folderTrail(lib.folders, f.folderId).map((x) => x.name).join(' / ') || 'Top level'; + const meta = (f: PrintFile) => [where(f), f.subsystem, f.material, copiesText(f)].filter(Boolean).join(' · '); + const chips = (f: PrintFile) => ( + + {f.dueDate && } + {f.priority !== 'normal' && } + {f.status === 'failed' && } + + ); + const fileName = (f: PrintFile) => { + // On a phone the chips become words in the line under the name. + const tags = [f.dueDate ? dueText(f.dueDate) : null, f.priority !== 'normal' ? PRIORITY_LABELS[f.priority] : null, f.status === 'failed' ? 'Failed' : null].filter(Boolean); + return ( + + ); + }; + + const menu = (f: PrintFile, index: number | null) => ( + + + + + + onOpen(f.id)}>Details + {canEdit && index !== null && ( + <> + + Move + void reorder(f.id, 0)}>To the top + void reorder(f.id, index - 1)}>Up one + void reorder(f.id, index + 1)}>Down one + void reorder(f.id, upNext.length - 1)}>To the bottom + + )} + {canEdit && ( + <> + + Priority + {PRINT_PRIORITIES.filter((p) => p !== f.priority).map((p) => void setPriority(f, p)}>{PRIORITY_LABELS[p]})} + + Mark as + {f.status !== 'printing' && void onStatus([f.id], 'printing')}>Printing} + {f.status === 'printing' && void onStatus([f.id], 'to_print')}>Back to up next} + void onStatus([f.id], 'printed')}>Printed + {f.status !== 'failed' && void onStatus([f.id], 'failed')}>Failed or reprint} + + )} + + + ); + + return ( +
+
+
+ +

Print queue · {queueSummary(lib.files)}

+
+ {canEdit && placed && } +
+ + {printing.length === 0 && upNext.length === 0 ? ( + + ) : ( +
+ {printing.length > 0 && ( +
+

Printing now

+
    + {printing.map((f) => ( +
  • + + + {fileName(f)} + {chips(f)} + {canEdit && ( + + + + + )} + {menu(f, null)} +
  • + ))} +
+
+ )} +
+

Up next

+ {upNext.length === 0 ?

Nothing waiting. Files marked To print or Failed line up here.

: ( +
    + {upNext.map((f, i) => ( +
  1. { if (!canEdit) return; e.dataTransfer.setData('text/plain', f.name); e.dataTransfer.effectAllowed = 'move'; setDrag(f.id); }} + onDragEnd={() => { setDrag(null); setDropAt(null); }} + className={cn('relative flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 last:border-b-0', canEdit && 'hover:bg-muted/40', drag === f.id && 'opacity-50')}> + {dropAt?.id === f.id && drag !== f.id && } + {canEdit ? ( + + ) : null} + {i + 1} + + {fileName(f)} + {chips(f)} + {menu(f, i)} +
  2. + ))} +
+ )} +
+ {canEdit && upNext.length > 1 && ( +

Drag files into the order you'll print them, or press Alt+↑/↓. Everyone sees the same order. Files nobody has placed slot in by priority, then due date.

+ )} +
+ )} +
+ ); +} diff --git a/src/modern/pages/prints/PrintsPage.tsx b/src/modern/pages/prints/PrintsPage.tsx index 7e7c2733b..ff89914e6 100644 --- a/src/modern/pages/prints/PrintsPage.tsx +++ b/src/modern/pages/prints/PrintsPage.tsx @@ -7,7 +7,7 @@ // everything or mark a selection printed or failed. // // The view lives in the URL (?folder=, ?status=, ?sub=, ?mat=, ?robot=, -// ?season=, ?col=, ?q=), so a group can be linked and Back works. +// ?season=, ?col=, ?q=, ?view=queue), so a group can be linked and Back works. import React, { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; import { @@ -23,11 +23,12 @@ import { confirmDialog, notify } from '../../../components/dialog'; import { fmtDate } from '../../../components/cad/useCad'; import { seasonName } from '../../../types/cadBom'; import { - FILTER_KEYS, PRINT_ACCEPT, STATUS_LABELS, canPreview, folderAndBelow, folderTrail, formatBytes, groupCounts, hasGroupFilter, isPrintStatus, - matchesFilters, sortFiles, type FilterKey, type PrintFile, type PrintFilters, type PrintFolder, type PrintLibrary, type PrintStatus, type SortKey, + FILTER_KEYS, PRINT_ACCEPT, PRIORITY_LABELS, STATUS_LABELS, canPreview, folderAndBelow, folderTrail, formatBytes, groupCounts, hasGroupFilter, isPrintStatus, + matchesFilters, sortFiles, stillToPrint, type FilterKey, type PrintFile, type PrintFilters, type PrintFolder, type PrintLibrary, type PrintStatus, type SortKey, } from '../../../types/prints'; import { EmptyState, Page, PageHeader } from '../../ui/page'; import { PrintDetails } from './PrintDetails'; +import { PrintQueue, queueSummary } from './PrintQueue'; import { PrintsSidebar, type DropHandlers } from './PrintsSidebar'; import { FileArt, FolderArt, MoveDialog, NameDialog, StatusChip, copiesText } from './printsUi'; import { download, downloadFiles, fileUrl, folderZipUrl, thumbnailFor, usePrints, type UploadItem } from './usePrints'; @@ -75,7 +76,8 @@ export function PrintsPage() { const filters = useMemo(() => readFilters(params), [params]); const folderParam = Number(params.get('folder')); const folderId = lib && Number.isInteger(folderParam) && lib.folders.some((f) => f.id === folderParam) ? folderParam : null; - const groupMode = hasGroupFilter(filters) || !!filters.query?.trim(); + const queueMode = params.get('view') === 'queue'; + const groupMode = !queueMode && (hasGroupFilter(filters) || !!filters.query?.trim()); const [view, setView] = useState<'list' | 'grid'>(() => { try { return localStorage.getItem(VIEW_KEY) === 'grid' ? 'grid' : 'list'; } catch { return 'list'; } }); const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'name', dir: 'asc' }); const [selected, setSelected] = useState>(() => new Set()); @@ -91,9 +93,10 @@ export function PrintsPage() { useEffect(() => { try { localStorage.setItem(VIEW_KEY, view); } catch { /* storage off */ } }, [view]); useEffect(() => { setQuery(filters.query ?? ''); }, [filters.query]); - const go = useCallback((next: { folder?: number | null; filters?: PrintFilters }) => { + const go = useCallback((next: { folder?: number | null; filters?: PrintFilters; queue?: boolean }) => { setParams(() => { const p = new URLSearchParams(); + if (next.queue) p.set('view', 'queue'); if (next.folder != null) p.set('folder', String(next.folder)); const f = next.filters ?? {}; for (const k of [...FILTER_KEYS, 'query'] as const) { const v = f[k]; if (v !== undefined && v !== '') p.set(PARAM[k], String(v)); } @@ -102,6 +105,7 @@ export function PrintsPage() { setSelected(new Set()); }, [setParams]); const openFolder = (id: number | null) => { go({ folder: id }); setBrowseOpen(false); }; + const openQueue = () => { go({ queue: true }); setBrowseOpen(false); }; const toggle = (key: K, value: PrintFilters[K]) => { const same = key === 'subsystem' || key === 'material' ? String(filters[key] ?? '').toLowerCase() === String(value).toLowerCase() @@ -135,8 +139,8 @@ export function PrintsPage() { const counts = groupCounts(shown.files); const label = lib && groupMode ? (filterLabel(filters, lib) || `Search “${filters.query}”`) : null; const trail = lib ? folderTrail(lib.folders, folderId) : []; - const here = groupMode ? label! : trail.length ? trail[trail.length - 1].name : 'All prints'; - const summary = `${plural(counts.files, 'file')}${counts.files ? (counts.stillToPrint ? ` · ${counts.stillToPrint} still to print` : ' · all printed or printing') : ''}`; + const here = queueMode ? 'Print queue' : groupMode ? label! : trail.length ? trail[trail.length - 1].name : 'All prints'; + const summary = queueMode && lib ? queueSummary(lib.files) : `${plural(counts.files, 'file')}${counts.files ? (counts.stillToPrint ? ` · ${counts.stillToPrint} still to print` : ' · all printed or printing') : ''}`; useEffect(() => { if (lib) setAnnounce(`${here}: ${summary}`); }, [here, summary, lib]); const filesBelow = useMemo(() => (lib && !groupMode ? (folderId == null ? lib.files.length : lib.files.filter((f) => f.folderId != null && folderAndBelow(lib.folders, folderId).has(f.folderId)).length) : 0), [lib, groupMode, folderId]); // Selection never holds files that left the view. @@ -318,7 +322,7 @@ export function PrintsPage() { if (!lib) return ; const sidebar = ( - newCollection()} onRenameCollection={renameCollection} onDeleteCollection={(id) => void deleteCollection(id)} /> ); const usedPct = Math.min(100, Math.round((lib.usage.used / Math.max(1, lib.usage.quota)) * 100)); @@ -373,7 +377,7 @@ export function PrintsPage() { ); const folderCount = (f: PrintFolder) => { const inside = folderAndBelow(lib.folders, f.id); return lib.files.filter((x) => x.folderId != null && inside.has(x.folderId)).length; }; const where = (f: PrintFile) => { const t = folderTrail(lib.folders, f.folderId); return t.length ? t.map((x) => x.name).join(' / ') : 'Top level'; }; - const meta = (f: PrintFile) => [f.subsystem, f.material, copiesText(f)].filter(Boolean).join(' · '); + const meta = (f: PrintFile) => [f.subsystem, f.material, copiesText(f), f.priority !== 'normal' && stillToPrint(f.status) ? `${PRIORITY_LABELS[f.priority]} priority` : null].filter(Boolean).join(' · '); return ( @@ -381,7 +385,7 @@ export function PrintsPage() { description="Every 3D print file in one library: folders for where a file lives, groups for how you think about it. One file can sit in many groups without a copy." actions={canEdit ? ( <> - + ) : undefined} /> @@ -401,200 +405,204 @@ export function PrintsPage() {
-
- {/* Location: the folder path, or the groups picked. */} -
- - {groupMode ? ( -
- {activeChips.map((k) => ( - - {filterLabel({ [k]: filters[k] } as PrintFilters, lib)} - - - ))} - -
- ) : ( -
- - {/* Toolbar */} -
-
- - setQuery(e.target.value)} placeholder="Search the library" aria-label="Search the library" className="h-10 pl-9" /> -
- - - -
- - + {trail.map((f, i) => ( +
  • + + +
  • + ))} + + + )}
    -
    - {/* What this group or folder holds */} -
    -

    - {here}{' '} - · {summary} -

    - -
    + {/* Toolbar */} +
    +
    + + setQuery(e.target.value)} placeholder="Search the library" aria-label="Search the library" className="h-10 pl-9" /> +
    + + + +
    + + +
    +
    - {selected.size > 0 && ( -
    - {selected.size} selected - {canEdit && } - {canEdit && } - {canEdit && ( - - - - Mark as - {(['to_print', 'printing'] as const).map((s) => void setStatus([...selected], s)}>{STATUS_LABELS[s]})} - - Add to collection - {lib.collections.map((c) => void collect([...selected], c.id)}>{c.name})} - newCollection([...selected])}>New collection… - {filters.collection !== undefined && ( - <> - - void collect([...selected], filters.collection!, false)}>Remove from {lib.collections.find((c) => c.id === filters.collection)?.name} - - )} - - - )} - {canEdit && } - - {canEdit && } - + {/* What this group or folder holds */} +
    +

    + {here}{' '} + · {summary} +

    +
    - )} - {uploads.length > 0 && setUploads([])} />} + {selected.size > 0 && ( +
    + {selected.size} selected + {canEdit && } + {canEdit && } + {canEdit && ( + + + + Mark as + {(['to_print', 'printing'] as const).map((s) => void setStatus([...selected], s)}>{STATUS_LABELS[s]})} + + Add to collection + {lib.collections.map((c) => void collect([...selected], c.id)}>{c.name})} + newCollection([...selected])}>New collection… + {filters.collection !== undefined && ( + <> + + void collect([...selected], filters.collection!, false)}>Remove from {lib.collections.find((c) => c.id === filters.collection)?.name} + + )} + + + )} + {canEdit && } + + {canEdit && } + +
    + )} + + {uploads.length > 0 && setUploads([])} />} - {shown.folders.length === 0 && shown.files.length === 0 ? ( - groupMode - ? go({})}>Clear groups} /> - : fileInput.current?.click()}> Upload files : undefined} /> - ) : view === 'list' ? ( -
    -
    - setSelected(v ? new Set(shown.files.map((f) => f.id)) : new Set())} disabled={!shown.files.length} /> - - Name - Status - Size - Changed - + {shown.folders.length === 0 && shown.files.length === 0 ? ( + groupMode + ? go({})}>Clear groups} /> + : fileInput.current?.click()}> Upload files : undefined} /> + ) : view === 'list' ? ( +
    +
    + setSelected(v ? new Set(shown.files.map((f) => f.id)) : new Set())} disabled={!shown.files.length} /> + + Name + Status + Size + Changed + +
    +
      + {shown.folders.map((f) => ( +
    • + + + + + + {fmtDate(f.updatedAt)} + {folderMenu(f)} +
    • + ))} + {shown.files.map((f) => ( +
    • + setSelected((s) => { const n = new Set(s); if (v) n.add(f.id); else n.delete(f.id); return n; })} /> + + + + {formatBytes(f.size)} + {fmtDate(f.updatedAt)} + {fileMenu(f)} +
    • + ))} +
    -
      + ) : ( +
        {shown.folders.map((f) => (
      • - - - - - - {fmtDate(f.updatedAt)} - {folderMenu(f)} + {folderMenu(f)}
      • ))} {shown.files.map((f) => (
      • - setSelected((s) => { const n = new Set(s); if (v) n.add(f.id); else n.delete(f.id); return n; })} /> - - - - {formatBytes(f.size)} - {fmtDate(f.updatedAt)} - {fileMenu(f)} +
        + + {fileMenu(f)} +
        + + setSelected((s) => { const n = new Set(s); if (v) n.add(f.id); else n.delete(f.id); return n; })} /> +
      • ))}
      -
    - ) : ( -
      - {shown.folders.map((f) => ( -
    • - - {folderMenu(f)} -
    • - ))} - {shown.files.map((f) => ( -
    • - -
      - - {fileMenu(f)} -
      - - setSelected((s) => { const n = new Set(s); if (v) n.add(f.id); else n.delete(f.id); return n; })} /> - -
    • - ))} -
    - )} - {canEdit && (shown.files.length > 0 || shown.folders.length > 0) && ( -

    Drop files here to upload. Drag files onto a folder to move them, or onto a collection to add them. Delete removes, F2 renames, Ctrl+A selects all.

    - )} -
    + )} + {canEdit && (shown.files.length > 0 || shown.folders.length > 0) && ( +

    Drop files here to upload. Drag files onto a folder to move them, or onto a collection to add them. Delete removes, F2 renames, Ctrl+A selects all.

    + )} + + )} diff --git a/src/modern/pages/prints/PrintsSidebar.tsx b/src/modern/pages/prints/PrintsSidebar.tsx index b5bcddade..937be6cb3 100644 --- a/src/modern/pages/prints/PrintsSidebar.tsx +++ b/src/modern/pages/prints/PrintsSidebar.tsx @@ -2,14 +2,14 @@ // team thinks about it) side by side. Smart groups are worked out from each // file's details; collections are the team's own. Picking a group filters the // library; picks in different groups combine ("Intake + PETG + To print"). -// Folders and collections take dropped files. +// Folders and collections take dropped files. The print queue sits on top. import { useMemo, useState, type DragEvent, type ReactNode } from 'react'; -import { ChevronRight, Library, MoreHorizontal, Plus } from 'lucide-react'; +import { ChevronRight, Library, ListOrdered, MoreHorizontal, Plus } from 'lucide-react'; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../components/ui-kit'; import { cn } from '../../../components/cn'; import { seasonName } from '../../../types/cadBom'; import { - PRINT_STATUSES, STATUS_LABELS, facetValues, matchesFilters, stillToPrint, type FilterKey, type PrintFilters, type PrintFolder, type PrintLibrary, + PRINT_STATUSES, STATUS_LABELS, dueState, facetValues, matchesFilters, stillToPrint, type FilterKey, type PrintFilters, type PrintFolder, type PrintLibrary, } from '../../../types/prints'; import { FolderArt, STATUS_DOT } from './printsUi'; @@ -54,8 +54,9 @@ function Heading({ children, action }: { children: ReactNode; action?: ReactNode ); } -export function PrintsSidebar({ lib, folderId, filters, groupMode, onFolder, onToggle, drop, onNewCollection, onRenameCollection, onDeleteCollection }: { - lib: PrintLibrary; folderId: number | null; filters: PrintFilters; groupMode: boolean; +export function PrintsSidebar({ lib, folderId, filters, groupMode, queueMode, onQueue, onFolder, onToggle, drop, onNewCollection, onRenameCollection, onDeleteCollection }: { + lib: PrintLibrary; folderId: number | null; filters: PrintFilters; groupMode: boolean; queueMode: boolean; + onQueue: () => void; onFolder: (id: number | null) => void; /** Toggle one smart group or collection; picks in different groups combine. */ onToggle: (key: K, value: PrintFilters[K]) => void; @@ -109,12 +110,19 @@ export function PrintsSidebar({ lib, folderId, filters, groupMode, onFolder, onT const subsystems = facetValues(lib.files, 'subsystem'); const materials = facetValues(lib.files, 'material'); const noRobot = lib.files.filter((f) => f.robotId == null).length; + const queued = lib.files.filter((f) => stillToPrint(f.status) || f.status === 'printing'); + const overdue = queued.filter((f) => dueState(f.dueDate) === 'overdue').length; return (