Files
AnnasRechnungsManager/app/routes/companies.$id.buchhaltung.bilanzen.tsx
T
hwinkel ad80688b8b 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>
2026-04-15 21:41:56 +02:00

323 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}