Spool page for NFC links, bridge address; vendor-first new spool form
CI / test (push) Successful in 2m29s
CI / image (push) Successful in 39s

- /s/<id> shows a spool: rest in Spoolman and CC2 Control, where it is,
  last booking, history, links to Spoolman and the tag generator.
- Setting "Adresse der Bridge"; the tag generator can put <address>/s/<id>
  on the tag, so an iPhone held to the roll opens that page.
- New spool: vendor and material chosen from Spoolman, the vendor's
  filaments offered, values taken from similar filaments, duplicates
  pointed out and never created twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 21:52:23 +01:00
1 parent b37cd64909
commit 64ea91d107
10 files changed
+304 -49

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Spule {{ spool_id }}{% endblock %}
{% block heading %}Spule{% endblock %}
{% block head %}
<style>
.wrap { max-width: 720px; }
.hero { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
.hero h2 { font-size: 26px; letter-spacing: -.02em; overflow-wrap: anywhere; }
.big { font-family: var(--mono); font-size: 28px; }
.where { font-size: 15px; padding: 6px 12px; }
.acts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.acts .btn { min-height: 48px; }
.entry { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
.entry:first-child { border-top: 0; }
</style>
{% endblock %}
{% block content %}
<div class="wrap stack" id="page" data-id="{{ spool_id }}"><div class="card muted">Lade …</div></div>
{% endblock %}
{% block script %}
<script>
const ID = Number($('page').dataset.id);
function render(d) {
const s = d.spool, color = s.color ? '#' + s.color : 'var(--faint)';
const initial = s.initial || 1000, rest = s.remaining;
const pct = rest == null ? 0 : Math.max(0, Math.min(100, rest / initial * 100));
let where;
if (s.archived) where = '<span class="chip where">Archiviert (leer)</span>';
else if (d.slot && d.slot.takeout) where = `<span class="chip warn where">${esc(d.slot.printer)} · Slot ${d.slot.slot} (herausgenommen, steckt noch)</span>`;
else if (d.slot) where = `<span class="chip accent dot where">${esc(d.slot.printer)} · Slot ${d.slot.slot}</span>`;
else where = `<span class="chip where">${esc(s.location || d.storage)}</span>`;
const rows = [
['Rest in Spoolman', rest == null ? '–' : `${fmtNum(rest, 1)} g`],
...d.copies.map((c) => [`Rest in CC2 Control (${esc(c.printer)})`, c.remaining == null ? '–' : `${fmtNum(c.remaining, 1)} g`]),
['Ort in Spoolman', esc(s.location || '–')],
['Zuletzt gebucht', d.last_booking ? `${fmtNum(d.last_booking.grams, 2)} g · ${fmtTime(d.last_booking.time)}${d.last_booking.details ? `<br><span class="small muted">${esc(d.last_booking.details)}</span>` : ''}` : '–'],
['Material', `${esc(s.vendor)} ${esc(s.material)}${s.diameter ? ` · ${s.diameter} mm` : ''}${s.density ? ` · ${s.density} g/cm³` : ''}`],
['Düse', s.extruder_temp ? `${s.extruder_temp} °C` : '–'],
['Leergewicht', s.spool_weight ? `${fmtNum(s.spool_weight)} g` : '–'],
];
$('page').innerHTML = `
<div class="card stack">
<div class="hero">
<div class="ring" style="width:120px;height:120px;background:conic-gradient(${color} 0 ${pct}%, var(--track) ${pct}% 100%)" aria-hidden="true">
<div style="width:88px;height:88px"><span class="mono" style="font-size:20px;line-height:1">${rest == null ? '?' : Math.round(rest)}</span><span class="small muted">von ${Math.round(initial)} g</span></div>
</div>
<div style="flex:1;min-width:200px" class="stack">
<div><div class="small muted">Spoolman #${s.id}</div><h2>${esc(s.label)}</h2><div class="muted">${esc(d.filament_name)}</div></div>
<div>${where}</div>
</div>
</div>
<dl class="kv">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>
<div class="acts">
<a class="btn" href="${esc(d.spoolman_url)}" target="_blank" rel="noopener">In Spoolman öffnen</a>
<a class="btn" href="/tags?spool=${s.id}">Tag neu schreiben</a>
</div>
</div>
${d.log.length ? `<div class="label section-label" style="margin:8px 4px 0">Verlauf</div>
<div class="group" style="padding:0 14px">${d.log.map((e) => {
const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, d.copies.map((c) => c.printer).concat(d.slot ? [d.slot.printer] : []));
return `<div class="entry"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
<div style="min-width:0"><div style="font-size:14px;overflow-wrap:anywhere">${esc(msg)}</div><div class="small muted">${esc(pn)} · ${fmtTime(e.time)}</div></div></div>`;
}).join('')}</div>` : ''}`;
}
async function load() {
try {
const d = await api('GET', `/api/spool/${ID}`);
setMode(d.dry_run);
render(d);
} catch (e) { $('page').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
load();
setInterval(() => { if (!document.hidden) load(); }, 10000);
</script>
{% endblock %}