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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & Regime</Link> eintragen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{daten.hinweise.map((h) => (
|
||||
<div key={h} className="hinweis">{h}</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user