Belege zu Buchungen: Durchsuchen, Ziehen-und-Ablegen, Einfügen

Zu jeder Buchung lässt sich ein Beleg hinterlegen, im Formular oder
nachträglich über die Buchungsliste.

Der Medientyp wird aus der Dateisignatur bestimmt, nicht aus Endung oder
Browserangabe: eine als rechnung.pdf benannte Textdatei wird abgewiesen. SVG ist
ausgeschlossen, weil es Skripte enthalten kann, die beim Anzeigen ausgeführt
würden. Ausgeliefert wird mit nosniff und enger Content-Security-Policy.

Zur Aufbewahrungspflicht nach §147 AO werden Belege nie überschrieben und nie
gelöscht. Beim Ersetzen bleibt die alte Datei liegen; das Lösen der Verknüpfung
entfernt nur den Verweis. Der Dateiname trägt den SHA-256-Anfang, die Buchung
den vollständigen Hash.

Ablage unter data/belege/<mandant>/<jahr>/ – je Mandant getrennt und im selben
Bind-Mount wie die Datenbanken, sodass ein Backup des data-Ordners Buchungen und
Belege gemeinsam erfasst.

Bisher fehlte jede Möglichkeit, bestehende Mandantendateien um neue Spalten zu
erweitern: CREATE TABLE IF NOT EXISTS lässt vorhandene Tabellen unverändert. Neu
hinzugekommene Spalten werden jetzt beim Öffnen additiv ergänzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-03 22:28:16 +02:00
co-authored by Claude Opus 5
parent 49ddaeccff
commit b343e89fbf
12 changed files with 765 additions and 4 deletions
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react';
/**
* Auswahl eines Belegs über Durchsuchen, Ziehen-und-Ablegen oder Einfügen.
*
* Zum Einfügen mit Strg+V: Screenshots liegen als Bild in der Zwischenablage und
* funktionieren überall. Beim Kopieren einer Datei im Dateimanager hängt es vom
* System ab, ob die Datei selbst oder nur ein file://-Verweis abgelegt wird
* Letzteren darf der Browser aus Sicherheitsgründen nicht lesen. Deshalb wird
* hier typunabhängig alles übernommen, was als Datei ankommt (also auch PDFs),
* und für den Rest bleibt Ziehen-und-Ablegen der verlässliche Weg.
*/
export const BELEG_TYPEN = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/tiff',
'image/heic',
];
interface Props {
datei: File | null;
onDatei: (datei: File | null) => void;
/** Meldung des Servers nach dem Hochladen */
status?: { art: 'gut' | 'kritisch'; text: string } | null;
}
function lesbareGroesse(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} kB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export default function BelegFeld({ datei, onDatei, status }: Props) {
const [ueberZone, setUeberZone] = useState(false);
const [vorschau, setVorschau] = useState<string | null>(null);
const [eingefuegt, setEingefuegt] = useState(false);
const eingabe = useRef<HTMLInputElement>(null);
// Vorschaubild nur für Bilder; die Objekt-URL muss wieder freigegeben werden.
useEffect(() => {
if (!datei || !datei.type.startsWith('image/')) {
setVorschau(null);
return;
}
const url = URL.createObjectURL(datei);
setVorschau(url);
return () => URL.revokeObjectURL(url);
}, [datei]);
// Strg+V global aufnehmen, aber nur wenn tatsächlich eine Datei mitkommt
// sonst würde das Einfügen von Text in die übrigen Felder gestört.
useEffect(() => {
function beiEinfuegen(e: ClipboardEvent) {
const dateien = Array.from(e.clipboardData?.files ?? []);
if (dateien.length === 0) return;
e.preventDefault();
onDatei(dateien[0]!);
setEingefuegt(true);
setTimeout(() => setEingefuegt(false), 2500);
}
window.addEventListener('paste', beiEinfuegen);
return () => window.removeEventListener('paste', beiEinfuegen);
}, [onDatei]);
function beiAblegen(e: React.DragEvent) {
e.preventDefault();
setUeberZone(false);
const f = e.dataTransfer.files?.[0];
if (f) onDatei(f);
}
return (
<div className="feld" style={{ gridColumn: '1 / -1' }}>
<label>Beleg</label>
{!datei && (
<div
className={`ablagezone${ueberZone ? ' aktiv' : ''}`}
onDragOver={(e) => {
e.preventDefault();
setUeberZone(true);
}}
onDragLeave={() => setUeberZone(false)}
onDrop={beiAblegen}
onClick={() => eingabe.current?.click()}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && eingabe.current?.click()}
role="button"
tabIndex={0}
>
<strong>Datei hierher ziehen</strong>
<span>
oder <u>durchsuchen</u> · mit <kbd>Strg</kbd>+<kbd>V</kbd> einfügen
</span>
<span className="gedimmt" style={{ fontSize: 11 }}>
PDF, JPEG, PNG, WebP, TIFF, HEIC · bis 20 MB
</span>
</div>
)}
<input
ref={eingabe}
type="file"
accept={BELEG_TYPEN.join(',')}
style={{ display: 'none' }}
onChange={(e) => onDatei(e.target.files?.[0] ?? null)}
/>
{datei && (
<div className="belegvorschau">
{vorschau ? (
<img src={vorschau} alt="Vorschau des Belegs" />
) : (
<div className="belegsymbol" aria-hidden="true">
{datei.type === 'application/pdf' ? 'PDF' : 'DAT'}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{datei.name}
</div>
<div className="gedimmt" style={{ fontSize: 12 }}>
{lesbareGroesse(datei.size)}
{eingefuegt && ' · aus der Zwischenablage übernommen'}
</div>
</div>
<button
type="button"
onClick={() => {
onDatei(null);
if (eingabe.current) eingabe.current.value = '';
}}
style={{ padding: '4px 10px', fontSize: 13 }}
>
Entfernen
</button>
</div>
)}
{status && <div className={`hinweis ${status.art}`} style={{ marginTop: 8 }}>{status.text}</div>}
</div>
);
}
+85 -2
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { api, useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { BEHANDLUNG_LABEL, centAus, datum, eur, REGIME_LABEL } from '../lib/format';
import { mitMandant } from '../lib/mandant';
import BelegFeld from '../components/BelegFeld';
interface Konto {
nummer: string;
@@ -30,6 +32,9 @@ interface Buchung {
storniert: number;
festgeschrieben: number;
anlagegut_id: number | null;
beleg_datei: string | null;
beleg_name: string;
beleg_typ: string;
}
interface Aktivierung {
@@ -84,6 +89,8 @@ export default function Buchungen() {
const [form, setForm] = useState(LEER);
const [vorschau, setVorschau] = useState<Vorschau | null>(null);
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
const [beleg, setBeleg] = useState<File | null>(null);
const [belegStatus, setBelegStatus] = useState<{ art: 'gut' | 'kritisch'; text: string } | null>(null);
const { daten: konten } = useDaten<Konto[]>('/api/konten');
// 1800 ist NUR im SKR04 die Bank im SKR03 sind das Privatentnahmen.
@@ -156,17 +163,67 @@ export default function Buchungen() {
}
: undefined,
});
let belegHinweis = '';
if (beleg) {
// Der Upload braucht die Buchungsnummer, läuft also erst nach dem Speichern.
// Schlägt er fehl, bleibt die Buchung erhalten und der Beleg kann in der
// Liste nachgereicht werden doppeltes Erfassen wäre schlimmer.
try {
await belegHochladen(antwort.id, beleg);
belegHinweis = ' Beleg hinterlegt.';
} catch (err) {
setBelegStatus({
art: 'kritisch',
text: `Die Buchung wurde gespeichert, der Beleg jedoch nicht: ${(err as Error).message} ` +
'Er lässt sich in der Liste nachtragen.',
});
}
}
setMeldung({
art: 'gut',
text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`,
text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${belegHinweis}${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`,
});
setForm({ ...LEER, art: form.art, konto: form.konto, gegenkonto: form.gegenkonto });
setBeleg(null);
neuLaden();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
}
/** multipart/form-data deshalb nicht über den JSON-Helfer in lib/api. */
async function belegHochladen(buchungId: number, datei: File) {
const daten = new FormData();
daten.append('datei', datei);
const antwort = await fetch(`/api/buchungen/${buchungId}/beleg`, {
method: 'POST',
headers: { ...(localStorage.getItem('euer.mandant') ? { 'X-Mandant': localStorage.getItem('euer.mandant')! } : {}) },
body: daten,
});
const text = await antwort.text();
const inhalt = text ? JSON.parse(text) : null;
if (!antwort.ok) throw new Error(inhalt?.fehler ?? `Fehler ${antwort.status}`);
return inhalt;
}
async function belegNachtragen(b: Buchung) {
const wahl = document.createElement('input');
wahl.type = 'file';
wahl.accept = 'application/pdf,image/jpeg,image/png,image/webp,image/tiff,image/heic';
wahl.onchange = async () => {
const datei = wahl.files?.[0];
if (!datei) return;
try {
await belegHochladen(b.id, datei);
setMeldung({ art: 'gut', text: `Beleg zu Buchung ${b.id} hinterlegt.` });
neuLaden();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
};
wahl.click();
}
async function stornieren(id: number) {
if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return;
try {
@@ -304,6 +361,8 @@ export default function Buchungen() {
onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })}
/>
</div>
<BelegFeld datei={beleg} onDatei={setBeleg} status={belegStatus} />
</div>
{form.art === 'ausgabe' && (
@@ -426,6 +485,7 @@ export default function Buchungen() {
<th>Text</th>
<th>Konto</th>
<th>Behandlung</th>
<th>Beleg</th>
<th className="rechts">Netto</th>
<th className="rechts">USt</th>
<th className="rechts">Brutto</th>
@@ -447,6 +507,29 @@ export default function Buchungen() {
</td>
<td className="zahl">{b.konto}</td>
<td style={{ fontSize: 12 }}>{BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung}</td>
<td>
{b.beleg_datei ? (
<a
className="belegverweis"
href={mitMandant(`/api/buchungen/${b.id}/beleg`)}
target="_blank"
rel="noreferrer"
title={b.beleg_name}
>
{b.beleg_typ === 'application/pdf' ? 'PDF' : 'Bild'} ansehen
</a>
) : b.storniert ? (
<span className="gedimmt"></span>
) : (
<button
type="button"
onClick={() => belegNachtragen(b)}
style={{ padding: '2px 8px', fontSize: 12 }}
>
+ Beleg
</button>
)}
</td>
<td className="rechts zahl">{eur(b.betrag_netto)}</td>
<td className="rechts zahl">{b.betrag_ust ? eur(b.betrag_ust) : ''}</td>
<td className={`rechts zahl ${b.art === 'einnahme' ? 'gut' : ''}`}>
@@ -471,7 +554,7 @@ export default function Buchungen() {
))}
{buchungen?.length === 0 && (
<tr>
<td colSpan={9} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
<td colSpan={10} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
</tr>
)}
</tbody>
+76
View File
@@ -273,3 +273,79 @@ pre.xml {
.mandant-neu input[type='text'], .mandant-neu input:not([type]) { font-size: 13px; }
.ankreuz { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.ankreuz input { width: auto; }
/* ---- Belegfeld ---------------------------------------------------------- */
.ablagezone {
border: 1.5px dashed var(--border-stark);
border-radius: var(--radius);
padding: 18px 14px;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
color: var(--text-secondary);
font-size: 13px;
background: var(--surface-2);
}
.ablagezone:hover,
.ablagezone:focus-visible { border-color: var(--akzent); color: var(--text-primary); }
.ablagezone.aktiv {
border-color: var(--akzent);
background: var(--akzent-weich);
color: var(--akzent);
}
.ablagezone strong { font-weight: 600; font-size: 14px; color: var(--text-primary); }
kbd {
font-family: var(--schrift-zahl);
font-size: 11px;
padding: 1px 5px;
border: 1px solid var(--border-stark);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--surface-1);
}
.belegvorschau {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
}
.belegvorschau img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-1);
}
.belegsymbol {
width: 52px;
height: 52px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-1);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
/* Beleg-Verweis in der Buchungsliste */
.belegverweis {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
text-decoration: none;
color: var(--akzent);
}
.belegverweis:hover { text-decoration: underline; }