Einnahmen-Überschuss-Rechnung mit ELSTER, DATEV und Mandantenfähigkeit

Gewinnermittlung nach §4 Abs. 3 EStG für Einzelunternehmen und Freiberufler.
Node/Fastify mit SQLite im Backend, React/Vite im Frontend; die Anwendung
läuft lokal ohne Cloud-Anbindung.

Fachlicher Kern ist eine Zeitachse der Besteuerungsform: das umsatzsteuerliche
Regime wird je Buchung aus dem Leistungsdatum aufgelöst, statt aus einer
globalen Ja/Nein-Flagge. Dadurch ist auch ein unterjähriger Wechsel von der
Kleinunternehmerregelung zur Regelbesteuerung abbildbar, wie ihn §19 Abs. 1
Satz 3 UStG seit 2025 beim Überschreiten der 100.000-EUR-Grenze vorsieht.

Enthalten:
- Buchungserfassung nach dem Zu-/Abflussprinzip mit Storno statt Löschung
- Anlagevermögen mit AfA (linear, GWG, Sammelposten); die Aktivierung
  entscheidet das Konto, die Methode der Betrag
- Anlage EÜR, BWA im Aufbau der Form 01, Umsatzsteuer-Voranmeldung
- ELSTER-Anbindung über ERiC mit validierendem Simulationsmodus
- DATEV-Buchungsstapel (EXTF 700) und GoBD-Datenträgerüberlassung (Z3)
- Kontenrahmen SKR03 und SKR04, je Mandant beim Anlegen wählbar
- Mandantenfähigkeit mit einer SQLite-Datei je Mandant; Testmandanten sind
  von der Übermittlung ans Finanzamt ausgeschlossen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-03 21:45:52 +02:00
co-authored by Claude Opus 5
commit bf371c8118
59 changed files with 12913 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Einnahmen-Überschuss-Rechnung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:polling": "EUER_POLLING=1 vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
import MandantenWahl from './components/MandantenWahl';
import Dashboard from './pages/Dashboard';
import Buchungen from './pages/Buchungen';
import Anlagen from './pages/Anlagen';
import Euer from './pages/Euer';
import Bwa from './pages/Bwa';
import Ustva from './pages/Ustva';
import Stammdaten from './pages/Stammdaten';
import Export from './pages/Export';
const NAVIGATION = [
{
gruppe: 'Übersicht',
eintraege: [{ pfad: '/', titel: 'Startseite', exakt: true }],
},
{
gruppe: 'Erfassung',
eintraege: [
{ pfad: '/buchungen', titel: 'Buchungen' },
{ pfad: '/anlagen', titel: 'Anlagevermögen' },
],
},
{
gruppe: 'Auswertungen',
eintraege: [
{ pfad: '/euer', titel: 'EÜR' },
{ pfad: '/bwa', titel: 'BWA' },
{ pfad: '/ustva', titel: 'USt-Voranmeldung' },
],
},
{
gruppe: 'Verwaltung',
eintraege: [
{ pfad: '/stammdaten', titel: 'Stammdaten & Regime' },
{ pfad: '/export', titel: 'Export' },
],
},
];
export default function App() {
return (
<div className="rahmen">
<nav className="seitenleiste">
<div className="marke">
Einnahmen-Überschuss-Rechnung
<small>open-eur-webapp</small>
</div>
<MandantenWahl />
{NAVIGATION.map((g) => (
<div key={g.gruppe}>
<div className="nav-gruppe">{g.gruppe}</div>
{g.eintraege.map((e) => (
<NavLink
key={e.pfad}
to={e.pfad}
end={'exakt' in e ? e.exakt : false}
className={({ isActive }) => (isActive ? 'aktiv' : '')}
>
{e.titel}
</NavLink>
))}
</div>
))}
</nav>
<main className="inhalt">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/buchungen" element={<Buchungen />} />
<Route path="/anlagen" element={<Anlagen />} />
<Route path="/euer" element={<Euer />} />
<Route path="/bwa" element={<Bwa />} />
<Route path="/ustva" element={<Ustva />} />
<Route path="/stammdaten" element={<Stammdaten />} />
<Route path="/export" element={<Export />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { api, useDaten } from '../lib/api';
import { aktiverMandant, wechsleMandant, type Mandant } from '../lib/mandant';
interface Antwort {
standard: string | null;
mandanten: Mandant[];
}
export default function MandantenWahl() {
const { daten, neuLaden } = useDaten<Antwort>('/api/mandanten');
const { daten: rahmen } = useDaten<{ id: string; bezeichnung: string; konten: number }[]>(
'/api/kontenrahmen',
);
const [anlegen, setAnlegen] = useState(false);
const [neu, setNeu] = useState({ name: '', testmandant: true, kontenrahmen: 'SKR04' });
const [fehler, setFehler] = useState<string | null>(null);
if (!daten) return null;
const aktiv = aktiverMandant() ?? daten.standard;
const gewaehlt = daten.mandanten.find((m) => m.slug === aktiv);
async function speichern(e: React.FormEvent) {
e.preventDefault();
setFehler(null);
try {
const r = await api.post<{ slug: string }>('/api/mandanten', neu);
neuLaden();
wechsleMandant(r.slug);
} catch (err) {
setFehler((err as Error).message);
}
}
return (
<div className="mandantenwahl">
<label htmlFor="mandant">Mandant</label>
<select
id="mandant"
value={aktiv ?? ''}
onChange={(e) => {
if (e.target.value === '__neu__') setAnlegen(true);
else wechsleMandant(e.target.value);
}}
>
{daten.mandanten.map((m) => (
<option key={m.slug} value={m.slug}>
{m.name}
{m.testmandant ? ' (Test)' : ''}
</option>
))}
<option value="__neu__">+ Neuer Mandant </option>
</select>
{gewaehlt?.testmandant && (
<div className="testkennung" role="status">
Testmandant keine Übermittlung ans Finanzamt
</div>
)}
{anlegen && (
<form onSubmit={speichern} className="mandant-neu">
<input
autoFocus
required
placeholder="Name des Unternehmens"
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
/>
<select
value={neu.kontenrahmen}
onChange={(e) => setNeu({ ...neu, kontenrahmen: e.target.value })}
>
{(rahmen ?? []).map((r) => (
<option key={r.id} value={r.id} title={r.bezeichnung}>
{r.id} ({r.konten} Konten)
</option>
))}
</select>
<span className="hilfe" style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Nach dem Anlegen nicht mehr wechselbar dieselbe Kontonummer bedeutet in SKR03 und
SKR04 etwas anderes.
</span>
<label className="ankreuz">
<input
type="checkbox"
checked={neu.testmandant}
onChange={(e) => setNeu({ ...neu, testmandant: e.target.checked })}
/>
Testmandant
</label>
{fehler && <div className="hinweis kritisch">{fehler}</div>}
<div style={{ display: 'flex', gap: 6 }}>
<button type="submit" className="primaer" style={{ flex: 1, padding: '5px 8px', fontSize: 13 }}>
Anlegen
</button>
<button
type="button"
style={{ padding: '5px 8px', fontSize: 13 }}
onClick={() => {
setAnlegen(false);
setFehler(null);
}}
>
Abbrechen
</button>
</div>
</form>
)}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from 'react';
import { eur, eurKurz } from '../lib/format';
export interface MonatsWert {
monat: string; // YYYY-MM
einnahmen: number;
ausgaben: number;
}
const KURZ = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const BREITE = 760;
const HOEHE = 220;
const RAND = { oben: 12, rechts: 8, unten: 26, links: 52 };
const ECKE = 4;
const LUECKE = 2; // Trennung zwischen benachbarten Balken
/** Rechteck mit abgerundeten oberen Ecken, an der Grundlinie verankert. */
function balkenPfad(x: number, y: number, b: number, h: number): string {
if (h <= 0) return '';
const r = Math.min(ECKE, h, b / 2);
return `M${x},${y + h}V${y + r}a${r},${r} 0 0 1 ${r},${-r}h${b - 2 * r}a${r},${r} 0 0 1 ${r},${r}V${y + h}Z`;
}
export default function MonatsDiagramm({ werte }: { werte: MonatsWert[] }) {
const [aktiv, setAktiv] = useState<number | null>(null);
const max = Math.max(1, ...werte.flatMap((w) => [w.einnahmen, w.ausgaben]));
// Achsenschritt aus einer Leiter runder Zahlen wählen, damit die
// Beschriftungen nicht auf krummen Werten wie "1,3 T€" landen.
const roh = max / 4;
const zehner = Math.pow(10, Math.floor(Math.log10(roh)));
const schritt = ([1, 2, 2.5, 5, 10].find((f) => f * zehner >= roh) ?? 10) * zehner;
const obergrenze = Math.ceil(max / schritt) * schritt;
const plotBreite = BREITE - RAND.links - RAND.rechts;
const plotHoehe = HOEHE - RAND.oben - RAND.unten;
const gruppenBreite = plotBreite / werte.length;
const balkenBreite = Math.max(3, (gruppenBreite - 10 - LUECKE) / 2);
const y = (v: number) => RAND.oben + plotHoehe - (v / obergrenze) * plotHoehe;
const gitter = Array.from({ length: Math.round(obergrenze / schritt) + 1 }, (_, i) => i * schritt);
const gezeigt = aktiv !== null ? werte[aktiv] : null;
return (
<div style={{ position: 'relative' }}>
<div className="legende">
<span>
<i style={{ background: 'var(--series-1)' }} /> Betriebseinnahmen
</span>
<span>
<i style={{ background: 'var(--series-2)' }} /> Betriebsausgaben
</span>
</div>
<svg
className="diagramm"
viewBox={`0 0 ${BREITE} ${HOEHE}`}
role="img"
aria-label="Betriebseinnahmen und Betriebsausgaben je Monat"
onMouseLeave={() => setAktiv(null)}
>
{gitter.map((v) => (
<g key={v}>
<line
x1={RAND.links}
x2={BREITE - RAND.rechts}
y1={y(v)}
y2={y(v)}
stroke="var(--border)"
strokeWidth={1}
/>
<text
x={RAND.links - 8}
y={y(v) + 4}
textAnchor="end"
fontSize={11}
fill="var(--text-muted)"
>
{eurKurz(v)}
</text>
</g>
))}
{werte.map((w, i) => {
const x0 = RAND.links + i * gruppenBreite + 5;
return (
<g
key={w.monat}
onMouseEnter={() => setAktiv(i)}
onFocus={() => setAktiv(i)}
tabIndex={0}
style={{ cursor: 'pointer' }}
>
{/* großzügige Trefferfläche über die gesamte Monatsspalte */}
<rect
x={RAND.links + i * gruppenBreite}
y={RAND.oben}
width={gruppenBreite}
height={plotHoehe}
fill={aktiv === i ? 'var(--surface-2)' : 'transparent'}
/>
<path
d={balkenPfad(x0, y(w.einnahmen), balkenBreite, plotHoehe - (y(w.einnahmen) - RAND.oben))}
fill="var(--series-1)"
/>
<path
d={balkenPfad(
x0 + balkenBreite + LUECKE,
y(w.ausgaben),
balkenBreite,
plotHoehe - (y(w.ausgaben) - RAND.oben),
)}
fill="var(--series-2)"
/>
<text
x={RAND.links + i * gruppenBreite + gruppenBreite / 2}
y={HOEHE - 8}
textAnchor="middle"
fontSize={11}
fill="var(--text-muted)"
>
{KURZ[i]}
</text>
</g>
);
})}
<line
x1={RAND.links}
x2={BREITE - RAND.rechts}
y1={y(0)}
y2={y(0)}
stroke="var(--border-stark)"
strokeWidth={1}
/>
</svg>
{gezeigt && aktiv !== null && (
<div
style={{
position: 'absolute',
top: 26,
left: `${((RAND.links + aktiv * gruppenBreite + gruppenBreite / 2) / BREITE) * 100}%`,
transform: 'translateX(-50%)',
background: 'var(--surface-1)',
border: '1px solid var(--border-stark)',
borderRadius: 8,
padding: '8px 10px',
fontSize: 12,
pointerEvents: 'none',
whiteSpace: 'nowrap',
boxShadow: '0 4px 14px rgba(0,0,0,0.12)',
}}
>
<strong>
{KURZ[aktiv]} {gezeigt.monat.slice(0, 4)}
</strong>
<div style={{ marginTop: 4 }}>
<i
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 2,
background: 'var(--series-1)',
marginRight: 6,
}}
/>
Einnahmen <span className="zahl">{eur(gezeigt.einnahmen)}</span>
</div>
<div>
<i
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 2,
background: 'var(--series-2)',
marginRight: 6,
}}
/>
Ausgaben <span className="zahl">{eur(gezeigt.ausgaben)}</span>
</div>
</div>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useState } from 'react';
import { aktiverMandant } from './mandant';
export class ApiFehler extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
async function anfrage<T>(pfad: string, init?: RequestInit): Promise<T> {
// Jeder Aufruf trägt den aktiven Mandanten; ohne Angabe wählt der Server.
const slug = aktiverMandant();
const antwort = await fetch(pfad, {
...init,
headers: {
'Content-Type': 'application/json',
...(slug ? { 'X-Mandant': slug } : {}),
...(init?.headers ?? {}),
},
});
const text = await antwort.text();
const daten = text ? JSON.parse(text) : null;
if (!antwort.ok) {
throw new ApiFehler(daten?.fehler ?? `Fehler ${antwort.status}`, antwort.status);
}
return daten as T;
}
export const api = {
get: <T,>(pfad: string) => anfrage<T>(pfad),
post: <T,>(pfad: string, body?: unknown) =>
anfrage<T>(pfad, { method: 'POST', body: JSON.stringify(body ?? {}) }),
put: <T,>(pfad: string, body: unknown) =>
anfrage<T>(pfad, { method: 'PUT', body: JSON.stringify(body) }),
del: <T,>(pfad: string) => anfrage<T>(pfad, { method: 'DELETE' }),
};
export function useDaten<T>(pfad: string | null): {
daten: T | null;
fehler: string | null;
laedt: boolean;
neuLaden: () => void;
} {
const [daten, setDaten] = useState<T | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
const [zaehler, setZaehler] = useState(0);
useEffect(() => {
if (!pfad) return;
let abgebrochen = false;
setLaedt(true);
api
.get<T>(pfad)
.then((d) => {
if (!abgebrochen) {
setDaten(d);
setFehler(null);
}
})
.catch((e: Error) => !abgebrochen && setFehler(e.message))
.finally(() => !abgebrochen && setLaedt(false));
return () => {
abgebrochen = true;
};
}, [pfad, zaehler]);
const neuLaden = useCallback(() => setZaehler((z) => z + 1), []);
return { daten, fehler, laedt, neuLaden };
}
+72
View File
@@ -0,0 +1,72 @@
/** Alle Beträge kommen als ganzzahlige Cent aus dem Backend. */
export function eur(cent: number, mitZeichen = false): string {
const s = (cent / 100).toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return mitZeichen && cent > 0 ? `+${s}` : `${s}`;
}
export function eurKurz(cent: number): string {
const v = cent / 100;
if (Math.abs(v) >= 1000) return `${(v / 1000).toLocaleString('de-DE', { maximumFractionDigits: 1 })} T€`;
return `${v.toLocaleString('de-DE', { maximumFractionDigits: 0 })} €`;
}
/** Eingabefeld "1.234,56" -> 123456 Cent */
export function centAus(eingabe: string): number {
const bereinigt = eingabe.replace(/\s|€/g, '').replace(/\./g, '').replace(',', '.');
const zahl = Number.parseFloat(bereinigt);
return Number.isFinite(zahl) ? Math.round(zahl * 100) : 0;
}
export function centZuFeld(cent: number): string {
return (cent / 100).toFixed(2).replace('.', ',');
}
export function datum(iso: string | null): string {
if (!iso) return '';
const [j, m, t] = iso.slice(0, 10).split('-');
return `${t}.${m}.${j}`;
}
export function prozent(bp: number): string {
return `${(bp / 100).toLocaleString('de-DE', { maximumFractionDigits: 2 })} %`;
}
export const MONATSNAMEN = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
export const ZEITRAUM_LABEL: Record<string, string> = {
monatlich: 'monatlich',
vierteljaehrlich: 'vierteljährlich',
jaehrlich: 'jährlich',
};
/** Prozentwert mit deutschem Dezimalkomma. */
export function proz(wert: number, stellen = 1, mitVorzeichen = false): string {
const s = wert.toLocaleString('de-DE', {
minimumFractionDigits: stellen,
maximumFractionDigits: stellen,
});
return `${mitVorzeichen && wert > 0 ? '+' : ''}${s} %`;
}
export const REGIME_LABEL: Record<string, string> = {
kleinunternehmer: 'Kleinunternehmer (§19 UStG)',
regelbesteuerung: 'Regelbesteuerung',
};
export const BEHANDLUNG_LABEL: Record<string, string> = {
regel: 'Steuerpflichtig',
kleinunternehmer: 'Kleinunternehmer §19',
steuerfrei_mit_vst: 'Steuerfrei mit Vorsteuerabzug',
steuerfrei_ohne_vst: 'Steuerfrei ohne Vorsteuerabzug',
ig_lieferung: 'Innergem. Lieferung',
ig_erwerb: 'Innergem. Erwerb',
reverse_charge_13b: 'Reverse Charge §13b',
nicht_steuerbar: 'Nicht steuerbar',
};
+20
View File
@@ -0,0 +1,20 @@
import { useCallback, useState } from 'react';
const SCHLUESSEL = 'euer.jahr';
/** Das gewählte Wirtschaftsjahr wird seitenübergreifend gemerkt. */
export function useJahr(): [number, (j: number) => void, number[]] {
const [jahr, setJahrIntern] = useState(() => {
const gespeichert = Number(localStorage.getItem(SCHLUESSEL));
return gespeichert || new Date().getFullYear();
});
const setJahr = useCallback((j: number) => {
localStorage.setItem(SCHLUESSEL, String(j));
setJahrIntern(j);
}, []);
const aktuell = new Date().getFullYear();
const auswahl = Array.from({ length: 7 }, (_, i) => aktuell + 1 - i);
return [jahr, setJahr, auswahl];
}
+41
View File
@@ -0,0 +1,41 @@
const SCHLUESSEL = 'euer.mandant';
export interface Mandant {
slug: string;
name: string;
testmandant: boolean;
angelegt_am?: string;
}
/**
* Der aktive Mandant steht im localStorage und wird bei jedem API-Aufruf als
* Header mitgeschickt. Ist nichts gesetzt, entscheidet der Server (erster
* echter Mandant) so funktioniert die Anwendung auch beim ersten Start.
*/
export function aktiverMandant(): string | null {
return localStorage.getItem(SCHLUESSEL);
}
/**
* Wechselt den Mandanten und lädt die Anwendung neu. Der harte Neuaufbau ist
* hier das Richtige: sonst blieben Zwischenstände des vorherigen Mandanten in
* den Formularen stehen und man bucht am Ende im falschen Unternehmen.
*/
export function wechsleMandant(slug: string): void {
localStorage.setItem(SCHLUESSEL, slug);
window.location.assign('/');
}
export function vergissMandant(): void {
localStorage.removeItem(SCHLUESSEL);
}
/**
* Download-Links sind einfache <a href>-Elemente und können keine Header
* setzen deshalb wird der Mandant dort als Query-Parameter angehängt.
*/
export function mitMandant(url: string): string {
const slug = aktiverMandant();
if (!slug) return url;
return url + (url.includes('?') ? '&' : '?') + `mandant=${encodeURIComponent(slug)}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+185
View File
@@ -0,0 +1,185 @@
import { useState } from 'react';
import { api, useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { centAus, datum, eur } from '../lib/format';
interface AnlageMitAfa {
id: number;
bezeichnung: string;
anschaffungsdatum: string;
akosten_netto: number;
akosten_brutto: number;
nutzungsdauer: number;
afa_methode: string;
abgang_datum: string | null;
notiz: string;
afa: {
bemessung: number;
buchwertAnfang: number;
afa: number;
buchwertEnde: number;
restbuchwertAbgang: number;
hinweis: string;
};
}
const METHODEN: Record<string, string> = {
linear: 'linear (§7 Abs. 1 EStG)',
gwg_sofort: 'GWG-Sofortabschreibung',
sammelposten: 'Sammelposten über 5 Jahre',
keine: 'nicht abnutzbar',
};
export default function Anlagen() {
const [jahr, setJahr, jahre] = useJahr();
const { daten, neuLaden } = useDaten<AnlageMitAfa[]>(`/api/anlagen?jahr=${jahr}`);
const [form, setForm] = useState({
bezeichnung: '',
anschaffungsdatum: new Date().toISOString().slice(0, 10),
nettoFeld: '',
bruttoFeld: '',
nutzungsdauer: '3',
afa_methode: '',
});
const [meldung, setMeldung] = useState<string | null>(null);
async function anlegen(e: React.FormEvent) {
e.preventDefault();
try {
const netto = centAus(form.nettoFeld);
await api.post('/api/anlagen', {
bezeichnung: form.bezeichnung,
anschaffungsdatum: form.anschaffungsdatum,
akosten_netto: netto,
akosten_brutto: form.bruttoFeld ? centAus(form.bruttoFeld) : netto,
nutzungsdauer: Number(form.nutzungsdauer) || 3,
afa_methode: form.afa_methode || undefined,
});
setForm({ ...form, bezeichnung: '', nettoFeld: '', bruttoFeld: '' });
setMeldung('Anlagegut angelegt.');
neuLaden();
} catch (err) {
setMeldung((err as Error).message);
}
}
async function abgangEintragen(a: AnlageMitAfa) {
const d = prompt(`Abgangsdatum für "${a.bezeichnung}" (JJJJ-MM-TT):`, `${jahr}-12-31`);
if (!d) return;
await api.put(`/api/anlagen/${a.id}`, { abgang_datum: d });
neuLaden();
}
const summeAfa = (daten ?? []).reduce((s, a) => s + a.afa.afa, 0);
const summeBuchwert = (daten ?? []).reduce((s, a) => s + a.afa.buchwertEnde, 0);
return (
<>
<h1>Anlagevermögen</h1>
<p className="untertitel">
Wirtschaftsgüter mit mehrjähriger Nutzung sind nicht sofort abziehbar, sondern über die
Nutzungsdauer abzuschreiben. Die AfA fließt automatisch in EÜR und BWA ein.
</p>
<div className="filterreihe">
<div className="feld">
<label>Jahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
</div>
<div className="kachelreihe">
<div className="kachel">
<div className="beschriftung">Abschreibung {jahr}</div>
<div className="wert zahl">{eur(summeAfa)}</div>
</div>
<div className="kachel">
<div className="beschriftung">Buchwert 31.12.{jahr}</div>
<div className="wert zahl">{eur(summeBuchwert)}</div>
</div>
</div>
<div className="karte">
<h2>Anlagegut erfassen</h2>
<form onSubmit={anlegen}>
<div className="formularraster">
<div className="feld" style={{ gridColumn: 'span 2' }}>
<label>Bezeichnung</label>
<input required value={form.bezeichnung} onChange={(e) => setForm({ ...form, bezeichnung: e.target.value })} />
</div>
<div className="feld">
<label>Anschaffungsdatum</label>
<input type="date" required value={form.anschaffungsdatum} onChange={(e) => setForm({ ...form, anschaffungsdatum: e.target.value })} />
</div>
<div className="feld">
<label>Anschaffungskosten netto</label>
<input required inputMode="decimal" placeholder="0,00" value={form.nettoFeld} onChange={(e) => setForm({ ...form, nettoFeld: e.target.value })} />
</div>
<div className="feld">
<label> brutto</label>
<input inputMode="decimal" placeholder="leer = wie netto" value={form.bruttoFeld} onChange={(e) => setForm({ ...form, bruttoFeld: e.target.value })} />
<span className="hilfe">für Kleinunternehmer maßgeblich</span>
</div>
<div className="feld">
<label>Nutzungsdauer (Jahre)</label>
<input inputMode="numeric" value={form.nutzungsdauer} onChange={(e) => setForm({ ...form, nutzungsdauer: e.target.value })} />
</div>
<div className="feld">
<label>AfA-Methode</label>
<select value={form.afa_methode} onChange={(e) => setForm({ ...form, afa_methode: e.target.value })}>
<option value="">automatisch nach Betrag</option>
{Object.entries(METHODEN).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="knopfreihe">
<button type="submit" className="primaer">Anlegen</button>
</div>
</form>
{meldung && <div className="hinweis" style={{ marginTop: 12 }}>{meldung}</div>}
</div>
<div className="karte">
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Wirtschaftsgut</th><th>Anschaffung</th><th>Methode</th>
<th className="rechts">Bemessung</th><th className="rechts">Buchwert 1.1.</th>
<th className="rechts">AfA {jahr}</th><th className="rechts">Buchwert 31.12.</th><th></th>
</tr>
</thead>
<tbody>
{(daten ?? []).map((a) => (
<tr key={a.id}>
<td>
{a.bezeichnung}
{a.abgang_datum && <div className="abzeichen">Abgang {datum(a.abgang_datum)}</div>}
</td>
<td>{datum(a.anschaffungsdatum)}</td>
<td className="gedimmt" style={{ fontSize: 12 }}>{METHODEN[a.afa_methode] ?? a.afa_methode}</td>
<td className="rechts zahl">{eur(a.afa.bemessung)}</td>
<td className="rechts zahl">{eur(a.afa.buchwertAnfang)}</td>
<td className="rechts zahl">{eur(a.afa.afa)}</td>
<td className="rechts zahl">{eur(a.afa.buchwertEnde)}</td>
<td className="rechts">
{!a.abgang_datum && (
<button type="button" style={{ padding: '3px 8px', fontSize: 12 }} onClick={() => abgangEintragen(a)}>
Abgang
</button>
)}
</td>
</tr>
))}
{daten?.length === 0 && (
<tr><td colSpan={8} className="gedimmt">Noch kein Anlagevermögen erfasst.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+483
View File
@@ -0,0 +1,483 @@
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';
interface Konto {
nummer: string;
bezeichnung: string;
typ: string;
ust_satz_bp: number;
ust_behandlung: string;
}
interface Buchung {
id: number;
art: 'einnahme' | 'ausgabe';
belegdatum: string;
leistungsdatum: string;
zahlungsdatum: string | null;
betrag_brutto: number;
betrag_netto: number;
betrag_ust: number;
ust_satz_bp: number;
ust_behandlung: string;
vorsteuer_abziehbar: number;
konto: string;
beleg_nr: string;
buchungstext: string;
partner: string;
storniert: number;
festgeschrieben: number;
anlagegut_id: number | null;
}
interface Aktivierung {
/** true = das Konto erzwingt die Aktivierung, das Häkchen ist nicht abwählbar */
erforderlich: boolean;
methode: 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine';
bemessung: number;
nutzungsdauerNoetig: boolean;
}
const METHODE_TEXT: Record<string, string> = {
gwg_sofort: 'geringwertiges Wirtschaftsgut im Anschaffungsjahr voll abgesetzt (§6 Abs. 2 EStG)',
sammelposten: 'Sammelposten über fünf Jahre aufgelöst (§6 Abs. 2a EStG)',
linear: 'linear über die Nutzungsdauer abzuschreiben (§7 Abs. 1 EStG)',
keine: 'nicht abnutzbar keine Abschreibung',
};
interface Vorschau {
regime: string;
aktivierung: Aktivierung | null;
brutto: number;
netto: number;
ust: number;
ust_satz_bp: number;
ust_behandlung: string;
vorsteuer_abziehbar: boolean;
hinweise: string[];
}
const heute = () => new Date().toISOString().slice(0, 10);
const LEER = {
art: 'ausgabe' as 'einnahme' | 'ausgabe',
belegdatum: heute(),
leistungsdatum: '',
zahlungsdatum: heute(),
betragFeld: '',
konto: '',
gegenkonto: '',
beleg_nr: '',
buchungstext: '',
partner: '',
privatanteilFeld: '0',
alsAnlage: false,
anlageBezeichnung: '',
anlageNutzungsdauer: '3',
};
export default function Buchungen() {
const [jahr, setJahr, jahre] = useJahr();
const [filter, setFilter] = useState({ art: '', suche: '', nurOffen: false });
const [form, setForm] = useState(LEER);
const [vorschau, setVorschau] = useState<Vorschau | null>(null);
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
const { daten: konten } = useDaten<Konto[]>('/api/konten');
// 1800 ist NUR im SKR04 die Bank im SKR03 sind das Privatentnahmen.
const { daten: mandant } = useDaten<{ standardGegenkonto: string }>('/api/mandant');
const bankkonto = mandant?.standardGegenkonto ?? '';
const abfrage = useMemo(() => {
const p = new URLSearchParams({ jahr: String(jahr) });
if (filter.art) p.set('art', filter.art);
if (filter.suche) p.set('suche', filter.suche);
if (filter.nurOffen) p.set('nurOffen', '1');
return `/api/buchungen?${p}`;
}, [jahr, filter]);
const { daten: buchungen, neuLaden } = useDaten<Buchung[]>(abfrage);
const passendeKonten = (konten ?? []).filter((k) =>
form.art === 'einnahme' ? k.typ === 'erloes' : k.typ === 'aufwand' || k.typ === 'anlage',
);
/*
* Bei einem Anlagekonto ist die Aktivierung nicht verhandelbar der Server
* lehnt die Buchung sonst ab. Das Häkchen wird deshalb gesetzt und gesperrt,
* statt den Anwender in eine Fehlermeldung laufen zu lassen.
*/
const erzwungeneAnlage =
form.art === 'ausgabe' &&
(konten ?? []).find((k) => k.nummer === form.konto)?.typ === 'anlage';
// Live-Vorschau: das Backend entscheidet über die USt-Behandlung, nicht das Formular.
useEffect(() => {
const brutto = centAus(form.betragFeld);
if (!form.konto || brutto === 0) {
setVorschau(null);
return;
}
const id = setTimeout(() => {
api
.post<Vorschau>('/api/buchungen/vorschau', {
art: form.art,
belegdatum: form.belegdatum,
leistungsdatum: form.leistungsdatum || form.belegdatum,
brutto,
konto: form.konto,
})
.then(setVorschau)
.catch(() => setVorschau(null));
}, 200);
return () => clearTimeout(id);
}, [form.art, form.konto, form.betragFeld, form.belegdatum, form.leistungsdatum]);
async function speichern(e: React.FormEvent) {
e.preventDefault();
setMeldung(null);
try {
const antwort = await api.post<Buchung & { hinweise: string[] }>('/api/buchungen', {
art: form.art,
belegdatum: form.belegdatum,
leistungsdatum: form.leistungsdatum || form.belegdatum,
zahlungsdatum: form.zahlungsdatum || null,
brutto: centAus(form.betragFeld),
konto: form.konto,
gegenkonto: form.gegenkonto || bankkonto,
beleg_nr: form.beleg_nr,
buchungstext: form.buchungstext,
partner: form.partner,
privatanteil_bp: Math.round(Number(form.privatanteilFeld.replace(',', '.')) * 100) || 0,
anlage: form.alsAnlage || erzwungeneAnlage
? {
bezeichnung: form.anlageBezeichnung || form.buchungstext,
nutzungsdauer: Number(form.anlageNutzungsdauer) || 3,
}
: undefined,
});
setMeldung({
art: 'gut',
text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`,
});
setForm({ ...LEER, art: form.art, konto: form.konto, gegenkonto: form.gegenkonto });
neuLaden();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
}
async function stornieren(id: number) {
if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return;
try {
await api.post(`/api/buchungen/${id}/storno`);
neuLaden();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
}
async function zahlungNachtragen(b: Buchung) {
const eingabe = prompt('Zahlungsdatum (JJJJ-MM-TT):', heute());
if (!eingabe) return;
await api.post(`/api/buchungen/${b.id}/zahlung`, { zahlungsdatum: eingabe });
neuLaden();
}
return (
<>
<h1>Buchungen</h1>
<p className="untertitel">
Maßgeblich für die EÜR ist das Zahlungsdatum. Ohne Zahlungsdatum gilt die Buchung als
offener Posten und fließt noch nicht in den Gewinn ein.
</p>
<div className="karte">
<h2>Neue Buchung</h2>
<form onSubmit={speichern}>
<div className="formularraster">
<div className="feld">
<label>Art</label>
<select
value={form.art}
onChange={(e) =>
setForm({ ...form, art: e.target.value as 'einnahme' | 'ausgabe', konto: '' })
}
>
<option value="ausgabe">Betriebsausgabe</option>
<option value="einnahme">Betriebseinnahme</option>
</select>
</div>
<div className="feld">
<label>Belegdatum</label>
<input
type="date"
required
value={form.belegdatum}
onChange={(e) => setForm({ ...form, belegdatum: e.target.value })}
/>
</div>
<div className="feld">
<label>Leistungsdatum</label>
<input
type="date"
value={form.leistungsdatum}
onChange={(e) => setForm({ ...form, leistungsdatum: e.target.value })}
/>
<span className="hilfe">leer = Belegdatum; bestimmt das Steuerregime</span>
</div>
<div className="feld">
<label>Zahlungsdatum</label>
<input
type="date"
value={form.zahlungsdatum}
onChange={(e) => setForm({ ...form, zahlungsdatum: e.target.value })}
/>
<span className="hilfe">leer = noch offen</span>
</div>
<div className="feld">
<label>Bruttobetrag</label>
<input
required
inputMode="decimal"
placeholder="0,00"
value={form.betragFeld}
onChange={(e) => setForm({ ...form, betragFeld: e.target.value })}
/>
</div>
<div className="feld" style={{ gridColumn: 'span 2' }}>
<label>Konto</label>
<select
required
value={form.konto}
onChange={(e) => setForm({ ...form, konto: e.target.value })}
>
<option value=""> bitte wählen </option>
{passendeKonten.map((k) => (
<option key={k.nummer} value={k.nummer}>
{k.nummer} · {k.bezeichnung}
</option>
))}
</select>
</div>
<div className="feld">
<label>Gegenkonto</label>
<select
value={form.gegenkonto || bankkonto}
onChange={(e) => setForm({ ...form, gegenkonto: e.target.value })}
>
{(konten ?? [])
.filter((k) => k.typ === 'bestand' || k.typ === 'privat')
.map((k) => (
<option key={k.nummer} value={k.nummer}>
{k.nummer} · {k.bezeichnung}
</option>
))}
</select>
</div>
<div className="feld">
<label>Belegnummer</label>
<input
value={form.beleg_nr}
onChange={(e) => setForm({ ...form, beleg_nr: e.target.value })}
/>
</div>
<div className="feld">
<label>Geschäftspartner</label>
<input
value={form.partner}
onChange={(e) => setForm({ ...form, partner: e.target.value })}
/>
</div>
<div className="feld" style={{ gridColumn: 'span 2' }}>
<label>Buchungstext</label>
<input
value={form.buchungstext}
onChange={(e) => setForm({ ...form, buchungstext: e.target.value })}
/>
</div>
<div className="feld">
<label>Privatanteil in %</label>
<input
inputMode="decimal"
value={form.privatanteilFeld}
onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })}
/>
</div>
</div>
{form.art === 'ausgabe' && (
<div style={{ marginTop: 12 }}>
<label style={{ fontSize: 13, display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={form.alsAnlage || erzwungeneAnlage}
disabled={erzwungeneAnlage}
onChange={(e) => setForm({ ...form, alsAnlage: e.target.checked })}
/>
Als Anlagegut erfassen (Abschreibung statt Sofortabzug)
{erzwungeneAnlage && (
<span className="abzeichen aktiv">durch das gewählte Anlagekonto vorgegeben</span>
)}
</label>
{vorschau?.aktivierung && (
<div className="hinweis" style={{ marginTop: 10 }}>
Wird als Anlagegut geführt:{' '}
{METHODE_TEXT[vorschau.aktivierung.methode] ?? vorschau.aktivierung.methode}.
{vorschau.aktivierung.nutzungsdauerNoetig &&
' Die Nutzungsdauer ist deshalb Pflicht.'}
</div>
)}
{(form.alsAnlage || erzwungeneAnlage) && (
<div className="formularraster" style={{ marginTop: 10 }}>
<div className="feld">
<label>Bezeichnung</label>
<input
value={form.anlageBezeichnung}
onChange={(e) => setForm({ ...form, anlageBezeichnung: e.target.value })}
/>
</div>
<div className="feld">
<label>Nutzungsdauer in Jahren</label>
<input
inputMode="numeric"
value={form.anlageNutzungsdauer}
onChange={(e) => setForm({ ...form, anlageNutzungsdauer: e.target.value })}
/>
<span className="hilfe">
bis 800 netto automatisch GWG, bis 1.000 Sammelposten
</span>
</div>
</div>
)}
</div>
)}
{vorschau && (
<div className="hinweis" style={{ marginTop: 14 }}>
<strong>Vorschau · {REGIME_LABEL[vorschau.regime]}</strong>
<div style={{ marginTop: 4 }}>
Netto <span className="zahl">{eur(vorschau.netto)}</span> · Umsatzsteuer{' '}
<span className="zahl">{eur(vorschau.ust)}</span> · Brutto{' '}
<span className="zahl">{eur(vorschau.brutto)}</span> ·{' '}
{BEHANDLUNG_LABEL[vorschau.ust_behandlung]}
{form.art === 'ausgabe' &&
(vorschau.vorsteuer_abziehbar ? ' · Vorsteuer abziehbar' : ' · kein Vorsteuerabzug')}
</div>
{vorschau.hinweise.map((h) => (
<div key={h} style={{ marginTop: 6 }}>{h}</div>
))}
</div>
)}
<div className="knopfreihe">
<button type="submit" className="primaer">Buchung speichern</button>
<button type="button" onClick={() => setForm(LEER)}>Zurücksetzen</button>
</div>
</form>
{meldung && <div className={`hinweis ${meldung.art}`} style={{ marginTop: 12 }}>{meldung.text}</div>}
</div>
<div className="filterreihe">
<div className="feld">
<label>Jahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
<div className="feld">
<label>Art</label>
<select value={filter.art} onChange={(e) => setFilter({ ...filter, art: e.target.value })}>
<option value="">alle</option>
<option value="einnahme">Einnahmen</option>
<option value="ausgabe">Ausgaben</option>
</select>
</div>
<div className="feld" style={{ minWidth: 220 }}>
<label>Suche</label>
<input
value={filter.suche}
placeholder="Text, Partner oder Belegnummer"
onChange={(e) => setFilter({ ...filter, suche: e.target.value })}
/>
</div>
<div className="feld" style={{ minWidth: 'auto' }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={filter.nurOffen}
onChange={(e) => setFilter({ ...filter, nurOffen: e.target.checked })}
/>
nur offene Posten
</label>
</div>
</div>
<div className="karte">
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Beleg</th>
<th>Text</th>
<th>Konto</th>
<th>Behandlung</th>
<th className="rechts">Netto</th>
<th className="rechts">USt</th>
<th className="rechts">Brutto</th>
<th></th>
</tr>
</thead>
<tbody>
{(buchungen ?? []).map((b) => (
<tr key={b.id} style={b.storniert ? { opacity: 0.5, textDecoration: 'line-through' } : undefined}>
<td>
{datum(b.zahlungsdatum ?? b.leistungsdatum)}
{!b.zahlungsdatum && <div className="abzeichen" style={{ marginTop: 3 }}>offen</div>}
</td>
<td className="gedimmt">{b.beleg_nr}</td>
<td>
{b.buchungstext}
{b.partner && <div className="gedimmt" style={{ fontSize: 12 }}>{b.partner}</div>}
{b.anlagegut_id && <div className="abzeichen">Anlagegut</div>}
</td>
<td className="zahl">{b.konto}</td>
<td style={{ fontSize: 12 }}>{BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung}</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' : ''}`}>
{b.art === 'einnahme' ? '+' : ''}
{eur(b.betrag_brutto)}
</td>
<td className="rechts">
{!b.storniert && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
{!b.zahlungsdatum && (
<button type="button" onClick={() => zahlungNachtragen(b)} style={{ padding: '3px 8px', fontSize: 12 }}>
bezahlt
</button>
)}
<button type="button" className="gefahr" onClick={() => stornieren(b.id)} style={{ padding: '3px 8px', fontSize: 12 }}>
Storno
</button>
</div>
)}
</td>
</tr>
))}
{buchungen?.length === 0 && (
<tr>
<td colSpan={9} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { mitMandant } from '../lib/mandant';
import { eur, MONATSNAMEN, proz } from '../lib/format';
interface BwaZeile {
id: string;
bezeichnung: string;
art: string;
zwischensumme: boolean;
monat: number;
kumuliert: number;
vorjahrKumuliert: number;
quoteKumuliert: number;
}
interface BwaDaten {
jahr: number;
monat: number;
basis: string;
zeilen: BwaZeile[];
hinweise: string[];
}
export default function Bwa() {
const [jahr, setJahr, jahre] = useJahr();
const [monat, setMonat] = useState(() => new Date().getMonth() + 1);
const [basis, setBasis] = useState<'zahlung' | 'leistung'>('zahlung');
const { daten, fehler, laedt } = useDaten<BwaDaten>(`/api/bwa/${jahr}/${monat}?basis=${basis}`);
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
if (laedt || !daten) return <p className="gedimmt">Wird geladen </p>;
const ergebnis = daten.zeilen.find((z) => z.id === 'vorlaeufiges_ergebnis');
const betriebsergebnis = daten.zeilen.find((z) => z.id === 'betriebsergebnis');
const gesamtleistung = daten.zeilen.find((z) => z.id === 'gesamtleistung');
const veraenderung = (z: BwaZeile) =>
z.vorjahrKumuliert === 0 ? null : ((z.kumuliert - z.vorjahrKumuliert) / Math.abs(z.vorjahrKumuliert)) * 100;
return (
<>
<h1>Betriebswirtschaftliche Auswertung</h1>
<p className="untertitel">
Kurzfristige Erfolgsrechnung im Aufbau der BWA Form 01 für den Monat und kumuliert seit
Jahresbeginn, mit Vergleich zum Vorjahreszeitraum.
</p>
<div className="filterreihe">
<div className="feld">
<label>Jahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
<div className="feld">
<label>Monat</label>
<select value={monat} onChange={(e) => setMonat(Number(e.target.value))}>
{MONATSNAMEN.map((m, i) => <option key={m} value={i + 1}>{m}</option>)}
</select>
</div>
<div className="feld" style={{ minWidth: 200 }}>
<label>Zeitliche Zuordnung</label>
<select value={basis} onChange={(e) => setBasis(e.target.value as 'zahlung' | 'leistung')}>
<option value="zahlung">Zahlungszeitpunkt (wie EÜR)</option>
<option value="leistung">Leistungszeitpunkt (periodengerecht)</option>
</select>
</div>
<a className="knopf" href={mitMandant(`/api/export/bwa/${jahr}/${monat}.csv?basis=${basis}`)}>
Als CSV herunterladen
</a>
</div>
<div className="kachelreihe">
<div className="kachel">
<div className="beschriftung">Gesamtleistung kumuliert</div>
<div className="wert zahl">{eur(gesamtleistung?.kumuliert ?? 0)}</div>
</div>
<div className="kachel">
<div className="beschriftung">Betriebsergebnis kumuliert</div>
<div className={`wert zahl ${(betriebsergebnis?.kumuliert ?? 0) < 0 ? 'kritisch' : 'gut'}`}>
{eur(betriebsergebnis?.kumuliert ?? 0)}
</div>
</div>
<div className="kachel">
<div className="beschriftung">Vorläufiges Ergebnis</div>
<div className={`wert zahl ${(ergebnis?.kumuliert ?? 0) < 0 ? 'kritisch' : 'gut'}`}>
{eur(ergebnis?.kumuliert ?? 0)}
</div>
<div className="zusatz">
Vorjahr: {eur(ergebnis?.vorjahrKumuliert ?? 0)}
</div>
</div>
</div>
<div className="karte">
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Zeile</th>
<th className="rechts">{MONATSNAMEN[monat - 1]}</th>
<th className="rechts">Kumuliert</th>
<th className="rechts">% der Gesamtleistung</th>
<th className="rechts">Vorjahr kumuliert</th>
<th className="rechts">Veränderung</th>
</tr>
</thead>
<tbody>
{daten.zeilen.map((z) => {
const v = veraenderung(z);
const leer = z.monat === 0 && z.kumuliert === 0 && z.vorjahrKumuliert === 0;
if (leer && !z.zwischensumme) return null;
return (
<tr key={z.id} className={z.id === 'vorlaeufiges_ergebnis' ? 'ergebnis' : z.zwischensumme ? 'summe' : ''}>
<td>{z.bezeichnung}</td>
<td className="rechts zahl">{eur(z.monat)}</td>
<td className="rechts zahl">{eur(z.kumuliert)}</td>
<td className="rechts zahl gedimmt">
{gesamtleistung?.kumuliert ? proz(z.quoteKumuliert) : ''}
</td>
<td className="rechts zahl gedimmt">{eur(z.vorjahrKumuliert)}</td>
<td className={`rechts zahl ${v === null ? 'gedimmt' : v >= 0 ? 'gut' : 'kritisch'}`}>
{v === null ? '' : proz(v, 1, true)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { Link } from 'react-router-dom';
import { useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { eur, proz, REGIME_LABEL, ZEITRAUM_LABEL } from '../lib/format';
import MonatsDiagramm, { type MonatsWert } from '../components/MonatsDiagramm';
interface DashboardDaten {
jahr: number;
regime: string;
besteuerungsart: string;
voranmeldungszeitraum: string;
einnahmen: number;
ausgaben: number;
gewinn: number;
verlauf: MonatsWert[];
offen: { art: string; anzahl: number; summe: number }[];
grenzen: {
laufenderUmsatz: number;
grenzeVorjahr: number;
grenzeLaufend: number;
warnungen: string[];
};
hinweise: string[];
}
export default function Dashboard() {
const [jahr, setJahr, jahre] = useJahr();
const { daten, fehler, laedt } = useDaten<DashboardDaten>(`/api/dashboard/${jahr}`);
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
if (laedt || !daten) return <p className="gedimmt">Wird geladen </p>;
const offenEin = daten.offen.find((o) => o.art === 'einnahme');
const offenAus = daten.offen.find((o) => o.art === 'ausgabe');
const auslastung = Math.min(100, (daten.grenzen.laufenderUmsatz / daten.grenzen.grenzeLaufend) * 100);
return (
<>
<h1>Übersicht {jahr}</h1>
<p className="untertitel">
{REGIME_LABEL[daten.regime]} · {daten.besteuerungsart === 'ist' ? 'Ist-Versteuerung' : 'Soll-Versteuerung'} ·
Voranmeldung {ZEITRAUM_LABEL[daten.voranmeldungszeitraum] ?? daten.voranmeldungszeitraum}
</p>
<div className="filterreihe">
<div className="feld">
<label htmlFor="jahr">Wirtschaftsjahr</label>
<select id="jahr" value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
</div>
{daten.grenzen.warnungen.map((w) => (
<div key={w} className="hinweis warnung">{w}</div>
))}
<div className="kachelreihe">
<div className="kachel">
<div className="beschriftung">Betriebseinnahmen</div>
<div className="wert zahl">{eur(daten.einnahmen)}</div>
<div className="zusatz">nach Zuflussprinzip (§11 EStG)</div>
</div>
<div className="kachel">
<div className="beschriftung">Betriebsausgaben</div>
<div className="wert zahl">{eur(daten.ausgaben)}</div>
<div className="zusatz">einschließlich Abschreibungen</div>
</div>
<div className="kachel">
<div className="beschriftung">Gewinn</div>
<div className={`wert zahl ${daten.gewinn < 0 ? 'kritisch' : 'gut'}`}>{eur(daten.gewinn)}</div>
<div className="zusatz">vorläufig, vor Steuern</div>
</div>
<div className="kachel">
<div className="beschriftung">Offene Posten</div>
<div className="wert zahl">{eur((offenEin?.summe ?? 0) - (offenAus?.summe ?? 0))}</div>
<div className="zusatz">
{offenEin?.anzahl ?? 0} Forderungen · {offenAus?.anzahl ?? 0} Verbindlichkeiten
</div>
</div>
</div>
<div className="karte">
<h2>Verlauf über das Jahr</h2>
<MonatsDiagramm werte={daten.verlauf} />
<details style={{ marginTop: 10 }}>
<summary className="gedimmt" style={{ cursor: 'pointer', fontSize: 13 }}>
Werte als Tabelle
</summary>
<div className="tabellenrahmen" style={{ marginTop: 8 }}>
<table>
<thead>
<tr>
<th>Monat</th>
<th className="rechts">Einnahmen</th>
<th className="rechts">Ausgaben</th>
<th className="rechts">Saldo</th>
</tr>
</thead>
<tbody>
{daten.verlauf.map((m) => (
<tr key={m.monat}>
<td>{m.monat}</td>
<td className="rechts zahl">{eur(m.einnahmen)}</td>
<td className="rechts zahl">{eur(m.ausgaben)}</td>
<td className="rechts zahl">{eur(m.einnahmen - m.ausgaben)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
{daten.regime === 'kleinunternehmer' && (
<div className="karte">
<h2>Umsatzgrenzen nach §19 UStG</h2>
<p className="untertitel" style={{ marginBottom: 10 }}>
Umsatz {jahr}: <span className="zahl">{eur(daten.grenzen.laufenderUmsatz)}</span> von{' '}
<span className="zahl">{eur(daten.grenzen.grenzeLaufend)}</span> ({proz(auslastung, 0)})
</p>
<div
style={{
height: 8,
borderRadius: 4,
background: 'var(--surface-2)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${auslastung}%`,
height: '100%',
background: auslastung > 80 ? 'var(--warnung)' : 'var(--series-1)',
}}
/>
</div>
<p className="gedimmt" style={{ fontSize: 13, marginTop: 10 }}>
Wird die Grenze überschritten, endet die Kleinunternehmerregelung sofort mit dem
überschreitenden Umsatz. Den Wechsel unter{' '}
<Link to="/stammdaten">Stammdaten &amp; Regime</Link> eintragen.
</p>
</div>
)}
{daten.hinweise.map((h) => (
<div key={h} className="hinweis">{h}</div>
))}
</>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { mitMandant } from '../lib/mandant';
import { datum, eur, REGIME_LABEL } from '../lib/format';
interface Zeile {
key: string;
zeile: number | null;
bezeichnung: string;
betrag: number;
anzahl: number;
hinweis?: string;
}
interface EuerDaten {
jahr: number;
regimeStart: string;
regimeEnde: string;
wechselImJahr: { gueltig_ab: string; modus: string; grund: string }[];
einnahmen: Zeile[];
ausgaben: Zeile[];
summeEinnahmen: number;
summeAusgaben: number;
gewinn: number;
offenePosten: { einnahmen: number; ausgaben: number };
fehlbuchungen: { id: number; datum: string; konto: string; text: string; betrag: number }[];
hinweise: string[];
}
interface AfaZeile {
anlagegut: { id: number; bezeichnung: string; anschaffungsdatum: string; nutzungsdauer: number };
bemessung: number;
buchwertAnfang: number;
afa: number;
buchwertEnde: number;
restbuchwertAbgang: number;
hinweis: string;
}
export default function Euer() {
const [jahr, setJahr, jahre] = useJahr();
const { daten, fehler, laedt } = useDaten<EuerDaten>(`/api/euer/${jahr}`);
const { daten: afa } = useDaten<AfaZeile[]>(`/api/euer/${jahr}/anlagen`);
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
if (laedt || !daten) return <p className="gedimmt">Wird geladen </p>;
return (
<>
<h1>Einnahmen-Überschuss-Rechnung {jahr}</h1>
<p className="untertitel">
Gewinnermittlung nach §4 Abs. 3 EStG. Die Zeilennummern beziehen sich auf die Anlage EÜR
und sind vor der Abgabe gegen das amtliche Formular des Jahres zu prüfen.
</p>
<div className="filterreihe">
<div className="feld">
<label>Wirtschaftsjahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
<a className="knopf" href={mitMandant(`/api/export/euer/${jahr}.csv`)}>Als CSV herunterladen</a>
</div>
<div className="kachelreihe">
<div className="kachel">
<div className="beschriftung">Summe Betriebseinnahmen</div>
<div className="wert zahl">{eur(daten.summeEinnahmen)}</div>
</div>
<div className="kachel">
<div className="beschriftung">Summe Betriebsausgaben</div>
<div className="wert zahl">{eur(daten.summeAusgaben)}</div>
</div>
<div className="kachel">
<div className="beschriftung">{daten.gewinn < 0 ? 'Verlust' : 'Gewinn'}</div>
<div className={`wert zahl ${daten.gewinn < 0 ? 'kritisch' : 'gut'}`}>{eur(daten.gewinn)}</div>
</div>
<div className="kachel">
<div className="beschriftung">Besteuerungsform</div>
<div className="wert" style={{ fontSize: 15 }}>
{daten.regimeStart === daten.regimeEnde
? REGIME_LABEL[daten.regimeStart]
: `${REGIME_LABEL[daten.regimeStart]}${REGIME_LABEL[daten.regimeEnde]}`}
</div>
</div>
</div>
{daten.wechselImJahr.map((w) => (
<div key={w.gueltig_ab} className="hinweis warnung">
Wechsel zum {datum(w.gueltig_ab)}: {REGIME_LABEL[w.modus]}
{w.grund && ` (${w.grund})`}. Umsätze vor und nach diesem Datum werden getrennt ausgewiesen.
</div>
))}
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
{daten.fehlbuchungen?.length > 0 && (
<div className="karte" style={{ borderColor: 'var(--warnung)' }}>
<h2 style={{ color: 'var(--warnung)' }}>Zu prüfen: Anschaffungen ohne Abschreibung</h2>
<p className="untertitel" style={{ marginBottom: 10 }}>
Diese Buchungen liegen auf einem Anlagekonto, ohne dass ein Anlagegut hinterlegt ist.
Sie sind oben in voller Höhe als Betriebsausgabe enthalten, statt über die Nutzungsdauer
verteilt zu werden. Bitte stornieren und mit Nutzungsdauer neu erfassen.
</p>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Buchungstext</th><th className="rechts">Betrag</th>
</tr>
</thead>
<tbody>
{daten.fehlbuchungen.map((f) => (
<tr key={f.id}>
<td>{datum(f.datum)}</td>
<td className="zahl">{f.konto}</td>
<td>{f.text || <span className="gedimmt">ohne Text</span>}</td>
<td className="rechts zahl">{eur(f.betrag)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{(daten.offenePosten.einnahmen > 0 || daten.offenePosten.ausgaben > 0) && (
<div className="hinweis">
Nicht enthalten, weil noch nicht bezahlt: Forderungen{' '}
<span className="zahl">{eur(daten.offenePosten.einnahmen)}</span>, Verbindlichkeiten{' '}
<span className="zahl">{eur(daten.offenePosten.ausgaben)}</span>.
</div>
)}
<div className="karte">
<h2>Betriebseinnahmen</h2>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th style={{ width: 60 }}>Zeile</th>
<th>Posten</th>
<th className="rechts" style={{ width: 90 }}>Belege</th>
<th className="rechts" style={{ width: 140 }}>Betrag</th>
</tr>
</thead>
<tbody>
{daten.einnahmen.map((z) => (
<tr key={z.key}>
<td className="zahl gedimmt">{z.zeile ?? ''}</td>
<td>{z.bezeichnung}</td>
<td className="rechts gedimmt">{z.anzahl || ''}</td>
<td className="rechts zahl">{eur(z.betrag)}</td>
</tr>
))}
<tr className="summe">
<td></td>
<td>Summe der Betriebseinnahmen</td>
<td></td>
<td className="rechts zahl">{eur(daten.summeEinnahmen)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="karte">
<h2>Betriebsausgaben</h2>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th style={{ width: 60 }}>Zeile</th>
<th>Posten</th>
<th className="rechts" style={{ width: 90 }}>Belege</th>
<th className="rechts" style={{ width: 140 }}>Betrag</th>
</tr>
</thead>
<tbody>
{daten.ausgaben.map((z) => (
<tr key={z.key}>
<td className="zahl gedimmt">{z.zeile ?? ''}</td>
<td>
{z.bezeichnung}
{z.hinweis && <div className="gedimmt" style={{ fontSize: 12 }}>{z.hinweis}</div>}
</td>
<td className="rechts gedimmt">{z.anzahl || ''}</td>
<td className="rechts zahl">{eur(z.betrag)}</td>
</tr>
))}
<tr className="summe">
<td></td>
<td>Summe der Betriebsausgaben</td>
<td></td>
<td className="rechts zahl">{eur(daten.summeAusgaben)}</td>
</tr>
<tr className="ergebnis">
<td></td>
<td>{daten.gewinn < 0 ? 'Verlust' : 'Gewinn'} {jahr}</td>
<td></td>
<td className="rechts zahl">{eur(daten.gewinn)}</td>
</tr>
</tbody>
</table>
</div>
</div>
{afa && afa.length > 0 && (
<div className="karte">
<h2>Anlagenverzeichnis {jahr}</h2>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Wirtschaftsgut</th>
<th>Anschaffung</th>
<th className="rechts">Bemessung</th>
<th className="rechts">Buchwert 1.1.</th>
<th className="rechts">AfA</th>
<th className="rechts">Buchwert 31.12.</th>
<th>Methode</th>
</tr>
</thead>
<tbody>
{afa.map((z) => (
<tr key={z.anlagegut.id}>
<td>{z.anlagegut.bezeichnung}</td>
<td>{datum(z.anlagegut.anschaffungsdatum)}</td>
<td className="rechts zahl">{eur(z.bemessung)}</td>
<td className="rechts zahl">{eur(z.buchwertAnfang)}</td>
<td className="rechts zahl">{eur(z.afa)}</td>
<td className="rechts zahl">{eur(z.buchwertEnde)}</td>
<td className="gedimmt" style={{ fontSize: 12 }}>{z.hinweis}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { api } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { mitMandant } from '../lib/mandant';
export default function Export() {
const [jahr, setJahr, jahre] = useJahr();
const [festschreibung, setFestschreibung] = useState(true);
const [buSchluessel, setBuSchluessel] = useState(false);
const [meldung, setMeldung] = useState<string | null>(null);
async function festschreiben() {
const bis = `${jahr}-12-31`;
if (
!confirm(
`Alle Buchungen bis zum 31.12.${jahr} werden festgeschrieben und sind danach nicht mehr ` +
'änderbar Korrekturen sind nur noch per Storno möglich (GoBD). Fortfahren?',
)
) {
return;
}
const r = await api.post<{ anzahl: number }>('/api/buchungen/festschreiben', { bis });
setMeldung(`${r.anzahl} Buchungen festgeschrieben.`);
}
return (
<>
<h1>Export</h1>
<p className="untertitel">
Datenweitergabe an die Steuerkanzlei und an die Betriebsprüfung.
</p>
<div className="filterreihe">
<div className="feld">
<label>Wirtschaftsjahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
</div>
{meldung && <div className="hinweis gut">{meldung}</div>}
<div className="karte">
<h2>DATEV (für die Steuerkanzlei)</h2>
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
Buchungsstapel im DATEV-Format (EXTF, Version 700, Formatversion 13) samt
Kontenbeschriftungen. Das ist das Format, das eine Kanzlei in DATEV Rechnungswesen
einliest. Die Buchungen werden nach dem Zahlungszeitpunkt sortiert; das Leistungsdatum
reist im Feld Leistungsdatum mit.
</p>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginBottom: 12 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" style={{ width: 'auto' }} checked={festschreibung} onChange={(e) => setFestschreibung(e.target.checked)} />
als festgeschrieben kennzeichnen
</label>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" style={{ width: 'auto' }} checked={buSchluessel} onChange={(e) => setBuSchluessel(e.target.checked)} />
BU-Schlüssel mitgeben (nur ohne Automatikkonten)
</label>
</div>
<div className="knopfreihe" style={{ marginTop: 0 }}>
<a
className="knopf primaer"
href={mitMandant(`/api/export/datev/${jahr}?festschreibung=${festschreibung ? 1 : 0}&buSchluessel=${buSchluessel ? 1 : 0}`)}
>
DATEV-Paket herunterladen (ZIP)
</a>
<a className="knopf" href={mitMandant(`/api/export/datev/${jahr}/buchungsstapel.csv`)}>
Nur Buchungsstapel (CSV)
</a>
</div>
</div>
<div className="karte">
<h2>GoBD-Datenträgerüberlassung (GDPdU, Format Z3)</h2>
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
ZIP mit <code>index.xml</code> nach dem Beschreibungsstandard für die
Datenträgerüberlassung sowie Buchungen, Kontenplan und Anlagenverzeichnis als CSV.
Dieses Paket verlangt die Betriebsprüfung; es wird in Prüfsoftware wie IDEA eingelesen.
</p>
<div className="knopfreihe" style={{ marginTop: 0 }}>
<a className="knopf primaer" href={mitMandant(`/api/export/gdpdu/${jahr}`)}>GoBD-Paket herunterladen (ZIP)</a>
</div>
</div>
<div className="karte">
<h2>Auswertungen als CSV</h2>
<div className="knopfreihe" style={{ marginTop: 0 }}>
<a className="knopf" href={mitMandant(`/api/export/euer/${jahr}.csv`)}>Einnahmen-Überschuss-Rechnung</a>
<a className="knopf" href={mitMandant(`/api/export/bwa/${jahr}/12.csv`)}>BWA (Dezember, kumuliert)</a>
</div>
</div>
<div className="karte">
<h2>Festschreibung</h2>
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
Nach der GoBD müssen Buchungen zeitnah unveränderbar gemacht werden. Festgeschriebene
Buchungen lassen sich nicht mehr bearbeiten; Korrekturen erfolgen ausschließlich über
eine Stornobuchung. Das ist bewusst nicht rückgängig zu machen.
</p>
<div className="knopfreihe" style={{ marginTop: 0 }}>
<button type="button" onClick={festschreiben}>Buchungen bis 31.12.{jahr} festschreiben</button>
</div>
</div>
</>
);
}
+293
View File
@@ -0,0 +1,293 @@
import { useEffect, useState } from 'react';
import { api, useDaten } from '../lib/api';
import { datum, eur, REGIME_LABEL } from '../lib/format';
interface Mandant {
name: string;
strasse: string;
plz: string;
ort: string;
bundesland: string;
steuernummer: string;
ust_idnr: string;
taetigkeit: string;
betriebsbeginn: string | null;
besteuerungsart: 'ist' | 'soll';
voranmeldungszeitraum: string;
dauerfristverlaengerung: number;
datev_berater_nr: string;
datev_mandanten_nr: string;
telefon: string;
email: string;
bundesSteuernummer: string | null;
steuernummerFehler: string | null;
bundeslaender: { code: string; name: string }[];
}
interface RegimeAntwort {
eintraege: { id: number; gueltig_ab: string; modus: string; grund: string; notiz: string }[];
aktuell: string;
stichtag: string;
}
interface Grenzen {
jahr: number;
vorjahresumsatz: number;
laufenderUmsatz: number;
grenzeVorjahr: number;
grenzeLaufend: number;
warnungen: string[];
}
export default function Stammdaten() {
const { daten: geladen, neuLaden: mandantNeu } = useDaten<Mandant>('/api/mandant');
const { daten: regime, neuLaden: regimeNeu } = useDaten<RegimeAntwort>('/api/regime');
const jahr = new Date().getFullYear();
const { daten: grenzen } = useDaten<Grenzen>(`/api/regime/grenzen/${jahr}`);
const [form, setForm] = useState<Mandant | null>(null);
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
const [neu, setNeu] = useState({ gueltig_ab: `${jahr + 1}-01-01`, modus: 'regelbesteuerung', grund: '', notiz: '' });
useEffect(() => { if (geladen) setForm(geladen); }, [geladen]);
if (!form || !regime) return <p className="gedimmt">Wird geladen </p>;
async function speichern(e: React.FormEvent) {
e.preventDefault();
try {
await api.put('/api/mandant', form);
setMeldung({ art: 'gut', text: 'Stammdaten gespeichert.' });
mandantNeu();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
}
async function regimeAnlegen(e: React.FormEvent) {
e.preventDefault();
try {
const r = await api.post<{ hinweise: string[] }>('/api/regime', neu);
setMeldung({ art: 'gut', text: `Wechsel eingetragen. ${r.hinweise.join(' ')}` });
regimeNeu();
} catch (err) {
setMeldung({ art: 'kritisch', text: (err as Error).message });
}
}
async function regimeLoeschen(id: number) {
if (!confirm('Diesen Eintrag der Regime-Zeitachse entfernen? Die steuerliche Behandlung aller Buchungen danach ändert sich.')) return;
await api.del(`/api/regime/${id}`);
regimeNeu();
}
const setzen = (feld: keyof Mandant, wert: string | number) => setForm({ ...form!, [feld]: wert });
return (
<>
<h1>Stammdaten &amp; Besteuerungsform</h1>
<p className="untertitel">
Die Zeitachse der Besteuerungsform steuert, wie jede Buchung umsatzsteuerlich behandelt
wird maßgeblich ist das Regime zum Leistungsdatum der Buchung.
</p>
{meldung && <div className={`hinweis ${meldung.art}`}>{meldung.text}</div>}
<div className="karte">
<h2>Zeitachse der Besteuerungsform</h2>
<p className="gedimmt" style={{ fontSize: 13, marginTop: 0 }}>
Aktuell gültig ({datum(regime.stichtag)}): <strong>{REGIME_LABEL[regime.aktuell]}</strong>
</p>
<div className="tabellenrahmen">
<table>
<thead>
<tr><th>Gültig ab</th><th>Besteuerungsform</th><th>Grund</th><th>Notiz</th><th></th></tr>
</thead>
<tbody>
{regime.eintraege.map((r) => (
<tr key={r.id}>
<td>{datum(r.gueltig_ab)}</td>
<td><span className="abzeichen aktiv">{REGIME_LABEL[r.modus]}</span></td>
<td>{r.grund || ''}</td>
<td className="gedimmt">{r.notiz || ''}</td>
<td className="rechts">
<button type="button" className="gefahr" style={{ padding: '3px 8px', fontSize: 12 }} onClick={() => regimeLoeschen(r.id)}>
Entfernen
</button>
</td>
</tr>
))}
{regime.eintraege.length === 0 && (
<tr><td colSpan={5} className="gedimmt">
Noch kein Eintrag. Ohne Eintrag wird die Kleinunternehmerregelung angenommen.
</td></tr>
)}
</tbody>
</table>
</div>
<h3>Wechsel eintragen</h3>
<form onSubmit={regimeAnlegen}>
<div className="formularraster">
<div className="feld">
<label>Gültig ab</label>
<input type="date" required value={neu.gueltig_ab} onChange={(e) => setNeu({ ...neu, gueltig_ab: e.target.value })} />
<span className="hilfe">in der Regel der 1. Januar</span>
</div>
<div className="feld">
<label>Besteuerungsform</label>
<select value={neu.modus} onChange={(e) => setNeu({ ...neu, modus: e.target.value })}>
<option value="regelbesteuerung">Regelbesteuerung</option>
<option value="kleinunternehmer">Kleinunternehmer (§19 UStG)</option>
</select>
</div>
<div className="feld">
<label>Grund</label>
<input
value={neu.grund}
placeholder="z.B. Vorjahresumsatz über 25.000 €"
onChange={(e) => setNeu({ ...neu, grund: e.target.value })}
/>
</div>
<div className="feld">
<label>Notiz</label>
<input value={neu.notiz} onChange={(e) => setNeu({ ...neu, notiz: e.target.value })} />
</div>
</div>
<div className="knopfreihe">
<button type="submit" className="primaer">Wechsel eintragen</button>
</div>
</form>
{grenzen && (
<>
<h3>Umsatzgrenzen §19 UStG ({jahr})</h3>
<table>
<tbody>
<tr>
<td>Umsatz Vorjahr ({jahr - 1})</td>
<td className="rechts zahl">{eur(grenzen.vorjahresumsatz)}</td>
<td className="rechts gedimmt">Grenze {eur(grenzen.grenzeVorjahr)}</td>
</tr>
<tr>
<td>Umsatz laufendes Jahr</td>
<td className="rechts zahl">{eur(grenzen.laufenderUmsatz)}</td>
<td className="rechts gedimmt">Grenze {eur(grenzen.grenzeLaufend)}</td>
</tr>
</tbody>
</table>
{grenzen.warnungen.map((w) => <div key={w} className="hinweis warnung" style={{ marginTop: 10 }}>{w}</div>)}
</>
)}
</div>
<form className="karte" onSubmit={speichern}>
<h2>Unternehmen</h2>
<div className="formularraster">
<div className="feld" style={{ gridColumn: 'span 2' }}>
<label>Name / Firma</label>
<input value={form.name} onChange={(e) => setzen('name', e.target.value)} />
</div>
<div className="feld">
<label>Straße</label>
<input value={form.strasse} onChange={(e) => setzen('strasse', e.target.value)} />
</div>
<div className="feld">
<label>PLZ</label>
<input value={form.plz} onChange={(e) => setzen('plz', e.target.value)} />
</div>
<div className="feld">
<label>Ort</label>
<input value={form.ort} onChange={(e) => setzen('ort', e.target.value)} />
</div>
<div className="feld">
<label>Tätigkeit</label>
<input value={form.taetigkeit} onChange={(e) => setzen('taetigkeit', e.target.value)} />
</div>
<div className="feld">
<label>Betriebsbeginn</label>
<input type="date" value={form.betriebsbeginn ?? ''} onChange={(e) => setzen('betriebsbeginn', e.target.value)} />
</div>
<div className="feld">
<label>Telefon</label>
<input value={form.telefon} onChange={(e) => setzen('telefon', e.target.value)} />
</div>
<div className="feld">
<label>E-Mail</label>
<input value={form.email} onChange={(e) => setzen('email', e.target.value)} />
</div>
</div>
<h3>Finanzamt</h3>
<div className="formularraster">
<div className="feld">
<label>Bundesland</label>
<select value={form.bundesland} onChange={(e) => setzen('bundesland', e.target.value)}>
{form.bundeslaender.map((b) => <option key={b.code} value={b.code}>{b.name}</option>)}
</select>
</div>
<div className="feld">
<label>Steuernummer</label>
<input
value={form.steuernummer}
placeholder="123/456/78901"
onChange={(e) => setzen('steuernummer', e.target.value)}
/>
<span className="hilfe">
{geladen?.bundesSteuernummer
? `ELSTER-Format: ${geladen.bundesSteuernummer}`
: geladen?.steuernummerFehler}
</span>
</div>
<div className="feld">
<label>USt-IdNr.</label>
<input value={form.ust_idnr} onChange={(e) => setzen('ust_idnr', e.target.value)} />
</div>
<div className="feld">
<label>Besteuerungsart</label>
<select value={form.besteuerungsart} onChange={(e) => setzen('besteuerungsart', e.target.value)}>
<option value="ist">Ist-Versteuerung (§20 UStG)</option>
<option value="soll">Soll-Versteuerung</option>
</select>
<span className="hilfe">bestimmt, ob Zahlungs- oder Leistungsdatum zählt</span>
</div>
<div className="feld">
<label>Voranmeldungszeitraum</label>
<select value={form.voranmeldungszeitraum} onChange={(e) => setzen('voranmeldungszeitraum', e.target.value)}>
<option value="monatlich">monatlich</option>
<option value="vierteljaehrlich">vierteljährlich</option>
<option value="jaehrlich">jährlich (keine Voranmeldung)</option>
</select>
</div>
<div className="feld">
<label>Dauerfristverlängerung</label>
<select
value={form.dauerfristverlaengerung}
onChange={(e) => setzen('dauerfristverlaengerung', Number(e.target.value))}
>
<option value={0}>nein</option>
<option value={1}>ja</option>
</select>
</div>
</div>
<h3>DATEV</h3>
<div className="formularraster">
<div className="feld">
<label>Beraternummer</label>
<input value={form.datev_berater_nr} onChange={(e) => setzen('datev_berater_nr', e.target.value)} />
</div>
<div className="feld">
<label>Mandantennummer</label>
<input value={form.datev_mandanten_nr} onChange={(e) => setzen('datev_mandanten_nr', e.target.value)} />
</div>
</div>
<div className="knopfreihe">
<button type="submit" className="primaer">Stammdaten speichern</button>
</div>
</form>
</>
);
}
+369
View File
@@ -0,0 +1,369 @@
import { useEffect, useState } from 'react';
import { api, useDaten } from '../lib/api';
import { useJahr } from '../lib/jahr';
import { datum, eur, REGIME_LABEL } from '../lib/format';
const KENNZAHLEN: Record<string, { text: string; art: 'bemessung' | 'steuer' }> = {
'21': { text: 'Nicht steuerbare sonstige Leistungen (§18b Satz 1 Nr. 2 UStG)', art: 'bemessung' },
'35': { text: 'Umsätze zu anderen Steuersätzen', art: 'bemessung' },
'36': { text: 'Steuer auf Umsätze zu anderen Steuersätzen', art: 'steuer' },
'41': { text: 'Innergemeinschaftliche Lieferungen', art: 'bemessung' },
'43': { text: 'Weitere steuerfreie Umsätze mit Vorsteuerabzug', art: 'bemessung' },
'45': { text: 'Nicht steuerbare Umsätze (Leistungsort nicht im Inland)', art: 'bemessung' },
'46': { text: 'Leistungen eines im Ausland ansässigen Unternehmers (§13b)', art: 'bemessung' },
'47': { text: 'Steuer darauf (§13b UStG)', art: 'steuer' },
'48': { text: 'Steuerfreie Umsätze ohne Vorsteuerabzug', art: 'bemessung' },
'61': { text: 'Vorsteuer aus innergemeinschaftlichen Erwerben', art: 'steuer' },
'62': { text: 'Entrichtete Einfuhrumsatzsteuer', art: 'steuer' },
'66': { text: 'Vorsteuerbeträge aus Rechnungen anderer Unternehmer', art: 'steuer' },
'67': { text: 'Vorsteuer aus Leistungen nach §13b UStG', art: 'steuer' },
'81': { text: 'Steuerpflichtige Umsätze zum Steuersatz 19 %', art: 'bemessung' },
'83': { text: 'Verbleibende Umsatzsteuer-Vorauszahlung', art: 'steuer' },
'86': { text: 'Steuerpflichtige Umsätze zum Steuersatz 7 %', art: 'bemessung' },
'89': { text: 'Innergemeinschaftliche Erwerbe zum Steuersatz 19 %', art: 'bemessung' },
'93': { text: 'Innergemeinschaftliche Erwerbe zum Steuersatz 7 %', art: 'bemessung' },
'95': { text: 'Innergemeinschaftliche Erwerbe zu anderen Steuersätzen', art: 'bemessung' },
'98': { text: 'Steuer auf innergemeinschaftliche Erwerbe zu anderen Sätzen', art: 'steuer' },
};
interface UstvaDaten {
zeitraum: { jahr: number; code: string; von: string; bis: string; bezeichnung: string };
besteuerungsart: string;
regimeStart: string;
regimeEnde: string;
abgabepflichtig: boolean;
kennzahlen: Record<string, number>;
umsatzsteuer: number;
vorsteuer: number;
zahllast: number;
hinweise: string[];
positionen: { buchungId: number; datum: string; text: string; kz: string; bemessung: number; steuer: number }[];
meldungen: { id: number; status: string; transferticket: string | null; uebermittelt_am: string | null }[];
}
interface ElsterStatus {
verfuegbar: boolean;
meldung: string;
zertifikat: boolean;
herstellerId: boolean;
testmodus: boolean;
bereitZumSenden: boolean;
anleitung: string[];
}
interface EricAntwort {
erfolg: boolean;
simulation: boolean;
transferticket: string | null;
protokoll: string;
fehler: string[];
hinweise: string[];
}
export default function Ustva() {
const [jahr, setJahr, jahre] = useJahr();
const [zeitraeume, setZeitraeume] = useState<{ code: string; bezeichnung: string; zahllast: number; abgabepflichtig: boolean }[]>([]);
const [code, setCode] = useState('41');
const [xml, setXml] = useState<string | null>(null);
const [antwort, setAntwort] = useState<EricAntwort | null>(null);
const [berichtigt, setBerichtigt] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const { daten: status } = useDaten<ElsterStatus>('/api/elster/status');
const { daten, fehler, neuLaden } = useDaten<UstvaDaten>(`/api/ustva/${jahr}/${code}`);
useEffect(() => {
api
.get<typeof zeitraeume>(`/api/ustva/${jahr}`)
.then((z) => {
setZeitraeume(z);
if (!z.some((x) => x.code === code) && z[0]) setCode(z[0].code);
})
.catch(() => setZeitraeume([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jahr]);
async function xmlAnzeigen() {
setAntwort(null);
const r = await api.get<{ xml: string }>(
`/api/elster/ustva/${jahr}/${code}/xml?berichtigt=${berichtigt ? 1 : ''}`,
);
setXml(r.xml);
}
async function pruefen() {
setLaeuft(true);
setXml(null);
try {
setAntwort(await api.post<EricAntwort>(`/api/elster/ustva/${jahr}/${code}/validieren`, { berichtigt }));
} catch (e) {
setAntwort({ erfolg: false, simulation: false, transferticket: null, protokoll: '', fehler: [(e as Error).message], hinweise: [] });
} finally {
setLaeuft(false);
neuLaden();
}
}
async function senden() {
const bestaetigung =
`Die Umsatzsteuer-Voranmeldung für ${daten?.zeitraum.bezeichnung} wird verbindlich an das ` +
`Finanzamt übermittelt.\n\nZahllast: ${eur(daten?.zahllast ?? 0)}\n\n` +
'Eine Übermittlung lässt sich nicht zurücknehmen; eine Korrektur ist nur als berichtigte ' +
'Anmeldung möglich.\n\nJetzt übermitteln?';
if (!confirm(bestaetigung)) return;
setLaeuft(true);
setXml(null);
try {
setAntwort(
await api.post<EricAntwort>(`/api/elster/ustva/${jahr}/${code}/senden`, {
berichtigt,
bestaetigt: true,
}),
);
} catch (e) {
setAntwort({ erfolg: false, simulation: false, transferticket: null, protokoll: '', fehler: [(e as Error).message], hinweise: [] });
} finally {
setLaeuft(false);
neuLaden();
}
}
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
const kzListe = Object.entries(daten?.kennzahlen ?? {})
.filter(([k]) => k !== '83')
.sort(([a], [b]) => Number(a) - Number(b));
return (
<>
<h1>Umsatzsteuer-Voranmeldung</h1>
<p className="untertitel">
Ermittlung nach §18 UStG und Übermittlung über ELSTER. Die zeitliche Zuordnung folgt der
eingestellten Besteuerungsart.
</p>
<div className="filterreihe">
<div className="feld">
<label>Jahr</label>
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
</select>
</div>
<div className="feld" style={{ minWidth: 190 }}>
<label>Zeitraum</label>
<select value={code} onChange={(e) => { setCode(e.target.value); setXml(null); setAntwort(null); }}>
{zeitraeume.map((z) => (
<option key={z.code} value={z.code}>
{z.bezeichnung}
{z.abgabepflichtig ? ` · ${eur(z.zahllast)}` : ' · keine Abgabepflicht'}
</option>
))}
</select>
</div>
<div className="feld" style={{ minWidth: 'auto' }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={berichtigt}
onChange={(e) => setBerichtigt(e.target.checked)}
/>
berichtigte Anmeldung (Kz 10)
</label>
</div>
</div>
{daten && (
<>
<div className="kachelreihe">
<div className="kachel">
<div className="beschriftung">Umsatzsteuer</div>
<div className="wert zahl">{eur(daten.umsatzsteuer)}</div>
</div>
<div className="kachel">
<div className="beschriftung">Abziehbare Vorsteuer</div>
<div className="wert zahl">{eur(daten.vorsteuer)}</div>
</div>
<div className="kachel">
<div className="beschriftung">{daten.zahllast < 0 ? 'Erstattungsanspruch' : 'Zahllast (Kz 83)'}</div>
<div className={`wert zahl ${daten.zahllast < 0 ? 'gut' : ''}`}>{eur(daten.zahllast)}</div>
<div className="zusatz">
{daten.besteuerungsart === 'ist' ? 'Ist-Versteuerung' : 'Soll-Versteuerung'} ·{' '}
{REGIME_LABEL[daten.regimeEnde]}
</div>
</div>
</div>
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
{!daten.abgabepflichtig && (
<div className="hinweis warnung">
Für diesen Zeitraum ist nach derzeitiger Datenlage keine Voranmeldung abzugeben.
</div>
)}
<div className="karte">
<h2>Kennzahlen</h2>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th style={{ width: 60 }}>Kz</th>
<th>Sachverhalt</th>
<th className="rechts" style={{ width: 150 }}>Betrag</th>
</tr>
</thead>
<tbody>
{kzListe.length === 0 && (
<tr><td colSpan={3} className="gedimmt">Keine meldepflichtigen Umsätze in diesem Zeitraum.</td></tr>
)}
{kzListe.map(([k, wert]) => (
<tr key={k}>
<td className="zahl">{k}</td>
<td>
{KENNZAHLEN[k]?.text ?? 'Kennzahl'}
{KENNZAHLEN[k]?.art === 'bemessung' && (
<span className="gedimmt" style={{ fontSize: 12 }}> · Bemessungsgrundlage</span>
)}
</td>
<td className="rechts zahl">{eur(wert)}</td>
</tr>
))}
<tr className="ergebnis">
<td className="zahl">83</td>
<td>Verbleibende Umsatzsteuer-Vorauszahlung</td>
<td className="rechts zahl">{eur(daten.zahllast)}</td>
</tr>
</tbody>
</table>
</div>
</div>
{daten.meldungen.length > 0 && (
<div className="karte">
<h3>Bisherige Meldungen zu diesem Zeitraum</h3>
<table>
<thead>
<tr><th>Status</th><th>Transferticket</th><th>Übermittelt</th></tr>
</thead>
<tbody>
{daten.meldungen.map((m) => (
<tr key={m.id}>
<td><span className={`abzeichen ${m.status === 'uebermittelt' ? 'aktiv' : ''}`}>{m.status}</span></td>
<td className="zahl">{m.transferticket ?? ''}</td>
<td>{m.uebermittelt_am ? datum(m.uebermittelt_am) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
<div className="karte">
<h2>Übermittlung über ELSTER</h2>
{status && (
<>
<div className={`hinweis ${status.bereitZumSenden ? 'gut' : 'warnung'}`}>
{status.bereitZumSenden ? (
<>ERiC ist eingebunden und einsatzbereit. {status.testmodus && 'Testmerker gesetzt Übermittlungen bleiben ohne steuerliche Wirkung.'}</>
) : (
<>
<strong>Kein Versand möglich ERiC ist nicht vollständig eingerichtet.</strong>
<div style={{ marginTop: 4 }}>{status.meldung}</div>
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
{status.anleitung.map((a) => <li key={a}>{a}</li>)}
</ul>
<div style={{ marginTop: 6 }}>
Ohne ERiC lässt sich die Anmeldung erzeugen und prüfen, aber nicht absenden.
</div>
</>
)}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 13, marginBottom: 12 }}>
<span className={`abzeichen ${status.verfuegbar ? 'aktiv' : ''}`}>ERiC-Bibliothek</span>
<span className={`abzeichen ${status.zertifikat ? 'aktiv' : ''}`}>Zertifikat</span>
<span className={`abzeichen ${status.herstellerId ? 'aktiv' : ''}`}>Hersteller-ID</span>
<span className={`abzeichen ${status.testmodus ? 'aktiv' : ''}`}>Testmodus</span>
</div>
</>
)}
<div className="knopfreihe" style={{ marginTop: 0 }}>
<button type="button" onClick={xmlAnzeigen}>XML ansehen</button>
<button type="button" onClick={pruefen} disabled={laeuft}>
{laeuft ? 'Wird geprüft …' : 'Prüfen (ohne Versand)'}
</button>
<button
type="button"
className="primaer"
onClick={senden}
disabled={laeuft || !status?.bereitZumSenden}
title={status?.bereitZumSenden ? '' : 'ERiC ist nicht eingerichtet'}
>
Verbindlich übermitteln
</button>
</div>
{antwort && (
<div className={`hinweis ${antwort.simulation ? 'warnung' : antwort.erfolg ? 'gut' : 'kritisch'}`} style={{ marginTop: 14 }}>
<strong>
{antwort.simulation
? 'Simulationsmodus nicht übermittelt'
: antwort.erfolg
? 'Erfolgreich'
: 'Fehlgeschlagen'}
</strong>
{antwort.transferticket && (
<div style={{ marginTop: 4 }}>Transferticket: <span className="zahl">{antwort.transferticket}</span></div>
)}
{antwort.fehler.map((f) => <div key={f} style={{ marginTop: 4 }}>{f}</div>)}
{antwort.hinweise.map((h) => <div key={h} style={{ marginTop: 4 }}>{h}</div>)}
{antwort.protokoll && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer' }}>Protokoll</summary>
<pre className="xml">{antwort.protokoll}</pre>
</details>
)}
</div>
)}
{xml && (
<>
<h3>Erzeugter ELSTER-Datensatz</h3>
<pre className="xml">{xml}</pre>
</>
)}
</div>
{daten && daten.positionen.length > 0 && (
<div className="karte">
<h2>Zugrunde liegende Buchungen</h2>
<div className="tabellenrahmen">
<table>
<thead>
<tr>
<th>Datum</th><th>Vorgang</th><th>Kz</th>
<th className="rechts">Bemessung</th><th className="rechts">Steuer</th>
</tr>
</thead>
<tbody>
{daten.positionen.map((p) => (
<tr key={`${p.buchungId}-${p.kz}`}>
<td>{datum(p.datum)}</td>
<td>{p.text}</td>
<td className="zahl">{p.kz}</td>
<td className="rechts zahl">{eur(p.bemessung)}</td>
<td className="rechts zahl">{p.steuer ? eur(p.steuer) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
+275
View File
@@ -0,0 +1,275 @@
:root {
color-scheme: light;
--surface-0: #f4f4f1;
--surface-1: #fcfcfb;
--surface-2: #eeeeea;
--border: #dcdcd4;
--border-stark: #c4c4b9;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #86857e;
--akzent: #2a78d6;
--akzent-weich: #e6f0fc;
--series-1: #2a78d6;
--series-2: #eb6834;
--gut: #0f7a4d;
--warnung: #8a5a00;
--warnung-flaeche: #fdf3dd;
--kritisch: #b4231f;
--kritisch-flaeche: #fdeceb;
--radius: 10px;
--schrift: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--schrift-zahl: 'SF Mono', ui-monospace, 'DejaVu Sans Mono', Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) {
color-scheme: dark;
--surface-0: #131312;
--surface-1: #1a1a19;
--surface-2: #232322;
--border: #34342f;
--border-stark: #4a4a44;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e85;
--akzent: #3987e5;
--akzent-weich: #17273a;
--series-1: #3987e5;
--series-2: #d95926;
--gut: #3fae7d;
--warnung: #d8a53c;
--warnung-flaeche: #2e2716;
--kritisch: #e66767;
--kritisch-flaeche: #331b1b;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--schrift);
background: var(--surface-0);
color: var(--text-primary);
font-size: 15px;
line-height: 1.5;
}
/* ---- Grundgerüst ------------------------------------------------------- */
/* Der Verlauf färbt die Navigationsspalte über die volle Seitenhöhe,
auch wenn die Navigation selbst kürzer ist als der Inhalt. */
.rahmen {
display: grid;
grid-template-columns: 232px 1fr;
min-height: 100vh;
background: linear-gradient(to right, var(--surface-1) 232px, var(--surface-0) 232px);
}
.seitenleiste {
border-right: 1px solid var(--border);
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.marke { font-weight: 650; font-size: 15px; padding: 0 10px 6px; letter-spacing: -0.01em; }
.marke small { display: block; font-weight: 400; color: var(--text-muted); font-size: 12px; }
.nav-gruppe {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 14px 10px 4px;
}
.seitenleiste a {
display: block;
padding: 7px 10px;
border-radius: 7px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
}
.seitenleiste a:hover { background: var(--surface-2); color: var(--text-primary); }
.seitenleiste a.aktiv { background: var(--akzent-weich); color: var(--akzent); font-weight: 550; }
.inhalt { padding: 26px 30px 60px; max-width: 1180px; }
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: -0.02em; }
h2 { font-size: 16px; margin: 26px 0 10px; letter-spacing: -0.01em; }
h3 { font-size: 14px; margin: 18px 0 8px; color: var(--text-secondary); }
.untertitel { color: var(--text-secondary); margin: 0 0 20px; font-size: 14px; }
/* ---- Bausteine --------------------------------------------------------- */
.karte {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
}
.karte > h2:first-child, .karte > h3:first-child { margin-top: 0; }
.kachelreihe { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
.kachel {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
}
.kachel .beschriftung { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.kachel .wert { font-size: 24px; font-weight: 620; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.kachel .zusatz { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.zahl { font-variant-numeric: tabular-nums; font-family: var(--schrift-zahl); }
.rechts { text-align: right; }
.gut { color: var(--gut); }
.kritisch { color: var(--kritisch); }
.gedimmt { color: var(--text-muted); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
text-align: left;
font-weight: 550;
font-size: 12px;
color: var(--text-secondary);
padding: 6px 10px;
border-bottom: 1px solid var(--border-stark);
white-space: nowrap;
}
td { padding: 7px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
tbody tr:hover { background: var(--surface-2); }
tr.summe td { font-weight: 620; border-top: 1px solid var(--border-stark); background: var(--surface-2); }
tr.ergebnis td { font-weight: 680; font-size: 15px; background: var(--akzent-weich); }
.tabellenrahmen { overflow-x: auto; }
button, .knopf {
font: inherit;
font-size: 14px;
padding: 7px 14px;
border-radius: 7px;
border: 1px solid var(--border-stark);
background: var(--surface-1);
color: var(--text-primary);
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button:hover, .knopf:hover { background: var(--surface-2); }
button.primaer, .knopf.primaer { background: var(--akzent); border-color: var(--akzent); color: #fff; }
button.primaer:hover { filter: brightness(1.08); }
button.gefahr { border-color: var(--kritisch); color: var(--kritisch); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, select, textarea {
font: inherit;
font-size: 14px;
padding: 6px 9px;
border-radius: 7px;
border: 1px solid var(--border-stark);
background: var(--surface-1);
color: var(--text-primary);
width: 100%;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--akzent); outline-offset: -1px; }
.feld { display: flex; flex-direction: column; gap: 4px; }
.feld > label { font-size: 12px; color: var(--text-secondary); }
.feld .hilfe { font-size: 11px; color: var(--text-muted); }
.formularraster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
.knopfreihe { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
.filterreihe { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
.filterreihe .feld { min-width: 130px; flex: 0 1 auto; }
.hinweis {
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 10px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.hinweis.warnung { background: var(--warnung-flaeche); border-color: color-mix(in srgb, var(--warnung) 40%, transparent); color: var(--warnung); }
.hinweis.kritisch { background: var(--kritisch-flaeche); border-color: color-mix(in srgb, var(--kritisch) 40%, transparent); color: var(--kritisch); }
.hinweis.gut { background: color-mix(in srgb, var(--gut) 12%, transparent); border-color: color-mix(in srgb, var(--gut) 40%, transparent); color: var(--gut); }
.abzeichen {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-secondary);
white-space: nowrap;
}
.abzeichen.aktiv { background: var(--akzent-weich); border-color: var(--akzent); color: var(--akzent); }
pre.xml {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
font-size: 12px;
font-family: var(--schrift-zahl);
max-height: 420px;
}
/* ---- Diagramm ---------------------------------------------------------- */
.diagramm { width: 100%; }
.diagramm text { font-family: var(--schrift); }
.legende { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
.legende span { display: inline-flex; align-items: center; gap: 6px; }
.legende i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
@media (max-width: 800px) {
.rahmen { grid-template-columns: 1fr; background: var(--surface-0); }
.seitenleiste { position: static; height: auto; flex-direction: row; flex-wrap: wrap; background: var(--surface-1); }
.nav-gruppe { display: none; }
.inhalt { padding: 18px 14px 50px; }
}
/* ---- Mandantenwahl ------------------------------------------------------ */
.mandantenwahl {
padding: 8px 10px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 6px;
}
.mandantenwahl > label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 4px;
}
.mandantenwahl select { font-size: 13px; padding: 5px 8px; }
/* Testmandanten müssen auf jeder Seite sofort erkennbar sein sonst bucht
man versehentlich Spieldaten in die echte Buchhaltung. */
.testkennung {
margin-top: 8px;
padding: 5px 8px;
border-radius: 6px;
font-size: 11px;
line-height: 1.35;
font-weight: 550;
color: var(--warnung);
background: var(--warnung-flaeche);
border: 1px solid color-mix(in srgb, var(--warnung) 45%, transparent);
}
.mandant-neu { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.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; }
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
* EUER_POLLING=1 stellt den Datei-Watcher auf Abfrage statt inotify um.
* Nötig auf Linux-Systemen, deren fs.inotify.max_user_instances aufgebraucht
* ist (Symptom: "EMFILE: too many open files, watch ..."). Kostet etwas CPU,
* kommt aber ohne Änderung an Systemeinstellungen aus.
*/
const polling = process.env['EUER_POLLING'] === '1';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:3311',
},
watch: polling ? { usePolling: true, interval: 400 } : undefined,
},
build: { outDir: 'dist' },
});