Zwei Druckbogen desselben Tages hießen beide `2026-09-01_druckbogen.pdf`. Der
Browser hängte „(1)" an; wer sie in denselben Ordner oder aufs NAS legte, verlor
den ersten. Genau der Fehler, der im August für die Bibliotheksbilder behoben
wurde — im Druckmodul stand er noch.
Druckdateien folgen jetzt derselben Regel wie alles andere:
2026-09-01_143207_druck-9-teile_a4_4eca.pdf
Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier, ein Kürzel.
Das Kürzel ist die Kennung des Verlaufseintrags — eine PDF-Datei lässt sich also
ihrem Eintrag unter „Zuletzt gedruckt" zuordnen. Dafür entsteht die Kennung vor
dem Dateinamen und wird an `recordRun` durchgereicht.
Einzelbilder erben das Motiv ihrer Quelle:
`2026-09-01_143207_lea-badge_130x180_25b3.jpg` statt `2026-09-01_1318.jpg`.
Dafür gibt `loadSourceNamed` neben den Bilddaten auch Namen und Kennung zurück.
Die Oberfläche erfindet keinen eigenen Namen mehr. Der Server schickt einen, der
Client liest ihn aus `Content-Disposition` — es gab zwei Wahrheiten, und die des
Clients war die schlechtere: Sie enthielt nur das Datum. Aus dem Kopf gelesene
Namen werden entschärft, bevor sie in `a.download` landen.
`sheetFilename` in printsource.ts ist entfallen; die Regeln stehen jetzt
vollständig in `naming.ts`, wo sie schon für die Bibliothek stehen.
12 neue Tests, 123 grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { nameAusKopf } from '../lib/naming';
|
||
|
||
/**
|
||
* Die letzten Drucke.
|
||
*
|
||
* Gespeichert ist **nur die Anleitung** — Bogen, Maße, Stückzahlen und die
|
||
* Verweise auf die Bilder. Kein PDF: Ein A3-Bogen ist schnell 20–80 MB, und
|
||
* das Modul rechnet ohne KI, also ergibt derselbe Auftrag wieder exakt
|
||
* dasselbe Blatt. Neu erzeugen ist billiger als aufheben.
|
||
*
|
||
* „Nochmal drucken" geht deshalb nur, wenn **alle** Bilder aus der Bibliothek
|
||
* kamen und noch da sind. Bei hochgeladenen Dateien ist die Quelle nach dem
|
||
* Aufräumen weg — das steht dann auch so am Eintrag.
|
||
*/
|
||
interface Run {
|
||
id: string; origin: string; summary: string | null; paper: string | null;
|
||
pages: number; pieces: number; bytes: number | null; created_at: string;
|
||
bilder: { id: string; filename: string | null }[];
|
||
fehlend: number; hochgeladen: number; wiederholbar: boolean; config: any;
|
||
}
|
||
|
||
const wann = (s: string) => {
|
||
const d = new Date(s);
|
||
const minuten = Math.round((Date.now() - d.getTime()) / 60000);
|
||
if (minuten < 1) return 'gerade eben';
|
||
if (minuten < 60) return `vor ${minuten} min`;
|
||
if (minuten < 24 * 60) return `vor ${Math.round(minuten / 60)} h`;
|
||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
};
|
||
// Ein Testbogen sind 20 KB, ein A3-Poster 80 MB. `toFixed(1)` in MB machte aus
|
||
// dem kleinen Fall „0.0 MB groß" — also unterhalb eines Megabytes in KB.
|
||
const groesse = (b: number | null) => {
|
||
if (!b) return '';
|
||
if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`;
|
||
return `${(b / 1048576).toFixed(1)} MB`.replace('.', ',');
|
||
};
|
||
const zahl = (n: number, ein: string, viele: string) => `${n} ${n === 1 ? ein : viele}`;
|
||
|
||
export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void }) {
|
||
const [runs, setRuns] = useState<Run[]>([]);
|
||
const [laden, setLaden] = useState(true);
|
||
const [fehler, setFehler] = useState(false);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||
|
||
const holen = useCallback(async () => {
|
||
try {
|
||
const r = await fetch('/api/print/runs');
|
||
if (!r.ok) throw new Error(String(r.status));
|
||
const d = await r.json();
|
||
setRuns(d.runs || []); setFehler(false);
|
||
} catch { setFehler(true); }
|
||
finally { setLaden(false); }
|
||
}, []);
|
||
useEffect(() => { holen(); }, [holen]);
|
||
|
||
const sagen = (t: string, ok = true) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
|
||
|
||
/** Denselben Bogen noch einmal erzeugen — dieselbe Anfrage, dasselbe PDF. */
|
||
const nochmal = async (r: Run) => {
|
||
setBusy(r.id);
|
||
try {
|
||
const res = await fetch('/api/print/sheet', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
// `origin` markiert den Lauf als Wiederholung, damit die Liste nicht
|
||
// aussieht, als hätte jemand denselben Bogen zweimal von Hand gebaut.
|
||
body: JSON.stringify({ ...r.config, origin: 'wiederholung' }),
|
||
});
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}));
|
||
sagen(j.error || 'Das hat nicht geklappt — vielleicht fehlt ein Bild.', false);
|
||
return;
|
||
}
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
// Der Wiederholungsdruck ist eine **neue** Datei mit neuem Zeitstempel und
|
||
// neuem Kürzel — er überschreibt den ersten Bogen also nicht.
|
||
a.download = nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf');
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||
sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.');
|
||
holen();
|
||
} catch { sagen('Netzwerkfehler.', false); }
|
||
finally { setBusy(null); }
|
||
};
|
||
|
||
const loeschen = async (r: Run) => {
|
||
if (!confirm('Diesen Eintrag aus der Liste nehmen?\n\nDie Bilder bleiben in der Bibliothek — es verschwindet nur der Verlaufseintrag.')) return;
|
||
const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||
if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else sagen(res.error || 'Hat nicht geklappt.', false);
|
||
};
|
||
|
||
if (laden) return <p className="pr-leer">Wird geladen …</p>;
|
||
|
||
if (fehler) return (
|
||
<div className="pr">
|
||
<p className="pr-leer">Der Verlauf ließ sich nicht laden.{' '}
|
||
<button className="pr-btn" onClick={() => { setLaden(true); holen(); }}>Noch einmal</button></p>
|
||
<PrintRunsStyles />
|
||
</div>
|
||
);
|
||
|
||
if (!runs.length) return (
|
||
<div className="pr">
|
||
<div className="pr-leer-box">
|
||
<span className="regmark" />
|
||
<h2>Noch nichts gedruckt</h2>
|
||
<p>Sobald du im <a href="/drucken">Druckbereich</a> ein PDF erzeugst, steht es hier —
|
||
mit einem Knopf, um genau denselben Bogen noch einmal zu bekommen.</p>
|
||
</div>
|
||
<PrintRunsStyles />
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="pr">
|
||
{msg && <p className={`pr-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||
|
||
<div className="pr-liste">
|
||
{runs.map((r) => (
|
||
<article key={r.id} className="pr-karte">
|
||
<div className="pr-kopf">
|
||
<div className="pr-titel">
|
||
<h3>{r.summary || 'Druckbogen'}</h3>
|
||
<span className="pr-wann">{wann(r.created_at)}</span>
|
||
</div>
|
||
{r.origin === 'wiederholung' && <span className="pr-marke">wiederholt</span>}
|
||
{r.origin === 'telegram' && <span className="pr-marke">Telegram</span>}
|
||
</div>
|
||
|
||
<div className="pr-meta">
|
||
{r.paper} · {zahl(r.pages, 'Bogen', 'Bogen')} · {zahl(r.pieces, 'Teil', 'Teile')}
|
||
{r.bytes ? <> · das PDF war {groesse(r.bytes)} groß</> : null}
|
||
</div>
|
||
|
||
{r.bilder.length > 0 && (
|
||
<div className="pr-bilder">
|
||
{r.bilder.slice(0, 8).map((b) => (
|
||
<img key={b.id} src={`/api/items/${b.id}/file?thumb=1`} alt={b.filename || ''}
|
||
title={b.filename || ''} loading="lazy"
|
||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
|
||
))}
|
||
{r.bilder.length > 8 && <span className="pr-mehr">+{r.bilder.length - 8}</span>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="pr-aktionen">
|
||
{r.wiederholbar ? (
|
||
<button className="pr-btn stark" disabled={busy === r.id} onClick={() => nochmal(r)}>
|
||
{busy === r.id ? 'Wird erzeugt …' : 'Nochmal drucken'}
|
||
</button>
|
||
) : (
|
||
<span className="pr-warum">
|
||
{r.fehlend > 0
|
||
? `${zahl(r.fehlend, 'Bild ist', 'Bilder sind')} inzwischen gelöscht — nicht wiederholbar.`
|
||
: 'Damals hochgeladen statt aus der Bibliothek — die Quelldatei ist weg.'}
|
||
</span>
|
||
)}
|
||
{onLaden && (
|
||
<button className="pr-btn" onClick={() => onLaden(r.config)}>Einstellung übernehmen</button>
|
||
)}
|
||
<button className="pr-btn gefahr" onClick={() => loeschen(r)}>Entfernen</button>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<p className="pr-fein">
|
||
Hier liegen nur die Einstellungen, nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB.
|
||
Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt.
|
||
Aufgehoben werden die letzten {100} Drucke je Person.
|
||
</p>
|
||
|
||
<PrintRunsStyles />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PrintRunsStyles() {
|
||
return <style>{`
|
||
.pr{display:grid;gap:14px;}
|
||
.pr-leer{color:var(--soft);}
|
||
.pr-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||
padding:44px 24px;text-align:center;}
|
||
.pr-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||
.pr-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||
.pr-liste{display:grid;gap:12px;}
|
||
.pr-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||
padding:15px;display:grid;gap:10px;}
|
||
.pr-kopf{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}
|
||
.pr-titel{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;min-width:0;flex:1;}
|
||
.pr-titel h3{margin:0;font-size:.98rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||
.pr-wann{font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||
.pr-marke{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||
padding:3px 8px;border-radius:20px;background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||
.pr-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||
.pr-bilder{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||
.pr-bilder img{width:46px;height:46px;object-fit:cover;border:1px solid var(--line);border-radius:3px;
|
||
background:var(--paper);}
|
||
.pr-mehr{font-family:var(--font-mono);font-size:11px;color:var(--soft);}
|
||
.pr-aktionen{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||
.pr-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||
padding:9px 12px;font-size:.84rem;cursor:pointer;font-family:inherit;color:var(--ink);white-space:nowrap;}
|
||
.pr-btn:hover{border-color:var(--accent);}
|
||
.pr-btn.stark{background:var(--accent);color:var(--card);border-color:var(--accent);font-weight:600;}
|
||
.pr-btn.gefahr{color:var(--err);}
|
||
.pr-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||
.pr-warum{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||
.pr-fein{margin:0;font-size:.78rem;color:var(--soft);line-height:1.6;max-width:70ch;}
|
||
.pr-msg{margin:0;font-size:.9rem;}
|
||
.pr-msg.ok{color:var(--ok);}
|
||
.pr-msg.err{color:var(--err);}
|
||
@media(max-width:640px){
|
||
.pr-btn{flex:1;text-align:center;padding:11px 10px;}
|
||
.pr-warum{flex-basis:100%;}
|
||
}
|
||
`}</style>;
|
||
}
|