feat: add financial transactions management for companies

- Implemented a new route for managing financial transactions (money) for companies, including creating, editing, and deleting transactions.
- Added a new model `Buchung` to represent transactions with fields for date, account type, transaction type, amount, and description.
- Updated the `companies` model to include a relation to the new `Buchung` model.
- Enhanced the company overview page to link to the new financial transactions page.
- Added migration scripts to create the necessary database tables and fields for the new functionality.
- Created utility scripts for resetting the admin password and setting up the initial admin user.
This commit is contained in:
hwinkel
2026-03-24 19:25:48 +01:00
parent 6d8c4b615f
commit d582c748a2
29 changed files with 2464 additions and 815 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { startCleanupScheduler } from "@/lib/cleanup.server";
import { startCleanupScheduler } from "./lib/cleanup.server";
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
+6
View File
@@ -20,6 +20,8 @@ export default [
route("companies/:id/bilanzen", "routes/companies.$id.bilanzen.tsx"),
route("companies/:id/ausgaben", "routes/companies.$id.ausgaben.tsx"),
route("companies/:id/einnahmen", "routes/companies.$id.einnahmen.tsx"),
route("companies/:id/anlagevermoegen", "routes/companies.$id.anlagevermoegen.tsx"),
route("companies/:id/money", "routes/companies.$id.money.tsx"),
route("archiv", "routes/archiv.tsx"),
route("settings/password", "routes/settings.password.tsx"),
]),
@@ -38,6 +40,7 @@ export default [
route("api/companies/:id", "routes/api.companies.$id.ts"),
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"),
route("api/customers", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.ts"),
route("api/services", "routes/api.services.ts"),
@@ -52,4 +55,7 @@ export default [
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
route("api/einnahmen", "routes/api.einnahmen.ts"),
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"),
route("api/anlagevermoegen/:id", "routes/api.anlagevermoegen.$id.ts"),
] satisfies RouteConfig;
+52
View File
@@ -0,0 +1,52 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0),
beschreibung: z.string().optional(),
aktiv: z.boolean(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const asset = await prisma.anlagegut.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.anlagegut.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.anlagegut.update({
where: { id: params.id },
data: {
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...updated,
anschaffungskosten: Number(updated.anschaffungskosten),
restwert: Number(updated.restwert),
anschaffungsdatum: updated.anschaffungsdatum.toISOString(),
});
}
+104
View File
@@ -0,0 +1,104 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { afaFuerJahr, buchwert, assetStatus } from "@/lib/afa";
const createSchema = z.object({
companyId: z.string().min(1),
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0).default(0),
beschreibung: z.string().optional(),
aktiv: z.boolean().default(true),
});
function toRaw(a: {
anschaffungskosten: unknown;
nutzungsdauerJahre: number;
restwert: unknown;
anschaffungsdatum: Date;
aktiv: boolean;
}) {
return {
anschaffungskosten: Number(a.anschaffungskosten),
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: Number(a.restwert),
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
aktiv: a.aktiv,
};
}
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const assets = await prisma.anlagegut.findMany({
where: { companyId },
orderBy: { anschaffungsdatum: "asc" },
});
return Response.json({
year,
assets: assets.map((a) => {
const raw = toRaw(a);
return {
id: a.id,
bezeichnung: a.bezeichnung,
beschreibung: a.beschreibung,
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
anschaffungskosten: raw.anschaffungskosten,
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: raw.restwert,
aktiv: a.aktiv,
afaJahr: afaFuerJahr(raw, year),
buchwert: buchwert(raw, year),
status: assetStatus(raw, year),
};
}),
});
}
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const asset = await prisma.anlagegut.create({
data: {
companyId: parsed.data.companyId,
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...asset,
anschaffungskosten: Number(asset.anschaffungskosten),
restwert: Number(asset.restwert),
anschaffungsdatum: asset.anschaffungsdatum.toISOString(),
}, { status: 201 });
}
+10 -1
View File
@@ -6,6 +6,8 @@ import { AusgabeKategorie } from "@prisma/client";
const updateSchema = z.object({
kategorie: z.nativeEnum(AusgabeKategorie),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
@@ -33,10 +35,17 @@ export async function action({ request, params }: { request: Request; params: {
data: {
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
return Response.json({
...updated,
betrag: Number(updated.betrag),
steuersatz: Number(updated.steuersatz),
datum: updated.datum.toISOString(),
});
}
+11 -1
View File
@@ -7,6 +7,8 @@ const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.nativeEnum(AusgabeKategorie),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
@@ -41,6 +43,7 @@ export async function loader({ request }: { request: Request }) {
ausgaben.map((a) => ({
...a,
betrag: Number(a.betrag),
steuersatz: Number(a.steuersatz),
datum: a.datum.toISOString(),
}))
);
@@ -64,10 +67,17 @@ export async function action({ request }: { request: Request }) {
companyId: parsed.data.companyId,
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...ausgabe, betrag: Number(ausgabe.betrag), datum: ausgabe.datum.toISOString() }, { status: 201 });
return Response.json({
...ausgabe,
betrag: Number(ausgabe.betrag),
steuersatz: Number(ausgabe.steuersatz),
datum: ausgabe.datum.toISOString(),
}, { status: 201 });
}
+41 -20
View File
@@ -64,26 +64,47 @@ export async function loader({ request }: { request: Request }) {
const summeAktiva = forderungen + bank;
// Betriebsausgaben für das Jahr
const ausgabenAgg = await prisma.betriebsausgabe.aggregate({
const ausgaben = await prisma.betriebsausgabe.findMany({
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
_sum: { betrag: true },
_count: true,
});
const ausgabenByKategorie = await prisma.betriebsausgabe.groupBy({
by: ["kategorie"],
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
_sum: { betrag: true },
});
const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.betrag), 0);
const ausgabenVorsteuer = ausgaben.reduce((s, a) => {
const brutto = Number(a.betrag);
const rate = Number(a.steuersatz) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
const ausgabenGesamt = Number(ausgabenAgg._sum.betrag ?? 0);
// Ausgaben nach Kategorie
const ausgabenByKategorieMap: Record<string, number> = {};
for (const a of ausgaben) {
const k = a.kategorie;
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.betrag);
}
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
// Sonstige Einnahmen für das Jahr
const einnahmenAgg = await prisma.betriebseinnahme.aggregate({
const einnahmen = await prisma.betriebseinnahme.findMany({
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
_sum: { betrag: true },
});
const sonstigeEinnahmen = Number(einnahmenAgg._sum.betrag ?? 0);
const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.betrag), 0);
const einnahmenUst = einnahmen.reduce((s, e) => {
const brutto = Number(e.betrag);
const rate = Number(e.steuersatz) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart)
const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.betrag), 0);
const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.betrag), 0);
const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.betrag), 0);
const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.betrag), 0);
// Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
const kasseNetto = einnahmenKasse - ausgabenKasse;
const bankNetto = bank + einnahmenBank - ausgabenBank;
const summeAktivaErweitert = forderungen + Math.max(0, bankNetto) + Math.max(0, kasseNetto);
const jahresergebnis = guvNetto + sonstigeEinnahmen - ausgabenGesamt;
@@ -97,22 +118,22 @@ export async function loader({ request }: { request: Request }) {
grossTotal: guvBrutto,
invoiceCount: guvInvoices.length,
ausgabenGesamt,
ausgabenByKategorie: ausgabenByKategorie.map((a) => ({
kategorie: a.kategorie,
betrag: Number(a._sum.betrag ?? 0),
})),
ausgabenVorsteuer,
ausgabenByKategorie,
sonstigeEinnahmen,
einnahmenUst,
jahresergebnis,
},
bilanz: {
aktiva: {
forderungen: { betrag: forderungen, anzahl: forderungenAgg._count },
bank: { betrag: bank, anzahl: bankAgg._count },
summe: summeAktiva,
bank: { betrag: Math.max(0, bankNetto), anzahl: bankAgg._count },
kasse: { betrag: Math.max(0, kasseNetto) },
summe: summeAktivaErweitert,
},
passiva: {
eigenkapital: summeAktiva,
summe: summeAktiva,
eigenkapital: summeAktivaErweitert,
summe: summeAktivaErweitert,
},
},
});
+110
View File
@@ -0,0 +1,110 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
type Transaction = {
id: string;
date: string;
account: "kasse" | "bank";
type: "einlage" | "entnahme";
amount: number;
description: string;
};
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; }): Transaction {
return {
id: buchung.id,
date: buchung.date.toISOString().split("T")[0],
account: buchung.account === "KASSE" ? "kasse" : "bank",
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
amount: Number(buchung.amount),
description: buchung.description || "",
};
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const buchungen = await prisma.buchung.findMany({
where: { companyId: id },
orderBy: { date: "desc" },
});
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const url = new URL(request.url);
const transactionId = url.searchParams.get("transactionId");
const method = request.method;
const data = await request.json().catch(() => ({}));
if (method === "POST") {
const amount = Number(data.amount);
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
await prisma.buchung.create({
data: {
companyId: id,
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
} else if (method === "PUT") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
const amount = Number(data.amount);
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
await prisma.buchung.update({
where: { id: transactionId },
data: {
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
} else if (method === "DELETE") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
} else {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const buchungen = await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" } });
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
+10 -1
View File
@@ -6,6 +6,8 @@ import { EinnahmeKategorie } from "@prisma/client";
const updateSchema = z.object({
kategorie: z.nativeEnum(EinnahmeKategorie),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
@@ -46,10 +48,17 @@ export async function action({ request, params }: { request: Request; params: {
data: {
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
return Response.json({
...updated,
betrag: Number(updated.betrag),
steuersatz: Number(updated.steuersatz),
datum: updated.datum.toISOString(),
});
}
+11 -1
View File
@@ -7,6 +7,8 @@ const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.nativeEnum(EinnahmeKategorie),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
@@ -52,6 +54,7 @@ export async function loader({ request }: { request: Request }) {
einnahmen.map((e) => ({
...e,
betrag: Number(e.betrag),
steuersatz: Number(e.steuersatz),
datum: e.datum.toISOString(),
}))
);
@@ -93,13 +96,20 @@ export async function action({ request }: { request: Request }) {
companyId: parsed.data.companyId,
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json(
{ ...einnahme, betrag: Number(einnahme.betrag), datum: einnahme.datum.toISOString() },
{
...einnahme,
betrag: Number(einnahme.betrag),
steuersatz: Number(einnahme.steuersatz),
datum: einnahme.datum.toISOString(),
},
{ status: 201 }
);
}
+444 -254
View File
@@ -1,18 +1,19 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { Link, useLoaderData, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { ChevronLeft, Plus, Edit, Trash2, Layers } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { formatCurrency, formatDate } from "@/lib/tax";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChevronLeft, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { formatCurrency } from "@/lib/tax";
import { afaFuerJahr, buchwert, assetStatus, type AnlagegutRaw } from "@/lib/afa";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
@@ -22,17 +23,6 @@ export const handle = {
],
};
const schema = z.object({
bezeichnung: z.string().min(1, "Pflichtfeld"),
anschaffungsdatum: z.string().min(1, "Pflichtfeld"),
anschaffungskosten: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }).positive("Betrag muss größer 0 sein"),
nutzungsdauerJahre: z.coerce.number().int().min(1, "Mindestens 1 Jahr"),
restwert: z.coerce.number().min(0).default(0),
beschreibung: z.string().optional(),
aktiv: z.boolean().default(true),
});
type FormData = z.infer<typeof schema>;
interface Asset {
id: string;
bezeichnung: string;
@@ -42,11 +32,46 @@ interface Asset {
nutzungsdauerJahre: number;
restwert: number;
aktiv: boolean;
afaJahr: number;
buchwert: number;
status: "aktiv" | "vollständig abgeschrieben" | "inaktiv";
}
interface AssetWithAfa extends Asset {
afaJahr: number;
buchwertJahr: number;
statusLabel: string;
}
function enrichAsset(a: Asset, year: number): AssetWithAfa {
const raw: AnlagegutRaw = {
anschaffungskosten: a.anschaffungskosten,
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: a.restwert,
anschaffungsdatum: a.anschaffungsdatum,
aktiv: a.aktiv,
};
return {
...a,
afaJahr: afaFuerJahr(raw, year),
buchwertJahr: buchwert(raw, year),
statusLabel: assetStatus(raw, year),
};
}
const STATUS_VARIANTS: Record<string, "success" | "secondary" | "outline"> = {
aktiv: "success",
"vollständig abgeschrieben": "secondary",
inaktiv: "outline",
};
const emptyForm = {
bezeichnung: "",
anschaffungsdatum: "",
anschaffungskosten: "",
nutzungsdauerJahre: "",
restwert: "0",
beschreibung: "",
aktiv: true,
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
@@ -54,136 +79,138 @@ export async function loader({ request, params }: { request: Request; params: {
select: { id: true, name: true },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { companyId: company.id, companyName: company.name };
}
function AnlagegutForm({
defaultValues,
onSubmit,
submitLabel,
}: {
defaultValues?: Partial<FormData>;
onSubmit: (d: FormData) => Promise<void>;
submitLabel: string;
}) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
aktiv: true,
restwert: 0,
anschaffungsdatum: new Date().toISOString().slice(0, 10),
...defaultValues,
},
const assets = await prisma.anlagegut.findMany({
where: { companyId: params.id },
orderBy: { anschaffungsdatum: "asc" },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label>Bezeichnung *</Label>
<Input {...register("bezeichnung")} placeholder="z.B. Laptop, Firmenwagen, Maschine" />
{errors.bezeichnung && <p className="text-xs text-red-600">{errors.bezeichnung.message}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Anschaffungsdatum *</Label>
<Input {...register("anschaffungsdatum")} type="date" />
{errors.anschaffungsdatum && <p className="text-xs text-red-600">{errors.anschaffungsdatum.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Nutzungsdauer (Jahre) *</Label>
<Input {...register("nutzungsdauerJahre")} type="number" min="1" step="1" placeholder="z.B. 5" />
{errors.nutzungsdauerJahre && <p className="text-xs text-red-600">{errors.nutzungsdauerJahre.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Anschaffungskosten () *</Label>
<Input {...register("anschaffungskosten")} type="number" step="0.01" placeholder="0.00" />
{errors.anschaffungskosten && <p className="text-xs text-red-600">{errors.anschaffungskosten.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Restwert ()</Label>
<Input {...register("restwert")} type="number" step="0.01" min="0" placeholder="0.00" />
{errors.restwert && <p className="text-xs text-red-600">{errors.restwert.message}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label>Beschreibung</Label>
<Textarea {...register("beschreibung")} placeholder="Optionale Anmerkung" rows={2} />
</div>
<div className="flex items-center gap-2">
<input {...register("aktiv")} type="checkbox" id="aktiv" className="rounded border-gray-300" />
<Label htmlFor="aktiv">Aktiv (noch im Betrieb)</Label>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Speichern..." : submitLabel}
</Button>
</div>
</form>
);
return {
companyId: company.id,
companyName: company.name,
initialYear: new Date().getFullYear(),
assets: assets.map((a) => ({
id: a.id,
bezeichnung: a.bezeichnung,
beschreibung: a.beschreibung,
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
anschaffungskosten: Number(a.anschaffungskosten),
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: Number(a.restwert),
aktiv: a.aktiv,
})),
};
}
const statusConfig = {
"aktiv": { label: "aktiv", className: "bg-green-100 text-green-700" },
"vollständig abgeschrieben": { label: "abgeschrieben", className: "bg-gray-100 text-gray-500" },
"inaktiv": { label: "inaktiv", className: "bg-amber-100 text-amber-700" },
};
export default function AnlagevermoegenPage() {
const { companyId, companyName } = useLoaderData<typeof loader>();
const { assets: initialAssets, companyId, companyName, initialYear } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [year, setYear] = useState(new Date().getFullYear());
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [editAsset, setEditAsset] = useState<Asset | null>(null);
const years = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const [year, setYear] = useState(initialYear);
const [assets, setAssets] = useState<Asset[]>(initialAssets);
const [loadingYear, setLoadingYear] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
async function fetchAssets(y = year) {
setLoading(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [form, setForm] = useState(emptyForm);
const years = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() + 2 - i);
async function loadYear(y: number) {
setYear(y);
setLoadingYear(true);
const res = await fetch(`/api/anlagevermoegen?companyId=${companyId}&year=${y}`);
const data = await res.json();
setAssets(data.assets ?? []);
setLoading(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setAssets(data.assets.map((a: any) => ({
id: a.id,
bezeichnung: a.bezeichnung,
beschreibung: a.beschreibung,
anschaffungsdatum: a.anschaffungsdatum,
anschaffungskosten: a.anschaffungskosten,
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: a.restwert,
aktiv: a.aktiv,
})));
setLoadingYear(false);
}
useEffect(() => { fetchAssets(); }, [companyId, year]);
async function handleCreate(data: FormData) {
await fetch("/api/anlagevermoegen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, companyId }),
});
setOpen(false);
fetchAssets();
revalidate();
function openCreate() {
setEditingAsset(null);
setForm(emptyForm);
setDialogOpen(true);
}
async function handleEdit(data: FormData) {
if (!editAsset) return;
await fetch(`/api/anlagevermoegen/${editAsset.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
function openEdit(asset: Asset) {
setEditingAsset(asset);
setForm({
bezeichnung: asset.bezeichnung,
anschaffungsdatum: asset.anschaffungsdatum.slice(0, 10),
anschaffungskosten: String(asset.anschaffungskosten),
nutzungsdauerJahre: String(asset.nutzungsdauerJahre),
restwert: String(asset.restwert),
beschreibung: asset.beschreibung ?? "",
aktiv: asset.aktiv,
});
setEditAsset(null);
fetchAssets();
revalidate();
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
bezeichnung: form.bezeichnung,
anschaffungsdatum: form.anschaffungsdatum,
anschaffungskosten: parseFloat(form.anschaffungskosten),
nutzungsdauerJahre: parseInt(form.nutzungsdauerJahre),
restwert: parseFloat(form.restwert) || 0,
beschreibung: form.beschreibung || undefined,
aktiv: form.aktiv,
};
try {
if (editingAsset) {
await fetch(`/api/anlagevermoegen/${editingAsset.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await fetch("/api/anlagevermoegen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, companyId }),
});
}
setDialogOpen(false);
await loadYear(year);
revalidate();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Anlagegut wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/anlagevermoegen/${id}`, { method: "DELETE" });
fetchAssets();
setDeleting(null);
await loadYear(year);
revalidate();
}
const totalAK = assets.reduce((s, a) => s + a.anschaffungskosten, 0);
const totalBW = assets.filter((a) => a.aktiv).reduce((s, a) => s + a.buchwert, 0);
const totalAfa = assets.reduce((s, a) => s + a.afaJahr, 0);
const enriched = assets.map((a) => enrichAsset(a, year));
const aktiveAnlagen = enriched.filter((a) => a.aktiv && a.statusLabel === "aktiv").length;
const gesamtAfa = enriched.reduce((s, a) => s + a.afaJahr, 0);
const gesamtBuchwert = enriched.reduce((s, a) => s + a.buchwertJahr, 0);
const formValid =
form.bezeichnung.trim().length > 0 &&
form.anschaffungsdatum.length > 0 &&
parseFloat(form.anschaffungskosten) > 0 &&
parseInt(form.nutzungsdauerJahre) >= 1;
return (
<div>
@@ -197,29 +224,26 @@ export default function AnlagevermoegenPage() {
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Anlagevermögen</h1>
<p className="text-gray-500 mt-1">{companyName} · Lineare Abschreibung (AfA)</p>
<p className="text-gray-500 mt-1">
{companyName} · {year}
</p>
</div>
<div className="flex items-center gap-3">
<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-amber-500"
onChange={(e) => loadYear(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-indigo-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-amber-600 hover:bg-amber-700">
<Plus className="h-4 w-4" /> Anlagegut anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neues Anlagegut</DialogTitle>
</DialogHeader>
<AnlagegutForm onSubmit={handleCreate} submitLabel="Anlegen" />
</DialogContent>
</Dialog>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Neues Anlagegut
</Button>
</div>
</div>
@@ -227,140 +251,306 @@ export default function AnlagevermoegenPage() {
<div className="grid grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Anschaffungskosten gesamt</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(totalAK)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Buchwert gesamt ({year})</p>
<p className="text-xl font-bold text-amber-600">{formatCurrency(totalBW)}</p>
<p className="text-xs text-gray-500 mb-1">Aktive Anlagen</p>
<p className="text-xl font-bold text-gray-900">{aktiveAnlagen}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">AfA gesamt {year}</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(totalAfa)}</p>
<p className="text-xl font-bold text-rose-600">{formatCurrency(gesamtAfa)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Gesamter Buchwert</p>
<p className="text-xl font-bold text-indigo-600">{formatCurrency(gesamtBuchwert)}</p>
</CardContent>
</Card>
</div>
{/* Edit Dialog */}
<Dialog open={!!editAsset} onOpenChange={(o) => !o && setEditAsset(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Anlagegut bearbeiten</DialogTitle>
</DialogHeader>
{editAsset && (
<AnlagegutForm
defaultValues={{
bezeichnung: editAsset.bezeichnung,
anschaffungsdatum: editAsset.anschaffungsdatum.slice(0, 10),
anschaffungskosten: editAsset.anschaffungskosten,
nutzungsdauerJahre: editAsset.nutzungsdauerJahre,
restwert: editAsset.restwert,
beschreibung: editAsset.beschreibung ?? undefined,
aktiv: editAsset.aktiv,
}}
onSubmit={handleEdit}
submitLabel="Speichern"
/>
)}
</DialogContent>
</Dialog>
{loading ? (
<div className="text-center text-gray-500 py-12">Lade Anlagevermögen...</div>
) : assets.length === 0 ? (
{/* Tabelle */}
{loadingYear ? (
<div className="flex items-center justify-center py-16 text-gray-400">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Lade Anlagen...
</div>
) : enriched.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Layers className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Noch keine Anlagegüter erfasst</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" /> Erstes Anlagegut anlegen
<CardContent className="py-16 text-center text-gray-400">
<p className="text-sm">Noch keine Anlagegüter erfasst.</p>
<Button variant="outline" className="mt-4" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erstes Anlagegut hinzufügen
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
<th className="px-4 py-3 text-left">Bezeichnung</th>
<th className="px-4 py-3 text-left">Anschaffung</th>
<th className="px-4 py-3 text-right">Anschaffungskosten</th>
<th className="px-4 py-3 text-right">Nutzungsdauer</th>
<th className="px-4 py-3 text-right">AfA {year}</th>
<th className="px-4 py-3 text-right">Buchwert {year}</th>
<th className="px-4 py-3 text-center">Status</th>
<th className="px-4 py-3"></th>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Bezeichnung
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Anschaffung
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
AK
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
ND (J)
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
AfA {year}
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Buchwert 31.12.{year}
</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
Status
</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{assets.map((asset) => {
const s = statusConfig[asset.status];
return (
<tr key={asset.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-slate-800">{asset.bezeichnung}</p>
{asset.beschreibung && (
<p className="text-xs text-slate-400 truncate max-w-xs">{asset.beschreibung}</p>
)}
</td>
<td className="px-4 py-3 text-slate-600 whitespace-nowrap">
{formatDate(asset.anschaffungsdatum)}
</td>
<td className="px-4 py-3 text-right text-slate-800">
{formatCurrency(asset.anschaffungskosten)}
</td>
<td className="px-4 py-3 text-right text-slate-600">
{asset.nutzungsdauerJahre} J.
</td>
<td className="px-4 py-3 text-right text-slate-800">
{asset.afaJahr > 0 ? formatCurrency(asset.afaJahr) : "—"}
</td>
<td className="px-4 py-3 text-right font-medium text-amber-700">
{formatCurrency(asset.buchwert)}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${s.className}`}>
{s.label}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setEditAsset(asset)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(asset.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
);
})}
{enriched.map((asset) => (
<tr key={asset.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5">
<p className="font-medium text-slate-800">{asset.bezeichnung}</p>
{asset.beschreibung && (
<p className="text-xs text-slate-400 truncate max-w-xs">
{asset.beschreibung}
</p>
)}
</td>
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
{new Date(asset.anschaffungsdatum).toLocaleDateString("de-DE")}
</td>
<td className="px-3 py-2.5 text-right text-slate-700 font-medium whitespace-nowrap">
{formatCurrency(asset.anschaffungskosten)}
</td>
<td className="px-3 py-2.5 text-right text-slate-600">
{asset.nutzungsdauerJahre}
</td>
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
{asset.afaJahr > 0 ? (
<span className="text-rose-600">{formatCurrency(asset.afaJahr)}</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3 py-2.5 text-right font-medium text-indigo-700 whitespace-nowrap">
{formatCurrency(asset.buchwertJahr)}
</td>
<td className="px-3 py-2.5 text-center">
<Badge variant={STATUS_VARIANTS[asset.statusLabel] ?? "outline"}>
{asset.statusLabel}
</Badge>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(asset)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(asset.id)}
disabled={deleting === asset.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === asset.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 bg-slate-50">
<td colSpan={2} className="px-4 py-3 font-semibold text-slate-700">Gesamt</td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(totalAK)}</td>
<td></td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(totalAfa)}</td>
<td className="px-4 py-3 text-right font-bold text-amber-700">{formatCurrency(totalBW)}</td>
<td colSpan={2}></td>
<tr className="border-t-2 border-slate-300 bg-slate-50">
<td colSpan={4} className="px-4 py-2.5 text-xs font-bold text-slate-700">
Gesamt
</td>
<td className="px-3 py-2.5 text-right text-xs font-bold text-rose-600">
{formatCurrency(gesamtAfa)}
</td>
<td className="px-3 py-2.5 text-right text-xs font-bold text-indigo-700">
{formatCurrency(gesamtBuchwert)}
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
<div className="px-3 py-2 border-t border-slate-100 text-xs text-slate-400">
AfA: lineare Abschreibung nach §7 EStG · Buchwert zum 31.12. des gewählten Jahres
</div>
</Card>
)}
{/* Dialog: Anlegen / Bearbeiten */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editingAsset ? "Anlagegut bearbeiten" : "Neues Anlagegut"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bezeichnung <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.bezeichnung}
onChange={(e) => setForm((f) => ({ ...f, bezeichnung: e.target.value }))}
placeholder="z.B. Laptop, Firmenwagen, Maschine"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anschaffungsdatum <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.anschaffungsdatum}
onChange={(e) => setForm((f) => ({ ...f, anschaffungsdatum: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nutzungsdauer (Jahre) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="1"
value={form.nutzungsdauerJahre}
onChange={(e) =>
setForm((f) => ({ ...f, nutzungsdauerJahre: e.target.value }))
}
placeholder="z.B. 3"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anschaffungskosten () <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.anschaffungskosten}
onChange={(e) =>
setForm((f) => ({ ...f, anschaffungskosten: e.target.value }))
}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Restwert ()
</label>
<input
type="number"
min="0"
step="0.01"
value={form.restwert}
onChange={(e) => setForm((f) => ({ ...f, restwert: e.target.value }))}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<textarea
rows={2}
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
placeholder="Optionale Notizen"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="aktiv"
checked={form.aktiv}
onChange={(e) => setForm((f) => ({ ...f, aktiv: e.target.checked }))}
className="rounded"
/>
<label htmlFor="aktiv" className="text-sm text-gray-700">
Anlagegut ist aktiv
</label>
</div>
{/* AfA-Vorschau */}
{formValid && (() => {
const raw: AnlagegutRaw = {
anschaffungskosten: parseFloat(form.anschaffungskosten),
nutzungsdauerJahre: parseInt(form.nutzungsdauerJahre),
restwert: parseFloat(form.restwert) || 0,
anschaffungsdatum: form.anschaffungsdatum,
aktiv: form.aktiv,
};
const afa = afaFuerJahr(raw, year);
const bw = buchwert(raw, year);
const jahresAfaVoll =
Math.round(
((raw.anschaffungskosten - raw.restwert) / raw.nutzungsdauerJahre) * 100
) / 100;
return (
<div className="rounded-lg bg-indigo-50 border border-indigo-100 px-3 py-2 text-xs text-indigo-700 space-y-1">
<p>
<strong>Jährliche AfA:</strong> {formatCurrency(jahresAfaVoll)}
</p>
<p>
<strong>AfA {year}:</strong> {formatCurrency(afa)}
</p>
<p>
<strong>Buchwert 31.12.{year}:</strong> {formatCurrency(bw)}
</p>
</div>
);
})()}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleSave} disabled={saving || !formValid}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingAsset ? "Speichern" : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+336 -245
View File
@@ -1,14 +1,15 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import { Link, useLoaderData, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronLeft, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react";
import { formatCurrency } from "@/lib/tax";
import { AUSGABE_KATEGORIEN, KATEGORIE_LABELS, type AusgabeKategorieKey } from "@/lib/ausgaben";
export { KATEGORIE_LABELS };
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
@@ -17,34 +18,30 @@ export const handle = {
],
};
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const STEUERSAETZE = [
{ label: "Keine (0 %)", value: 0 },
{ label: "7 %", value: 7 },
{ label: "19 %", value: 19 },
];
interface Ausgabe {
id: string;
kategorie: AusgabeKategorieKey;
betrag: number;
steuersatz: number;
zahlungsart: "KASSE" | "BANK";
datum: string;
beschreibung: string | null;
}
// { [kategorie]: { [month 1-12]: { ids: string[]; betrag: number } } }
type GridCell = { ids: string[]; betrag: number };
type GridData = Record<string, Record<number, GridCell>>;
function buildGrid(ausgaben: Ausgabe[]): GridData {
const grid: GridData = {};
for (const k of AUSGABE_KATEGORIEN) {
grid[k] = {};
for (let m = 1; m <= 12; m++) grid[k][m] = { ids: [], betrag: 0 };
}
for (const a of ausgaben) {
const month = new Date(a.datum).getMonth() + 1;
if (grid[a.kategorie]?.[month] !== undefined) {
grid[a.kategorie][month].ids.push(a.id);
grid[a.kategorie][month].betrag += a.betrag;
}
}
return grid;
}
const emptyForm = {
kategorie: "SONSTIGER_BETRIEBSBEDARF" as AusgabeKategorieKey,
betrag: "",
steuersatz: 19,
zahlungsart: "BANK" as "KASSE" | "BANK",
datum: new Date().toISOString().slice(0, 10),
beschreibung: "",
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
@@ -60,7 +57,7 @@ export async function loader({ request, params }: { request: Request; params: {
companyId: params.id,
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
},
orderBy: { datum: "asc" },
orderBy: { datum: "desc" },
});
return {
@@ -71,148 +68,110 @@ export async function loader({ request, params }: { request: Request; params: {
id: a.id,
kategorie: a.kategorie as AusgabeKategorieKey,
betrag: Number(a.betrag),
steuersatz: Number(a.steuersatz),
zahlungsart: a.zahlungsart as "KASSE" | "BANK",
datum: a.datum.toISOString(),
beschreibung: a.beschreibung,
})),
};
}
export default function AusgabenPage() {
const { ausgaben: initialAusgaben, companyId, companyName, initialYear } = useLoaderData<typeof loader>();
const { ausgaben: initialAusgaben, companyId, companyName, initialYear } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [year, setYear] = useState(initialYear);
const [grid, setGrid] = useState<GridData>(() => buildGrid(initialAusgaben));
const [ausgaben, setAusgaben] = useState<Ausgabe[]>(initialAusgaben);
const [loadingYear, setLoadingYear] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [editingCell, setEditingCell] = useState<{ kategorie: string; month: number } | null>(null);
const [cellInput, setCellInput] = useState("");
const [savingCell, setSavingCell] = useState<{ kategorie: string; month: number } | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
async function loadYear(y: number) {
setEditingCell(null);
setYear(y);
setLoadingYear(true);
const res = await fetch(`/api/ausgaben?companyId=${companyId}&year=${y}`);
const data: Ausgabe[] = await res.json();
setGrid(buildGrid(data));
setAusgaben(data);
setLoadingYear(false);
}
function startEdit(kategorie: string, month: number) {
if (savingCell) return;
const cell = grid[kategorie]?.[month];
setEditingCell({ kategorie, month });
setCellInput(cell?.betrag ? String(cell.betrag) : "");
function openCreate() {
setEditingId(null);
setForm({ ...emptyForm, datum: `${year}-01-01` });
setDialogOpen(true);
}
const commitCell = useCallback(async () => {
if (!editingCell) return;
const { kategorie, month } = editingCell;
setEditingCell(null);
const newBetrag = parseFloat(cellInput.replace(",", ".")) || 0;
const cell = grid[kategorie]?.[month];
const oldBetrag = cell?.betrag ?? 0;
if (newBetrag === oldBetrag) return;
setSavingCell({ kategorie, month });
function openEdit(a: Ausgabe) {
setEditingId(a.id);
setForm({
kategorie: a.kategorie,
betrag: String(a.betrag),
steuersatz: a.steuersatz,
zahlungsart: a.zahlungsart,
datum: a.datum.slice(0, 10),
beschreibung: a.beschreibung ?? "",
});
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
kategorie: form.kategorie,
betrag: parseFloat(form.betrag),
steuersatz: form.steuersatz,
zahlungsart: form.zahlungsart,
datum: form.datum,
beschreibung: form.beschreibung || undefined,
};
try {
if (newBetrag <= 0 && cell?.ids.length) {
// Löschen aller Records für diese Zelle
await Promise.all(
cell.ids.map((id) => fetch(`/api/ausgaben/${id}`, { method: "DELETE" }))
);
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [], betrag: 0 } };
return next;
});
} else if (newBetrag > 0 && cell?.ids.length === 1) {
// Update des bestehenden Records
await fetch(`/api/ausgaben/${cell.ids[0]}`, {
if (editingId) {
await fetch(`/api/ausgaben/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
body: JSON.stringify(payload),
});
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: cell.ids, betrag: newBetrag } };
return next;
});
} else if (newBetrag > 0 && (cell?.ids.length ?? 0) > 1) {
// Mehrere Records → alle löschen, einen neuen anlegen
await Promise.all(
cell!.ids.map((id) => fetch(`/api/ausgaben/${id}`, { method: "DELETE" }))
);
const res = await fetch("/api/ausgaben", {
} else {
await fetch("/api/ausgaben", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId,
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
const created: Ausgabe = await res.json();
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } };
return next;
});
} else if (newBetrag > 0) {
// Neuer Record
const res = await fetch("/api/ausgaben", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId,
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
const created: Ausgabe = await res.json();
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } };
return next;
body: JSON.stringify({ ...payload, companyId }),
});
}
setDialogOpen(false);
await loadYear(year);
revalidate();
} finally {
setSavingCell(null);
}
}, [editingCell, cellInput, grid, companyId, year, revalidate]);
// Berechnungen
const rowTotals: Record<string, number> = {};
const colTotals: Record<number, number> = {};
let grandTotal = 0;
for (const k of AUSGABE_KATEGORIEN) {
rowTotals[k] = 0;
for (let m = 1; m <= 12; m++) {
const b = grid[k]?.[m]?.betrag ?? 0;
rowTotals[k] += b;
colTotals[m] = (colTotals[m] ?? 0) + b;
grandTotal += b;
setSaving(false);
}
}
const topKategorien = [...AUSGABE_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
async function handleDelete(id: string) {
if (!confirm("Eintrag wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/ausgaben/${id}`, { method: "DELETE" });
setDeleting(null);
await loadYear(year);
revalidate();
}
// Berechnungen
const gesamt = ausgaben.reduce((s, a) => s + a.betrag, 0);
const kasseGesamt = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + a.betrag, 0);
const bankGesamt = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + a.betrag, 0);
const vorstGesamt = ausgaben.reduce((s, a) => {
const rate = a.steuersatz / 100;
return s + (rate > 0 ? Math.round((a.betrag / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
return (
<div>
@@ -228,13 +187,19 @@ export default function AusgabenPage() {
<h1 className="text-2xl font-bold text-gray-900">Betriebsausgaben</h1>
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
</div>
<select
value={year}
onChange={(e) => loadYear(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-rose-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<div className="flex items-center gap-3">
<select
value={year}
onChange={(e) => loadYear(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-rose-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<Button onClick={openCreate} className="bg-rose-600 hover:bg-rose-700">
<Plus className="h-4 w-4" />
Neue Ausgabe
</Button>
</div>
</div>
{/* Zusammenfassung */}
@@ -242,145 +207,271 @@ export default function AusgabenPage() {
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
<p className="text-xl font-bold text-rose-600">{formatCurrency(grandTotal)}</p>
<p className="text-xl font-bold text-rose-600">{formatCurrency(gesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Kategorien mit Einträgen</p>
<p className="text-xl font-bold text-gray-900">
{AUSGABE_KATEGORIEN.filter((k) => rowTotals[k] > 0).length}
</p>
<div className="flex items-center gap-1 mb-1">
<Landmark className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500">Bank</p>
</div>
<p className="text-xl font-bold text-gray-900">{formatCurrency(bankGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<div className="flex items-center gap-1 mb-1">
<Banknote className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500">Kasse</p>
</div>
<p className="text-xl font-bold text-gray-900">{formatCurrency(kasseGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Vorsteuer (enthalten)</p>
<p className="text-xl font-bold text-amber-600">{formatCurrency(vorstGesamt)}</p>
</CardContent>
</Card>
{topKategorien.map((k) => (
<Card key={k}>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1 truncate">{KATEGORIE_LABELS[k]}</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(rowTotals[k])}</p>
</CardContent>
</Card>
))}
{topKategorien.length < 2 && Array.from({ length: 2 - topKategorien.length }).map((_, i) => (
<div key={i} />
))}
</div>
{/* Matrix-Tabelle */}
{/* Liste */}
{loadingYear ? (
<div className="flex items-center justify-center py-16 text-gray-400">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Lade Ausgaben...
</div>
) : ausgaben.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-gray-400">
<p className="text-sm">Noch keine Ausgaben für {year} erfasst.</p>
<Button variant="outline" className="mt-4" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erste Ausgabe hinzufügen
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed border-collapse">
<colgroup>
<col style={{ width: "180px" }} />
{MONTHS.map((_, i) => <col key={i} style={{ width: "72px" }} />)}
<col style={{ width: "88px" }} />
</colgroup>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Kategorie
</th>
{MONTHS.map((m) => (
<th key={m} className="px-1 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
{m}
</th>
))}
<th className="px-2 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Gesamt
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Datum</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Kategorie</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Brutto</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">MwSt.</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Netto</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">Zahlung</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Notiz</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{AUSGABE_KATEGORIEN.map((kat) => (
<tr key={kat} className="hover:bg-slate-50/60 group">
<td className="px-3 py-1.5 text-xs font-medium text-slate-700 truncate">
{KATEGORIE_LABELS[kat]}
</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
const cell = grid[kat]?.[month];
const betrag = cell?.betrag ?? 0;
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
return (
<td
key={month}
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-indigo-50" : ""}`}
>
{isSaving ? (
<span className="flex justify-end pr-1">
<Loader2 className="h-3 w-3 animate-spin text-slate-400" />
</span>
) : isEditing ? (
<input
type="number"
step="0.01"
min="0"
autoFocus
value={cellInput}
onChange={(e) => setCellInput(e.target.value)}
onBlur={commitCell}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
if (e.key === "Escape") setEditingCell(null);
}}
className="w-full text-right text-sm border border-indigo-300 rounded px-1 py-0.5 focus:outline-none focus:ring-1 focus:ring-indigo-400"
/>
) : (
<button
type="button"
onClick={() => startEdit(kat, month)}
disabled={!!savingCell}
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
${betrag > 0
? "text-slate-800 font-medium"
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
hover:bg-indigo-50 focus:outline-none focus:ring-1 focus:ring-indigo-300`}
>
{betrag > 0
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</button>
)}
</td>
);
})}
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-rose-600" : "text-slate-300"}`}>
{rowTotals[kat] > 0
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</td>
</tr>
))}
<tbody className="divide-y divide-slate-100">
{ausgaben.map((a) => {
const rate = a.steuersatz / 100;
const netto = rate > 0 ? Math.round((a.betrag / (1 + rate)) * 100) / 100 : a.betrag;
return (
<tr key={a.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{new Date(a.datum).toLocaleDateString("de-DE")}
</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">
{KATEGORIE_LABELS[a.kategorie]}
</td>
<td className="px-3 py-2.5 text-right font-medium text-rose-700 whitespace-nowrap">
{formatCurrency(a.betrag)}
</td>
<td className="px-3 py-2.5 text-center">
{a.steuersatz > 0 ? (
<Badge variant="secondary">{a.steuersatz} %</Badge>
) : (
<span className="text-slate-300 text-xs"></span>
)}
</td>
<td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
{formatCurrency(netto)}
</td>
<td className="px-3 py-2.5 text-center">
{a.zahlungsart === "BANK" ? (
<span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
<Landmark className="h-3 w-3" /> Bank
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
<Banknote className="h-3 w-3" /> Kasse
</span>
)}
</td>
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
{a.beschreibung ?? ""}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(a)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(a.id)}
disabled={deleting === a.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === a.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-300 bg-slate-50">
<td className="px-3 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<td key={month} className={`px-1 py-2.5 text-right text-xs font-bold ${colTotals[month] > 0 ? "text-slate-800" : "text-slate-300"}`}>
{colTotals[month] > 0
? colTotals[month].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</td>
))}
<td className="px-2 py-2.5 text-right text-xs font-bold text-rose-600">
{grandTotal.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<td colSpan={2} className="px-4 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
<td className="px-3 py-2.5 text-right text-xs font-bold text-rose-600">{formatCurrency(gesamt)}</td>
<td />
<td className="px-3 py-2.5 text-right text-xs font-bold text-slate-600">
{formatCurrency(gesamt - vorstGesamt)}
</td>
<td colSpan={3} />
</tr>
</tfoot>
</table>
</div>
<div className="px-3 py-2 border-t border-slate-100 text-xs text-slate-400">
Zelle anklicken zum Bearbeiten · Enter speichern · Escape abbrechen · 0 löscht den Eintrag
</div>
</Card>
)}
{/* Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Ausgabe bearbeiten" : "Neue Ausgabe"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betrag (brutto, ) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.betrag}
onChange={(e) => setForm((f) => ({ ...f, betrag: e.target.value }))}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategorie <span className="text-red-500">*</span>
</label>
<select
value={form.kategorie}
onChange={(e) => setForm((f) => ({ ...f, kategorie: e.target.value as AusgabeKategorieKey }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
>
{AUSGABE_KATEGORIEN.map((k) => (
<option key={k} value={k}>{KATEGORIE_LABELS[k]}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zahlungsweg</label>
<div className="flex gap-2">
{(["BANK", "KASSE"] as const).map((za) => (
<button
key={za}
type="button"
onClick={() => setForm((f) => ({ ...f, zahlungsart: za }))}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border text-sm font-medium transition-colors
${form.zahlungsart === za
? za === "BANK"
? "bg-blue-50 border-blue-300 text-blue-700"
: "bg-amber-50 border-amber-300 text-amber-700"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{za === "BANK" ? <Landmark className="h-3.5 w-3.5" /> : <Banknote className="h-3.5 w-3.5" />}
{za === "BANK" ? "Bank" : "Kasse"}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Steuersatz</label>
<select
value={form.steuersatz}
onChange={(e) => setForm((f) => ({ ...f, steuersatz: Number(e.target.value) }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
>
{STEUERSAETZE.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* Vorschau Nettobetrag */}
{parseFloat(form.betrag) > 0 && form.steuersatz > 0 && (
<div className="rounded-lg bg-rose-50 border border-rose-100 px-3 py-2 text-xs text-rose-700 space-y-0.5">
<p><strong>Brutto:</strong> {formatCurrency(parseFloat(form.betrag))}</p>
<p><strong>Vorsteuer ({form.steuersatz} %):</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * (form.steuersatz / 100) * 100) / 100)}</p>
<p><strong>Netto:</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * 100) / 100)}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz</label>
<input
type="text"
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
placeholder="Optional"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
onClick={handleSave}
disabled={saving || !formValid}
className="bg-rose-600 hover:bg-rose-700"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingId ? "Speichern" : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+26 -8
View File
@@ -4,7 +4,7 @@ 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 { ChevronLeft, Scale, TrendingUp, Info, Banknote, Landmark } from "lucide-react";
import { KATEGORIE_LABELS } from "@/lib/ausgaben";
export const handle = {
@@ -41,14 +41,17 @@ interface BilanzenData {
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: {
@@ -187,7 +190,10 @@ export default function BilanzenPage() {
{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="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>
)}
@@ -208,6 +214,9 @@ export default function BilanzenPage() {
) : (
<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>
@@ -252,17 +261,26 @@ export default function BilanzenPage() {
value={data.bilanz.aktiva.forderungen.betrag}
indent
/>
<Row
label={`Bank / Kasse (${data.bilanz.aktiva.bank.anzahl} bezahlt)`}
value={data.bilanz.aktiva.bank.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/Kasse ist eine Näherung auf Basis bezahlter Rechnungen (kumuliert bis Jahresende).
Bank enthält bezahlte Rechnungen + sonstige Bankeinnahmen abzgl. Bankausgaben. Kasse = sonstige Kasseneinnahmen abzgl. Kassenausgaben.
</p>
</div>
</CardContent>
+336 -269
View File
@@ -1,9 +1,12 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import { Link, useLoaderData, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronLeft, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react";
import { formatCurrency } from "@/lib/tax";
import { EINNAHME_KATEGORIEN, EINNAHME_LABELS, type EinnahmeKategorieKey } from "@/lib/einnahmen";
@@ -15,53 +18,31 @@ export const handle = {
],
};
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const STEUERSAETZE = [
{ label: "Keine (0 %)", value: 0 },
{ label: "7 %", value: 7 },
{ label: "19 %", value: 19 },
];
interface Einnahme {
id: string;
kategorie: EinnahmeKategorieKey;
betrag: number;
steuersatz: number;
zahlungsart: "KASSE" | "BANK";
datum: string;
beschreibung: string | null;
}
type GridCell = { ids: string[]; betrag: number };
type GridData = Record<string, Record<number, GridCell>>;
const emptyForm = {
kategorie: "SONSTIGE_EINNAHMEN" as EinnahmeKategorieKey,
betrag: "",
steuersatz: 0,
zahlungsart: "BANK" as "KASSE" | "BANK",
datum: new Date().toISOString().slice(0, 10),
beschreibung: "",
};
/**
* Builds a grid data structure from the given einnahmen array.
* The grid has the shape of { [kategorie]: { [month]: { ids: string[]; betrag: number } } }
* where each month has a list of einnahmen ids and the sum of their betrage.
*
* @param {Einnahme[]} einnahmen - The array of einnahmen to build the grid from.
* @returns {GridData} - The built grid data structure.
*/
function buildGrid(einnahmen: Einnahme[]): GridData {
const grid: GridData = {};
for (const k of EINNAHME_KATEGORIEN) {
grid[k] = {};
for (let m = 1; m <= 12; m++) grid[k][m] = { ids: [], betrag: 0 };
}
for (const e of einnahmen) {
const month = new Date(e.datum).getMonth() + 1;
if (grid[e.kategorie]?.[month] !== undefined) {
grid[e.kategorie][month].ids.push(e.id);
grid[e.kategorie][month].betrag += e.betrag;
}
}
return grid;
}
/**
* Loads the data for the EinnahmenPage.
*
* @param {Request} request - The request object.
* @param {Object} params - The route parameters.
* @param {string} params.id - The id of the company.
*
* @returns {Promise<Object>} - A promise resolving to an object containing the company data and the initial year.
*
* @throws {Response} - If the company is not found.
*/
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
@@ -76,7 +57,7 @@ export async function loader({ request, params }: { request: Request; params: {
companyId: params.id,
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
},
orderBy: { datum: "asc" },
orderBy: { datum: "desc" },
});
return {
@@ -87,156 +68,110 @@ export async function loader({ request, params }: { request: Request; params: {
id: e.id,
kategorie: e.kategorie as EinnahmeKategorieKey,
betrag: Number(e.betrag),
steuersatz: Number(e.steuersatz),
zahlungsart: e.zahlungsart as "KASSE" | "BANK",
datum: e.datum.toISOString(),
beschreibung: e.beschreibung,
})),
};
}
/**
* The EinnahmenPage component displays a table of the company's expenses
* for the selected year. It allows the user to edit the expenses and save
* the changes.
*
* @returns {JSX.Element} - The EinnahmenPage component.
*/
export default function EinnahmenPage() {
const { einnahmen: initialEinnahmen, companyId, companyName, initialYear } = useLoaderData<typeof loader>();
const { einnahmen: initialEinnahmen, companyId, companyName, initialYear } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [year, setYear] = useState(initialYear);
const [grid, setGrid] = useState<GridData>(() => buildGrid(initialEinnahmen));
const [einnahmen, setEinnahmen] = useState<Einnahme[]>(initialEinnahmen);
const [loadingYear, setLoadingYear] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [editingCell, setEditingCell] = useState<{ kategorie: string; month: number } | null>(null);
const [cellInput, setCellInput] = useState("");
const [savingCell, setSavingCell] = useState<{ kategorie: string; month: number } | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
/**
* Load the expenses for the given year.
*
* @param {number} y - The year to load the expenses for.
*/
async function loadYear(y: number) {
setEditingCell(null);
setYear(y);
setLoadingYear(true);
const res = await fetch(`/api/einnahmen?companyId=${companyId}&year=${y}`);
const data: Einnahme[] = await res.json();
setGrid(buildGrid(data));
setEinnahmen(data);
setLoadingYear(false);
}
function startEdit(kategorie: string, month: number) {
if (savingCell) return;
const cell = grid[kategorie]?.[month];
setEditingCell({ kategorie, month });
setCellInput(cell?.betrag ? String(cell.betrag) : "");
function openCreate() {
setEditingId(null);
setForm({ ...emptyForm, datum: `${year}-01-01` });
setDialogOpen(true);
}
const commitCell = useCallback(async () => {
if (!editingCell) return;
const { kategorie, month } = editingCell;
setEditingCell(null);
const newBetrag = parseFloat(cellInput.replace(",", ".")) || 0;
const cell = grid[kategorie]?.[month];
const oldBetrag = cell?.betrag ?? 0;
if (newBetrag === oldBetrag) return;
setSavingCell({ kategorie, month });
function openEdit(e: Einnahme) {
setEditingId(e.id);
setForm({
kategorie: e.kategorie,
betrag: String(e.betrag),
steuersatz: e.steuersatz,
zahlungsart: e.zahlungsart,
datum: e.datum.slice(0, 10),
beschreibung: e.beschreibung ?? "",
});
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
kategorie: form.kategorie,
betrag: parseFloat(form.betrag),
steuersatz: form.steuersatz,
zahlungsart: form.zahlungsart,
datum: form.datum,
beschreibung: form.beschreibung || undefined,
};
try {
if (newBetrag <= 0 && cell?.ids.length) {
await Promise.all(
cell.ids.map((id) => fetch(`/api/einnahmen/${id}`, { method: "DELETE" }))
);
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [], betrag: 0 } };
return next;
});
} else if (newBetrag > 0 && cell?.ids.length === 1) {
await fetch(`/api/einnahmen/${cell.ids[0]}`, {
if (editingId) {
await fetch(`/api/einnahmen/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
body: JSON.stringify(payload),
});
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: cell.ids, betrag: newBetrag } };
return next;
});
} else if (newBetrag > 0 && (cell?.ids.length ?? 0) > 1) {
await Promise.all(
cell!.ids.map((id) => fetch(`/api/einnahmen/${id}`, { method: "DELETE" }))
);
const res = await fetch("/api/einnahmen", {
} else {
await fetch("/api/einnahmen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId,
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
const created: Einnahme = await res.json();
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } };
return next;
});
} else if (newBetrag > 0) {
const res = await fetch("/api/einnahmen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId,
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
const created: Einnahme = await res.json();
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } };
return next;
body: JSON.stringify({ ...payload, companyId }),
});
}
setDialogOpen(false);
await loadYear(year);
revalidate();
} finally {
setSavingCell(null);
}
}, [editingCell, cellInput, grid, companyId, year, revalidate]);
// Berechnungen
const rowTotals: Record<string, number> = {};
const colTotals: Record<number, number> = {};
let grandTotal = 0;
for (const k of EINNAHME_KATEGORIEN) {
rowTotals[k] = 0;
for (let m = 1; m <= 12; m++) {
const b = grid[k]?.[m]?.betrag ?? 0;
rowTotals[k] += b;
colTotals[m] = (colTotals[m] ?? 0) + b;
grandTotal += b;
setSaving(false);
}
}
const topKategorien = [...EINNAHME_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
async function handleDelete(id: string) {
if (!confirm("Eintrag wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/einnahmen/${id}`, { method: "DELETE" });
setDeleting(null);
await loadYear(year);
revalidate();
}
// Berechnungen
const gesamt = einnahmen.reduce((s, e) => s + e.betrag, 0);
const kasseGesamt = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + e.betrag, 0);
const bankGesamt = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + e.betrag, 0);
const ustGesamt = einnahmen.reduce((s, e) => {
const rate = e.steuersatz / 100;
return s + (rate > 0 ? Math.round((e.betrag / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
return (
<div>
@@ -252,13 +187,19 @@ export default function EinnahmenPage() {
<h1 className="text-2xl font-bold text-gray-900">Sonstige Einnahmen</h1>
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
</div>
<select
value={year}
onChange={(e) => loadYear(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-emerald-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<div className="flex items-center gap-3">
<select
value={year}
onChange={(e) => loadYear(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-emerald-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<Button onClick={openCreate} className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="h-4 w-4" />
Neue Einnahme
</Button>
</div>
</div>
{/* Zusammenfassung */}
@@ -266,145 +207,271 @@ export default function EinnahmenPage() {
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
<p className="text-xl font-bold text-emerald-600">{formatCurrency(grandTotal)}</p>
<p className="text-xl font-bold text-emerald-600">{formatCurrency(gesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Kategorien mit Einträgen</p>
<p className="text-xl font-bold text-gray-900">
{EINNAHME_KATEGORIEN.filter((k) => rowTotals[k] > 0).length}
</p>
<div className="flex items-center gap-1 mb-1">
<Landmark className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500">Bank</p>
</div>
<p className="text-xl font-bold text-gray-900">{formatCurrency(bankGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<div className="flex items-center gap-1 mb-1">
<Banknote className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500">Kasse</p>
</div>
<p className="text-xl font-bold text-gray-900">{formatCurrency(kasseGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Umsatzsteuer (enthalten)</p>
<p className="text-xl font-bold text-amber-600">{formatCurrency(ustGesamt)}</p>
</CardContent>
</Card>
{topKategorien.map((k) => (
<Card key={k}>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1 truncate">{EINNAHME_LABELS[k]}</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(rowTotals[k])}</p>
</CardContent>
</Card>
))}
{topKategorien.length < 2 && Array.from({ length: 2 - topKategorien.length }).map((_, i) => (
<div key={i} />
))}
</div>
{/* Matrix-Tabelle */}
{/* Liste */}
{loadingYear ? (
<div className="flex items-center justify-center py-16 text-gray-400">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Lade Einnahmen...
</div>
) : einnahmen.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-gray-400">
<p className="text-sm">Noch keine Einnahmen für {year} erfasst.</p>
<Button variant="outline" className="mt-4" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erste Einnahme hinzufügen
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed border-collapse">
<colgroup>
<col style={{ width: "180px" }} />
{MONTHS.map((_, i) => <col key={i} style={{ width: "72px" }} />)}
<col style={{ width: "88px" }} />
</colgroup>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Kategorie
</th>
{MONTHS.map((m) => (
<th key={m} className="px-1 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
{m}
</th>
))}
<th className="px-2 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Gesamt
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Datum</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Kategorie</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Brutto</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">MwSt.</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Netto</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">Zahlung</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Notiz</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{EINNAHME_KATEGORIEN.map((kat) => (
<tr key={kat} className="hover:bg-slate-50/60 group">
<td className="px-3 py-1.5 text-xs font-medium text-slate-700 truncate">
{EINNAHME_LABELS[kat]}
</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
const cell = grid[kat]?.[month];
const betrag = cell?.betrag ?? 0;
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
return (
<td
key={month}
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-emerald-50" : ""}`}
>
{isSaving ? (
<span className="flex justify-end pr-1">
<Loader2 className="h-3 w-3 animate-spin text-slate-400" />
</span>
) : isEditing ? (
<input
type="number"
step="0.01"
min="0"
autoFocus
value={cellInput}
onChange={(e) => setCellInput(e.target.value)}
onBlur={commitCell}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
if (e.key === "Escape") setEditingCell(null);
}}
className="w-full text-right text-sm border border-emerald-300 rounded px-1 py-0.5 focus:outline-none focus:ring-1 focus:ring-emerald-400"
/>
) : (
<button
type="button"
onClick={() => startEdit(kat, month)}
disabled={!!savingCell}
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
${betrag > 0
? "text-slate-800 font-medium"
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
hover:bg-emerald-50 focus:outline-none focus:ring-1 focus:ring-emerald-300`}
>
{betrag > 0
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</button>
)}
</td>
);
})}
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-emerald-600" : "text-slate-300"}`}>
{rowTotals[kat] > 0
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</td>
</tr>
))}
<tbody className="divide-y divide-slate-100">
{einnahmen.map((e) => {
const rate = e.steuersatz / 100;
const netto = rate > 0 ? Math.round((e.betrag / (1 + rate)) * 100) / 100 : e.betrag;
return (
<tr key={e.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{new Date(e.datum).toLocaleDateString("de-DE")}
</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">
{EINNAHME_LABELS[e.kategorie]}
</td>
<td className="px-3 py-2.5 text-right font-medium text-emerald-700 whitespace-nowrap">
{formatCurrency(e.betrag)}
</td>
<td className="px-3 py-2.5 text-center">
{e.steuersatz > 0 ? (
<Badge variant="secondary">{e.steuersatz} %</Badge>
) : (
<span className="text-slate-300 text-xs"></span>
)}
</td>
<td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
{formatCurrency(netto)}
</td>
<td className="px-3 py-2.5 text-center">
{e.zahlungsart === "BANK" ? (
<span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
<Landmark className="h-3 w-3" /> Bank
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
<Banknote className="h-3 w-3" /> Kasse
</span>
)}
</td>
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
{e.beschreibung ?? ""}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(e)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(e.id)}
disabled={deleting === e.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === e.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-300 bg-slate-50">
<td className="px-3 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<td key={month} className={`px-1 py-2.5 text-right text-xs font-bold ${colTotals[month] > 0 ? "text-slate-800" : "text-slate-300"}`}>
{colTotals[month] > 0
? colTotals[month].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</td>
))}
<td className="px-2 py-2.5 text-right text-xs font-bold text-emerald-600">
{grandTotal.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<td colSpan={2} className="px-4 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
<td className="px-3 py-2.5 text-right text-xs font-bold text-emerald-600">{formatCurrency(gesamt)}</td>
<td />
<td className="px-3 py-2.5 text-right text-xs font-bold text-slate-600">
{formatCurrency(gesamt - ustGesamt)}
</td>
<td colSpan={3} />
</tr>
</tfoot>
</table>
</div>
<div className="px-3 py-2 border-t border-slate-100 text-xs text-slate-400">
Zelle anklicken zum Bearbeiten · Enter speichern · Escape abbrechen · 0 löscht den Eintrag
</div>
</Card>
)}
{/* Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Einnahme bearbeiten" : "Neue Einnahme"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betrag (brutto, ) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.betrag}
onChange={(e) => setForm((f) => ({ ...f, betrag: e.target.value }))}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategorie <span className="text-red-500">*</span>
</label>
<select
value={form.kategorie}
onChange={(e) => setForm((f) => ({ ...f, kategorie: e.target.value as EinnahmeKategorieKey }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
{EINNAHME_KATEGORIEN.map((k) => (
<option key={k} value={k}>{EINNAHME_LABELS[k]}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zahlungsweg</label>
<div className="flex gap-2">
{(["BANK", "KASSE"] as const).map((za) => (
<button
key={za}
type="button"
onClick={() => setForm((f) => ({ ...f, zahlungsart: za }))}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border text-sm font-medium transition-colors
${form.zahlungsart === za
? za === "BANK"
? "bg-blue-50 border-blue-300 text-blue-700"
: "bg-amber-50 border-amber-300 text-amber-700"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{za === "BANK" ? <Landmark className="h-3.5 w-3.5" /> : <Banknote className="h-3.5 w-3.5" />}
{za === "BANK" ? "Bank" : "Kasse"}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Steuersatz</label>
<select
value={form.steuersatz}
onChange={(e) => setForm((f) => ({ ...f, steuersatz: Number(e.target.value) }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
{STEUERSAETZE.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* Vorschau Nettobetrag */}
{parseFloat(form.betrag) > 0 && form.steuersatz > 0 && (
<div className="rounded-lg bg-emerald-50 border border-emerald-100 px-3 py-2 text-xs text-emerald-700 space-y-0.5">
<p><strong>Brutto:</strong> {formatCurrency(parseFloat(form.betrag))}</p>
<p><strong>USt. ({form.steuersatz} %):</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * (form.steuersatz / 100) * 100) / 100)}</p>
<p><strong>Netto:</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * 100) / 100)}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz</label>
<input
type="text"
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
placeholder="Optional"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
onClick={handleSave}
disabled={saving || !formValid}
className="bg-emerald-600 hover:bg-emerald-700"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingId ? "Speichern" : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+370
View File
@@ -0,0 +1,370 @@
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { ChevronLeft, Loader2, Plus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { formatCurrency } from "@/lib/tax";
import { useLoaderData, Link, useRevalidator } from "react-router";
type Transaction = {
id: string;
date: string;
account: 'kasse' | 'bank';
type: 'einlage' | 'entnahme';
amount: number;
description: string;
};
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("Company not Found", { status: 404 });
const buchungen = await prisma.buchung.findMany({
where: { companyId: company.id },
orderBy: { date: 'desc' },
});
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
id: b.id,
date: b.date.toISOString().split('T')[0],
account: b.account === 'BANK' ? 'bank' : 'kasse',
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
amount: Number(b.amount),
description: b.description || '',
}));
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
return {
companyId: company.id,
companyName: company.name,
transactions,
balance,
};
}
export default function CompanyMoney() {
const { transactions: initialTransactions, companyId, companyName, balance } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
account: 'kasse' as 'kasse' | 'bank',
type: 'einlage' as 'einlage' | 'entnahme',
amount: '',
description: '',
});
function openCreate() {
setEditingTransaction(null);
setForm({
date: new Date().toISOString().split('T')[0],
account: 'kasse',
type: 'einlage',
amount: '',
description: '',
});
setDialogOpen(true);
}
function openEdit(transaction: Transaction) {
setEditingTransaction(transaction);
setForm({
date: transaction.date,
account: transaction.account,
type: transaction.type,
amount: String(transaction.amount),
description: transaction.description,
});
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
date: form.date,
account: form.account,
type: form.type,
amount: parseFloat(form.amount),
description: form.description,
};
try {
if (editingTransaction) {
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await fetch(`/api/companies/${companyId}/money`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
setDialogOpen(false);
revalidate();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Transaktion wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/companies/${companyId}/money?transactionId=${id}`, { method: "DELETE" });
setDeleting(null);
revalidate();
}
const sortedTransactions = [...initialTransactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const kasseBalance = initialTransactions
.filter((t) => t.account === 'kasse')
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
const bankBalance = initialTransactions
.filter((t) => t.account === 'bank')
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
const formValid = form.date && form.amount && parseFloat(form.amount) > 0;
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">Kasse und Bank</h1>
<p className="text-gray-500 mt-1">
{companyName}
</p>
</div>
<div className="flex items-center gap-3">
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Neue Transaktion
</Button>
</div>
</div>
{/* Zusammenfassung */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Kasse (Saldo)</p>
<p className="text-xl font-bold text-indigo-700">{formatCurrency(kasseBalance)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Bank (Saldo)</p>
<p className="text-xl font-bold text-teal-700">{formatCurrency(bankBalance)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Gesamter Kontostand</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(balance)}</p>
</CardContent>
</Card>
</div>
{/* Tabelle */}
{sortedTransactions.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-gray-400">
<p className="text-sm">Noch keine Transaktionen erfasst.</p>
<Button variant="outline" className="mt-4" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erste Transaktion hinzufügen
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Datum
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Konto
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Typ
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Beschreibung
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Betrag
</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedTransactions.map((transaction) => (
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.date}
</td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
</td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
</Badge>
</td>
<td className="px-3 py-2.5 text-slate-700">
{transaction.description}
</td>
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
</span>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(transaction)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(transaction.id)}
disabled={deleting === transaction.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === transaction.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Dialog: Anlegen / Bearbeiten */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editingTransaction ? "Transaktion bearbeiten" : "Neue Transaktion"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konto <span className="text-red-500">*</span>
</label>
<select
value={form.account}
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="kasse">Kasse</option>
<option value="bank">Bank</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ <span className="text-red-500">*</span>
</label>
<select
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="einlage">Einlage</option>
<option value="entnahme">Entnahme</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betrag () <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.amount}
onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="z.B. Barentnahme, Gehalt"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleSave} disabled={saving || !formValid}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingTransaction ? "Speichern" : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+21 -1
View File
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import {
FileText, Users, BarChart3, Plus, Edit, Building2,
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase, Scale, TrendingDown, TrendingUp
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase, Scale, TrendingDown, TrendingUp, PackageSearch, DollarSign
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
import { useState } from "react";
@@ -263,6 +263,26 @@ export default function CompanyPage() {
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/anlagevermoegen`} className="block">
<Card className="hover:border-violet-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-violet-50">
<PackageSearch className="h-4 w-4 text-violet-600" />
</div>
<span className="text-sm font-medium text-gray-700">Anlagevermögen</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/money`} className="block">
<Card className="hover:border-cyan-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-cyan-50">
<DollarSign className="h-4 w-4 text-cyan-600" />
</div>
<span className="text-sm font-medium text-gray-700">Finanzmittel</span>
</CardContent>
</Card>
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">