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..166e6a9f1 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,122 @@ 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('sends queue saves one at a time, so a slow older save never lands after a newer one', async () => { + lib = queueLib(); + let finishFirst: () => void = () => {}; + const sent: number[][] = []; + api.apiFetch.mockImplementation((url: string, init?: any) => { + if (url === '/api/prints/library') return res(lib); + if (url === '/api/prints/queue') { + sent.push(JSON.parse(init.body).ids); + if (sent.length === 1) return new Promise((r) => { finishFirst = () => r({ ok: true, status: 200, json: async () => ({ success: true }) }); }); + } + return res({ success: true, files: 1 }); + }); + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + const handle = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name.replace('.', '\\.')}, `) }); + fireEvent.keyDown(handle('Lift spool.3mf'), { key: 'ArrowUp', altKey: true }); + await waitFor(() => expect(sent).toEqual([[4, 3, 1]])); + // Two more moves while the first save is still out: nothing else is sent yet. + fireEvent.keyDown(handle('Lift spool.3mf'), { key: 'ArrowUp', altKey: true }); + fireEvent.keyDown(handle('Roller hub.stl'), { key: 'ArrowUp', altKey: true }); + expect(upNext().map((t) => t!.split('.')[0])).toEqual(['Lift spool', 'Roller hub', 'Camera mount']); + await new Promise((r) => setTimeout(r, 50)); + expect(sent).toHaveLength(1); + // Once it lands, only the latest order goes (it holds the move before it). + finishFirst(); + await waitFor(() => expect(sent).toEqual([[4, 3, 1], [3, 1, 4]])); + }); + + it('names the workspace a queued save was made in, even after a switch meanwhile', async () => { + lib = queueLib(); + let finishFirst: () => void = () => {}; + const teams: (string | null)[] = []; + api.apiFetch.mockImplementation((url: string, init?: any) => { + if (url === '/api/prints/library') return res(lib); + if (url === '/api/prints/queue') { + teams.push(new Headers(init.headers).get('X-CP-Prints-Team')); + if (teams.length === 1) return new Promise((r) => { finishFirst = () => r({ ok: true, status: 200, json: async () => ({ success: true }) }); }); + } + return res({ success: true, files: 1 }); + }); + setup('/prints?view=queue'); + await screen.findByRole('list', { name: 'Up next' }); + fireEvent.keyDown(screen.getByRole('button', { name: /^Lift spool\.3mf, / }), { key: 'ArrowUp', altKey: true }); + await waitFor(() => expect(teams).toEqual(['1'])); + fireEvent.keyDown(screen.getByRole('button', { name: /^Lift spool\.3mf, / }), { key: 'ArrowUp', altKey: true }); + // The workspace switches: the first save's reload brings back workspace 2. + lib = { ...queueLib(), teamId: 2 }; + finishFirst(); + // The waiting save still says workspace 1, so the server refuses it instead of clearing workspace 2's order. + await waitFor(() => expect(teams).toEqual(['1', '1'])); + }); + + 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..fd6e14ea5 --- /dev/null +++ b/src/modern/pages/prints/PrintQueue.tsx @@ -0,0 +1,249 @@ +// 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, type ReactNode } 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, uploads }: { + 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; + /** The upload progress panel, while uploads run or report. */ + uploads?: ReactNode; +}) { + 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 saves = useRef>(Promise.resolve()); + /** + * Save an order (or [] for priority order) after the saves before it, so an older one never lands last. A save a + * newer move replaced while it waited isn't sent: the newer one carries the whole order. Resolves once sent or skipped. + */ + const saveOrder = (ids: number[]) => { + const seq = ++saving.current; + // The workspace this order belongs to, fixed now: a save still waiting after a switch is refused there. + const team = lib.teamId; + const run = saves.current.then(() => (seq === saving.current ? prints.setQueueOrder(ids, team).then(() => {}) : undefined)); + saves.current = run.catch(() => {}); + return run.finally(() => { if (saving.current === seq) setPending(null); }); + }; + 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'; + setPending(next); + onAnnounce(`${name} is now ${next.indexOf(id) + 1} of ${next.length}`); + try { await saveOrder(next); } catch (e) { onError(e); } + }; + 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 saveOrder([]); 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 && } +
+ {uploads} + + {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..afa03ea53 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,206 @@ 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 (