mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
feat(inventory): printable PDF spool labels in 4 sizes (#809)
Closes the longest-standing inventory gap — finding a specific spool
in a closet of 50 partials. Per-spool icon button on every inventory
card and table row, plus a "Print labels..." header action that opens
a multi-select picker pre-loaded with the currently filtered spools.
Four pre-built templates: AMS holder (30 x 15 mm) for the popular
Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
the colour swatch (with multi-colour gradient stripes for spools
with extra_colors set), brand, material, name, the *spool ID*
(bsaunder's articulated user-need: telling 8 spools of "PLA White"
apart, especially partials), and a QR code that deep-links to
/inventory?spool=<id> for phone-scan round-trips. Box-label adds
storage location; AMS-holder drops the QR — at 30 x 15 mm there is
no room for swatch + text + QR without truncating away the spool ID,
and AMS-bay identification is at arm's length where the swatch and
ID are enough.
Server-side rendering via ReportLab + qrcode (already a dep). Pure
Python, no headless browser, no system libs. Output is byte-identical
across browsers, Avery sheets align to <0.1 mm, and bulk export is
one click for one PDF. Two endpoints — POST /inventory/labels (local
DB) and POST /spoolman/labels (Spoolman-backed) — gated on
INVENTORY_READ, capped at 500 spools per request, returning
application/pdf via StreamingResponse. The renderer is decoupled
from the SQLAlchemy model via a LabelData dataclass so the same code
path serves both modes.
Modal picker scales to large libraries: search (substring match
across name / brand / #ID), material filter chips derived from the
visible spools, additive Select-all-visible / Deselect-visible /
Clear-all actions so selections survive filter changes. Restyled
twice in development — first cut used generic Tailwind which clashed
with the inventory's bambu-dark palette; second cut switched to
bambu-dark-secondary / bambu-green / bambu-gray to match.
Two render bugs found during visual inspection of generated PDFs and
fixed before commit:
1. AMS-30x15 template originally produced labels with only swatch
+ QR and no text at all — the side-by-side layout left <5 mm
for the text column, so the renderer bailed without drawing
anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
regimes; tight regime drops the QR and gives the right column
to brand + material + a 13pt-bold spool ID.
2. Box-62x29 template aggressively truncated text — swatch + QR
each at ~14 mm on a 26mm-tall label squeezed the text column
to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
"Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
leaving the text column ~30 mm — full names render without
truncation.
Both bugs pinned by regression tests in test_label_renderer.py that
render with pageCompression=0 so the resulting PDF bytes contain the
text as ASCII and `assert b"Polymaker" in pdf` works.
This commit is contained in:
@@ -2259,6 +2259,9 @@ export interface LinkedSpoolsMap {
|
||||
}
|
||||
|
||||
// Inventory types
|
||||
// Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
|
||||
export type SpoolLabelTemplate = 'ams_30x15' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
|
||||
|
||||
export interface InventorySpool {
|
||||
id: number;
|
||||
material: string;
|
||||
@@ -4382,6 +4385,37 @@ export const api = {
|
||||
}),
|
||||
unassignSpool: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
|
||||
// ── Spool label printing (#809) ──────────────────────────────────────────
|
||||
// Both endpoints return application/pdf. Frontend opens the resulting Blob
|
||||
// in a new tab so the user can print or save from the browser's PDF viewer.
|
||||
printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const response = await fetch(`${API_BASE}/inventory/labels`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const response = await fetch(`${API_BASE}/spoolman/labels`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
getSpoolCatalog: () =>
|
||||
request<SpoolCatalogEntry[]>('/inventory/catalog'),
|
||||
addCatalogEntry: (data: { name: string; weight: number }) =>
|
||||
|
||||
Reference in New Issue
Block a user