Files
klarbild/src/components/PrintRuns.tsx
T
tillandClaude Opus 5 a87587c273 Drucken: eindeutige Dateinamen für PDFs und Einzelbilder
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
2026-09-01 15:31:23 +00:00

221 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}