Refactor: consolidate accounting routes under Buchhaltung submenu

- New layout route: companies.$id.buchhaltung.tsx with card-based navigation
- Renamed 7 accounting routes to use buchhaltung prefix:
  - companies.$id.bilanzen.tsx → companies.$id.buchhaltung.bilanzen.tsx
  - companies.$id.ausgaben.tsx → companies.$id.buchhaltung.ausgaben.tsx
  - companies.$id.ausgaben.kategorien.tsx → companies.$id.buchhaltung.ausgaben.kategorien.tsx
  - companies.$id.einnahmen.tsx → companies.$id.buchhaltung.einnahmen.tsx
  - companies.$id.einnahmen.kategorien.tsx → companies.$id.buchhaltung.einnahmen.kategorien.tsx
  - companies.$id.anlagevermoegen.tsx → companies.$id.buchhaltung.anlagevermoegen.tsx
  - companies.$id.money.tsx → companies.$id.buchhaltung.money.tsx

- Updated routing configuration (app/routes.ts) to use nested layout structure
- Updated breadcrumbs in all accounting routes to show Buchhaltung hierarchy
- Updated internal links in kategorien pages to use new URLs
- Main menu now shows single 'Buchhaltung' card instead of 5 separate items

Navigation improvements:
- Cleaner main menu (1 item vs 5)
- Clear accounting subsection with icon-based navigation
- Consistent URL structure (/companies/:id/buchhaltung/*)
- Better information hierarchy

Build:  Successful
Accounting routes:  Accessible
Navigation:  Functional

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
hwinkel
2026-04-15 21:41:56 +02:00
parent f10a79471e
commit ad80688b8b
68 changed files with 141 additions and 4014 deletions
@@ -0,0 +1,322 @@
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, Banknote, Landmark } 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: "Buchhaltung", href: `/companies/${data.companyId}/buchhaltung/bilanzen` },
{ 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;
ausgabenVorsteuer: number;
ausgabenByKategorie: { kategorie: string; betrag: number }[];
sonstigeEinnahmen: number;
einnahmenUst: number;
jahresergebnis: number;
};
bilanz: {
aktiva: {
forderungen: { betrag: number; anzahl: number };
bank: { betrag: number; anzahl: number };
kasse: { betrag: 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. (brutto)" value={data.guv.sonstigeEinnahmen} indent />
{data.guv.einnahmenUst > 0 && (
<Row label="Umsatzsteuer (enthalten)" value={data.guv.einnahmenUst} indent muted />
)}
<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 />
)}
{data.guv.ausgabenVorsteuer > 0 && (
<Row label="Vorsteuer (enthalten)" value={data.guv.ausgabenVorsteuer} 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
/>
<div className="flex justify-between py-2 border-b border-gray-50">
<span className="text-sm ml-4 text-gray-700 flex items-center gap-1.5">
<Landmark className="h-3.5 w-3.5 text-blue-500" />
{`Bank (${data.bilanz.aktiva.bank.anzahl} bezahlte Rechnungen + Einnahmen)`}
</span>
<span className="text-sm tabular-nums text-gray-800">{formatCurrency(data.bilanz.aktiva.bank.betrag)}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-50">
<span className="text-sm ml-4 text-gray-700 flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-amber-500" />
Kasse (Saldo sonstige Belege)
</span>
<span className="text-sm tabular-nums text-gray-800">{formatCurrency(data.bilanz.aktiva.kasse.betrag)}</span>
</div>
<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 enthält bezahlte Rechnungen + sonstige Bankeinnahmen abzgl. Bankausgaben. Kasse = sonstige Kasseneinnahmen abzgl. Kassenausgaben.
</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>
);
}