ADD: added einnahmen, ausgaben and bilanz

This commit is contained in:
hwinkel
2026-03-24 14:48:32 +01:00
parent 1bbeaf2c34
commit 6d8c4b615f
32 changed files with 2982 additions and 10 deletions
+303
View File
@@ -0,0 +1,303 @@
import { useState, useEffect } from "react";
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency } from "@/lib/tax";
import { ChevronLeft, Scale, TrendingUp, Info } from "lucide-react";
import { KATEGORIE_LABELS } from "@/lib/ausgaben";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Bilanzen" },
],
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true, name: true },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { companyId: company.id, companyName: company.name };
}
interface ErloeseByRate {
netAmount: number;
taxAmount: number;
grossAmount: number;
}
interface BilanzenData {
year: number;
kleinunternehmer: boolean;
guv: {
erloeseByRate: Record<string, ErloeseByRate>;
netTotal: number;
taxTotal: number;
grossTotal: number;
invoiceCount: number;
ausgabenGesamt: number;
ausgabenByKategorie: { kategorie: string; betrag: number }[];
sonstigeEinnahmen: number;
jahresergebnis: number;
};
bilanz: {
aktiva: {
forderungen: { betrag: number; anzahl: number };
bank: { betrag: number; anzahl: number };
summe: number;
};
passiva: {
eigenkapital: number;
summe: number;
};
};
}
function Row({ label, value, bold, indent, muted }: {
label: string;
value?: number;
bold?: boolean;
indent?: boolean;
muted?: boolean;
}) {
return (
<div className={`flex justify-between py-2 ${bold ? "border-t border-gray-200 mt-1" : "border-b border-gray-50"}`}>
<span className={`text-sm ${indent ? "ml-4" : ""} ${bold ? "font-semibold text-gray-900" : muted ? "text-gray-400" : "text-gray-700"}`}>
{label}
</span>
{value !== undefined ? (
<span className={`text-sm tabular-nums ${bold ? "font-bold text-gray-900" : muted ? "text-gray-400" : "text-gray-800"}`}>
{formatCurrency(value)}
</span>
) : null}
</div>
);
}
export default function BilanzenPage() {
const { companyId } = useLoaderData<typeof loader>();
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<BilanzenData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/bilanzen?companyId=${companyId}&year=${year}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [companyId, year]);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Bilanzen</h1>
<p className="text-gray-500 mt-1">Bilanz und Gewinn- & Verlustrechnung</p>
</div>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{loading ? (
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
) : data && (
<div className="space-y-6">
{/* Zusammenfassung */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.guv.netTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Betriebsausgaben</p>
<p className="text-2xl font-bold text-rose-600">{formatCurrency(data.guv.ausgabenGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Jahresergebnis</p>
<p className={`text-2xl font-bold ${data.guv.jahresergebnis >= 0 ? "text-teal-600" : "text-red-600"}`}>
{formatCurrency(data.guv.jahresergebnis)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Bilanzsumme</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.bilanz.aktiva.summe)}</p>
</CardContent>
</Card>
</div>
{/* GuV */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-teal-600" />
<CardTitle>Gewinn- und Verlustrechnung (GuV) {year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="max-w-lg">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Erträge</p>
{Object.entries(data.guv.erloeseByRate)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rate, group]) => (
<Row
key={rate}
label={
data.kleinunternehmer
? "Umsatzerlöse (steuerfrei)"
: `Umsatzerlöse ${Number(rate) > 0 ? `${rate}% MwSt.` : "steuerfrei"}`
}
value={group.netAmount}
indent
/>
))}
{!data.kleinunternehmer && data.guv.taxTotal > 0 && (
<Row label="Umsatzsteuer" value={data.guv.taxTotal} indent muted />
)}
<Row label="Summe Umsatzerlöse (netto)" value={data.guv.netTotal} bold />
{data.guv.sonstigeEinnahmen > 0 && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Sonstige Einnahmen</p>
<Row label="Privateinlagen, Erstattungen u.a." value={data.guv.sonstigeEinnahmen} indent />
<Row label="Summe Sonstige Einnahmen" value={data.guv.sonstigeEinnahmen} bold />
</div>
)}
<div className="mt-6">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Aufwendungen</p>
{data.guv.ausgabenByKategorie.length > 0 ? (
data.guv.ausgabenByKategorie
.sort((a, b) => b.betrag - a.betrag)
.map((a) => (
<Row
key={a.kategorie}
label={KATEGORIE_LABELS[a.kategorie as keyof typeof KATEGORIE_LABELS] ?? a.kategorie}
value={a.betrag}
indent
/>
))
) : (
<Row label="Betriebsausgaben" value={0} indent muted />
)}
<Row label="Summe Aufwendungen" value={data.guv.ausgabenGesamt} bold />
</div>
<div className="mt-6 pt-3 border-t-2 border-teal-200">
<div className="flex justify-between py-2">
<span className="text-base font-bold text-gray-900">
{data.guv.jahresergebnis >= 0 ? "Jahresüberschuss" : "Jahresfehlbetrag"}
</span>
<span className={`text-base font-bold ${data.guv.jahresergebnis >= 0 ? "text-teal-600" : "text-red-600"}`}>
{formatCurrency(data.guv.jahresergebnis)}
</span>
</div>
</div>
{data.guv.ausgabenGesamt === 0 && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-100">
<Info className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700">
Noch keine Betriebsausgaben für {data.year} erfasst.
Ausgaben können über die <a href={`/companies/${companyId}/ausgaben`} className="underline">Ausgaben-Seite</a> gepflegt werden.
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Bilanz */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Aktiva */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-teal-600" />
<CardTitle>Aktiva Stichtag 31.12.{year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Umlaufvermögen</p>
<Row
label={`Forderungen aus L+L (${data.bilanz.aktiva.forderungen.anzahl} offen)`}
value={data.bilanz.aktiva.forderungen.betrag}
indent
/>
<Row
label={`Bank / Kasse (${data.bilanz.aktiva.bank.anzahl} bezahlt)`}
value={data.bilanz.aktiva.bank.betrag}
indent
/>
<Row label="Summe Aktiva" value={data.bilanz.aktiva.summe} bold />
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-gray-50 border border-gray-100">
<Info className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-500">
Bank/Kasse ist eine Näherung auf Basis bezahlter Rechnungen (kumuliert bis Jahresende).
</p>
</div>
</CardContent>
</Card>
{/* Passiva */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-teal-600" />
<CardTitle>Passiva Stichtag 31.12.{year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Eigenkapital</p>
<Row label="Eigenkapital (vereinfacht)" value={data.bilanz.passiva.eigenkapital} indent />
<div className="mt-4">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Verbindlichkeiten</p>
<Row label="Verbindlichkeiten" value={0} indent muted />
</div>
<Row label="Summe Passiva" value={data.bilanz.passiva.summe} bold />
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-gray-50 border border-gray-100">
<Info className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-500">
Verbindlichkeiten werden nicht erfasst. Das Eigenkapital entspricht vereinfacht der Aktivseite.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
}