diff --git a/.react-router/types/+routes.ts b/.react-router/types/+routes.ts index 0125122..c88e172 100644 --- a/.react-router/types/+routes.ts +++ b/.react-router/types/+routes.ts @@ -83,11 +83,21 @@ type Pages = { "id": string; }; }; + "/companies/:id/ausgaben/kategorien": { + params: { + "id": string; + }; + }; "/companies/:id/einnahmen": { params: { "id": string; }; }; + "/companies/:id/einnahmen/kategorien": { + params: { + "id": string; + }; + }; "/companies/:id/anlagevermoegen": { params: { "id": string; @@ -200,6 +210,17 @@ type Pages = { "id": string; }; }; + "/api/companies/:id/buchungkategorien": { + params: { + "id": string; + }; + }; + "/api/companies/:id/buchungkategorien/:katId": { + params: { + "id": string; + "katId": string; + }; + }; "/api/anlagevermoegen": { params: {}; }; @@ -213,7 +234,7 @@ type Pages = { type RouteFiles = { "root.tsx": { id: "root"; - page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/einnahmen" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password" | "/admin/mandanten" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/companies/:id/money" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/invoices/:id/xml" | "/api/reports" | "/api/bilanzen" | "/api/ausgaben" | "/api/ausgaben/:id" | "/api/einnahmen" | "/api/einnahmen/:id" | "/api/anlagevermoegen" | "/api/anlagevermoegen/:id"; + page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/ausgaben/kategorien" | "/companies/:id/einnahmen" | "/companies/:id/einnahmen/kategorien" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password" | "/admin/mandanten" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/companies/:id/money" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/invoices/:id/xml" | "/api/reports" | "/api/bilanzen" | "/api/ausgaben" | "/api/ausgaben/:id" | "/api/einnahmen" | "/api/einnahmen/:id" | "/api/companies/:id/buchungkategorien" | "/api/companies/:id/buchungkategorien/:katId" | "/api/anlagevermoegen" | "/api/anlagevermoegen/:id"; }; "routes/login.tsx": { id: "routes/login"; @@ -225,7 +246,7 @@ type RouteFiles = { }; "routes/dashboard-layout.tsx": { id: "routes/dashboard-layout"; - page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/einnahmen" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password"; + page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/ausgaben/kategorien" | "/companies/:id/einnahmen" | "/companies/:id/einnahmen/kategorien" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password"; }; "routes/home.tsx": { id: "routes/home"; @@ -283,10 +304,18 @@ type RouteFiles = { id: "routes/companies.$id.ausgaben"; page: "/companies/:id/ausgaben"; }; + "routes/companies.$id.ausgaben.kategorien.tsx": { + id: "routes/companies.$id.ausgaben.kategorien"; + page: "/companies/:id/ausgaben/kategorien"; + }; "routes/companies.$id.einnahmen.tsx": { id: "routes/companies.$id.einnahmen"; page: "/companies/:id/einnahmen"; }; + "routes/companies.$id.einnahmen.kategorien.tsx": { + id: "routes/companies.$id.einnahmen.kategorien"; + page: "/companies/:id/einnahmen/kategorien"; + }; "routes/companies.$id.anlagevermoegen.tsx": { id: "routes/companies.$id.anlagevermoegen"; page: "/companies/:id/anlagevermoegen"; @@ -403,6 +432,14 @@ type RouteFiles = { id: "routes/api.einnahmen.$id"; page: "/api/einnahmen/:id"; }; + "routes/api.companies.$id.buchungkategorien.ts": { + id: "routes/api.companies.$id.buchungkategorien"; + page: "/api/companies/:id/buchungkategorien"; + }; + "routes/api.companies.$id.buchungkategorien.$katId.ts": { + id: "routes/api.companies.$id.buchungkategorien.$katId"; + page: "/api/companies/:id/buchungkategorien/:katId"; + }; "routes/api.anlagevermoegen.ts": { id: "routes/api.anlagevermoegen"; page: "/api/anlagevermoegen"; @@ -432,7 +469,9 @@ type RouteModules = { "routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx"); "routes/companies.$id.bilanzen": typeof import("./app/routes/companies.$id.bilanzen.tsx"); "routes/companies.$id.ausgaben": typeof import("./app/routes/companies.$id.ausgaben.tsx"); + "routes/companies.$id.ausgaben.kategorien": typeof import("./app/routes/companies.$id.ausgaben.kategorien.tsx"); "routes/companies.$id.einnahmen": typeof import("./app/routes/companies.$id.einnahmen.tsx"); + "routes/companies.$id.einnahmen.kategorien": typeof import("./app/routes/companies.$id.einnahmen.kategorien.tsx"); "routes/companies.$id.anlagevermoegen": typeof import("./app/routes/companies.$id.anlagevermoegen.tsx"); "routes/companies.$id.money": typeof import("./app/routes/companies.$id.money.tsx"); "routes/archiv": typeof import("./app/routes/archiv.tsx"); @@ -462,6 +501,8 @@ type RouteModules = { "routes/api.ausgaben.$id": typeof import("./app/routes/api.ausgaben.$id.ts"); "routes/api.einnahmen": typeof import("./app/routes/api.einnahmen.ts"); "routes/api.einnahmen.$id": typeof import("./app/routes/api.einnahmen.$id.ts"); + "routes/api.companies.$id.buchungkategorien": typeof import("./app/routes/api.companies.$id.buchungkategorien.ts"); + "routes/api.companies.$id.buchungkategorien.$katId": typeof import("./app/routes/api.companies.$id.buchungkategorien.$katId.ts"); "routes/api.anlagevermoegen": typeof import("./app/routes/api.anlagevermoegen.ts"); "routes/api.anlagevermoegen.$id": typeof import("./app/routes/api.anlagevermoegen.$id.ts"); }; \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.$katId.ts b/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.$katId.ts new file mode 100644 index 0000000..cd79ae7 --- /dev/null +++ b/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.$katId.ts @@ -0,0 +1,62 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../api.companies.$id.buchungkategorien.$katId.js") + +type Info = GetInfo<{ + file: "routes/api.companies.$id.buchungkategorien.$katId.ts", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/api.companies.$id.buchungkategorien.$katId"; + module: typeof import("../api.companies.$id.buchungkategorien.$katId.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.ts b/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.ts new file mode 100644 index 0000000..4a787f5 --- /dev/null +++ b/.react-router/types/app/routes/+types/api.companies.$id.buchungkategorien.ts @@ -0,0 +1,62 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../api.companies.$id.buchungkategorien.js") + +type Info = GetInfo<{ + file: "routes/api.companies.$id.buchungkategorien.ts", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/api.companies.$id.buchungkategorien"; + module: typeof import("../api.companies.$id.buchungkategorien.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/companies.$id.ausgaben.kategorien.ts b/.react-router/types/app/routes/+types/companies.$id.ausgaben.kategorien.ts new file mode 100644 index 0000000..3425f1e --- /dev/null +++ b/.react-router/types/app/routes/+types/companies.$id.ausgaben.kategorien.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../companies.$id.ausgaben.kategorien.js") + +type Info = GetInfo<{ + file: "routes/companies.$id.ausgaben.kategorien.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/dashboard-layout"; + module: typeof import("../dashboard-layout.js"); +}, { + id: "routes/companies.$id.ausgaben.kategorien"; + module: typeof import("../companies.$id.ausgaben.kategorien.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/companies.$id.einnahmen.kategorien.ts b/.react-router/types/app/routes/+types/companies.$id.einnahmen.kategorien.ts new file mode 100644 index 0000000..c058e63 --- /dev/null +++ b/.react-router/types/app/routes/+types/companies.$id.einnahmen.kategorien.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../companies.$id.einnahmen.kategorien.js") + +type Info = GetInfo<{ + file: "routes/companies.$id.einnahmen.kategorien.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/dashboard-layout"; + module: typeof import("../dashboard-layout.js"); +}, { + id: "routes/companies.$id.einnahmen.kategorien"; + module: typeof import("../companies.$id.einnahmen.kategorien.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0319d41 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ + +# Annas Rechnungsmanager (CLAUDE onboarding) + +## 1. Projektüberblick + +Annas Rechnungsmanager ist ein Buchhaltungs- und Rechnungsverwaltungssystem für Steuerberater und Buchhalter mit Mandantenverwaltung. Funktionalitäten: +- Mandantenverwaltung (CRUD, Archiv, Papierkorb) +- Rechnungsverwaltung (Erstellen, PDF-/XML-Export, Zahlung eintragen) +- Kundenverwaltung (Stammdaten pro Mandant) +- Steuerberichte (USt-Voranmeldung, Monats-/Quartalsreport) +- Benutzerverwaltung mit Rollen (ADMIN, USER) +- Audit-Log (Aktionen + IP + Benutzer) + +## 2. Tech Stack (Schlüsseltechnologien) + +- `Node.js 22+`, `TypeScript` +- REMIX/React Router v7 (Server-/Client-CSS, SSR, file-based routing) +- `Prisma` + `MariaDB` (MySQL-kompatibel) +- Authentifizierung: cookie-basierte Sessions, `bcryptjs` +- UI: `Tailwind CSS v4`, `shadcn/ui`, Remix components +- PDF: `@react-pdf/renderer` +- Deployment: `Docker`, `docker-compose`, optional `k8s` + +## 3. Repository-Architektur + +- `app/` - Remiх/React-Router-Quellcode + - `components/` - shared UI und Domain-Komponenten (`company`, `invoice`, `layout`, `ui`) + - `lib/` - Datenbank, Logik, Helpers + - `routes/` - Datei-Routing-Endpunkte und APIs + - `session.server.ts` - Session/Auth-Handling + - `types/index.ts` - globale Typen +- `prisma/` - Schema, Migrationen, Seeder +- `scripts/` - CLI-Hilfs-Skripte (`setup-admin`, `reset-password`) +- `docker-compose.yml`, `Dockerfile`, `k8s.yml` - Deployment & Infrastruktur + +## 4. Schlüsseldateien + +- `app/root.tsx` - Root-Layout und Fehlergrenzen +- `app/entry.server.tsx` - Server-Entry (SSR) +- `app/routes/index.tsx` (`home`, `dashboard`, `login`, `settings`) +- `app/routes/api.*` - REST-API-Endpunkte für CRUD (companies, invoices, etc.) +- `app/lib/prisma.server.ts` - Prisma-Client-Initialisierung +- `app/lib/logger.server.ts`, `rate-limiter.server.ts` - Infrastruktur +- `prisma/schema.prisma` - Datenmodell +- `package.json` und `tsconfig.json` - Build / Lint / Types + +## 5. Konventionen & Code-Richtlinien + +- FS-basierte React Router v7 Routen +- Server-Endpunkte in `app/routes/api.*` als Remix-Loaders/Actions +- Mutationen und Datenzugriffe in `app/lib` (Prisma, Tax, utils) +- Typescript-sicher und null-safe; bevorzugt `unknown`/`guard`-Checks für externe Daten +- UI-Toolkit: shadcn-Komponenten + Tailwind Utility-Klassen +- Error-Handling mit Remix `redirect`, `json`, `badRequest` + +## 6. Häufige Aufgaben und Workflows + +- Lokale Entwicklung: `npm install`, `.env` konfigurieren, `npx prisma migrate deploy`, `npm run dev` +- DB initialisieren/seeden: `npm run db:seed`; `npm run db:migrate` +- Admin einrichten: `npm run setup-admin` (oder via Docker-Env `ADMIN_PASSWORD` beim Start) +- Passwort reset: `npm run reset-password` +- Automatische Migration beim Containerstart (Production) + +## 7. Spezielle Hinweise für Claude + +- Bevorzuge präzise Änderungen in bestehendem Code (letzte Routen und Libs). +- Halte Backward-Kompatibilität: bestehende API-Contracts in `api.*` sollen intakt bleiben. +- Dokumentiere bei komplexen Änderungen Business-Logik (Steuern, Rechnungscodes, UStG §14). +- Vollständige Test: `npm run typecheck`, ggf. `npm run lint` (falls eingerichtet). + +## 8. Agent-Persona (optional) + +- Rolle: Full-stack Remix / TypeScript-Fachkraft für deutsche Rechnungssoftware +- Fokus: Feature-Implementierung im Domain-Kontext (Invoices, Reports, Clients) +- Toolpräferenzen: `read_file`, `grep_search`, `replace_string_in_file` und `run_in_terminal` für Verifikation +- Vermeide: ungetestete massive Refactorings ohne vorhandenen Abdeckungsstatus + +## 9. Weiteres + +- `CLAUDE.md` wird als Projekt-spezifisches Onboarding für ChatGPT/Claude-Agenten genutzt. +- Für Dev-Workflows und PR-Beschreibungen, bitte auf `README.md` und `package.json` verweisen. + diff --git a/app/lib/kategorie-defaults.ts b/app/lib/kategorie-defaults.ts new file mode 100644 index 0000000..1b143f5 --- /dev/null +++ b/app/lib/kategorie-defaults.ts @@ -0,0 +1,32 @@ +export const DEFAULT_AUSGABE_KATEGORIEN = [ + "Waren, Rohstoffe, Hilfsstoffe", + "Geringwertige Wirtschaftsgüter", + "Abschreibungen", + "Miete", + "Strom, Wasser", + "Telekommunikationskosten", + "Fortbildungskosten/Messen", + "Beiträge", + "Versicherungen", + "Werbekosten", + "Zinsen", + "Reisekosten", + "Reparaturen / Instandhaltung", + "Bürobedarf", + "Repräsentationskosten", + "Sonstiger Betriebsbedarf", + "Nebenkosten des Geldverkehrs", +]; + +export const DEFAULT_EINNAHME_KATEGORIEN = [ + "Fußpflege/Verkauf/Gutscheine", + "Privateinlagen", + "Darlehen", + "Steuererstattungen", + "Versicherungserstattungen", + "Zinserträge", + "Miet-/Pachteinnahmen", + "Veräußerungserlöse", + "Eigenverbrauch", + "Sonstige Einnahmen", +]; diff --git a/app/routes.ts b/app/routes.ts index f214797..6f89149 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -19,7 +19,9 @@ export default [ route("companies/:id/reports", "routes/companies.$id.reports.tsx"), route("companies/:id/bilanzen", "routes/companies.$id.bilanzen.tsx"), route("companies/:id/ausgaben", "routes/companies.$id.ausgaben.tsx"), + route("companies/:id/ausgaben/kategorien", "routes/companies.$id.ausgaben.kategorien.tsx"), route("companies/:id/einnahmen", "routes/companies.$id.einnahmen.tsx"), + route("companies/:id/einnahmen/kategorien", "routes/companies.$id.einnahmen.kategorien.tsx"), route("companies/:id/anlagevermoegen", "routes/companies.$id.anlagevermoegen.tsx"), route("companies/:id/money", "routes/companies.$id.money.tsx"), route("archiv", "routes/archiv.tsx"), @@ -55,6 +57,8 @@ 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/companies/:id/buchungkategorien", "routes/api.companies.$id.buchungkategorien.ts"), + route("api/companies/:id/buchungkategorien/:katId", "routes/api.companies.$id.buchungkategorien.$katId.ts"), route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"), route("api/anlagevermoegen/:id", "routes/api.anlagevermoegen.$id.ts"), diff --git a/app/routes/api.companies.$id.buchungkategorien.$katId.ts b/app/routes/api.companies.$id.buchungkategorien.$katId.ts new file mode 100644 index 0000000..5d4046c --- /dev/null +++ b/app/routes/api.companies.$id.buchungkategorien.$katId.ts @@ -0,0 +1,51 @@ +import { getApiUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { z } from "zod"; + +const updateSchema = z.object({ + name: z.string().min(1, "Name ist erforderlich").max(100), +}); + +export async function action({ + request, + params, +}: { + request: Request; + params: { id: string; katId: string }; +}) { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const kat = await prisma.buchungKategorie.findFirst({ + where: { id: params.katId, companyId: params.id, company: { userId: user.id } }, + }); + if (!kat) return Response.json({ error: "Not found" }, { status: 404 }); + + if (request.method === "DELETE") { + const usedCount = await prisma.buchung.count({ + where: { companyId: params.id, kategorie: kat.name, isBusinessRecord: true }, + }); + if (usedCount > 0) { + return Response.json( + { error: `Kategorie wird von ${usedCount} Buchung(en) verwendet und kann nicht gelöscht werden.` }, + { status: 409 } + ); + } + await prisma.buchungKategorie.delete({ where: { id: params.katId } }); + return Response.json({ ok: true }); + } + + if (request.method === "PUT") { + 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.buchungKategorie.update({ + where: { id: params.katId }, + data: { name: parsed.data.name }, + }); + return Response.json(updated); + } + + return Response.json({ error: "Method not allowed" }, { status: 405 }); +} diff --git a/app/routes/api.companies.$id.buchungkategorien.ts b/app/routes/api.companies.$id.buchungkategorien.ts new file mode 100644 index 0000000..2fcca4b --- /dev/null +++ b/app/routes/api.companies.$id.buchungkategorien.ts @@ -0,0 +1,69 @@ +import { getApiUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { z } from "zod"; + +const createSchema = z.object({ + name: z.string().min(1, "Name ist erforderlich").max(100), + typ: z.enum(["AUSGABE", "EINNAHME"]), +}); + +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 company = await prisma.company.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true }, + }); + if (!company) return Response.json({ error: "Not found" }, { status: 404 }); + + const { searchParams } = new URL(request.url); + const typ = searchParams.get("typ") as "AUSGABE" | "EINNAHME" | null; + if (!typ || !["AUSGABE", "EINNAHME"].includes(typ)) { + return Response.json({ error: "typ (AUSGABE|EINNAHME) required" }, { status: 400 }); + } + + const kats = await prisma.buchungKategorie.findMany({ + where: { companyId: params.id, typ }, + orderBy: { name: "asc" }, + }); + + const withUsage = await Promise.all( + kats.map(async (k) => ({ + id: k.id, + name: k.name, + typ: k.typ, + inUse: + (await prisma.buchung.count({ + where: { companyId: params.id, kategorie: k.name, isBusinessRecord: true }, + })) > 0, + })) + ); + + return Response.json(withUsage); +} + +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 company = await prisma.company.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true }, + }); + if (!company) return Response.json({ error: "Not found" }, { status: 404 }); + + const body = await request.json(); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); + + const kat = await prisma.buchungKategorie.create({ + data: { + companyId: params.id, + name: parsed.data.name, + typ: parsed.data.typ, + }, + }); + + return Response.json(kat, { status: 201 }); +} diff --git a/app/routes/companies.$id.ausgaben.kategorien.tsx b/app/routes/companies.$id.ausgaben.kategorien.tsx new file mode 100644 index 0000000..3bd501f --- /dev/null +++ b/app/routes/companies.$id.ausgaben.kategorien.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { Link, useLoaderData, useRevalidator } from "react-router"; +import { requireUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { DEFAULT_AUSGABE_KATEGORIEN } from "@/lib/kategorie-defaults"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { ChevronLeft, Plus, Pencil, Trash2, Loader2 } from "lucide-react"; + +export const handle = { + breadcrumbs: (data: { companyId: string; companyName: string }) => [ + { label: "Mandanten", href: "/companies" }, + { label: data.companyName, href: `/companies/${data.companyId}` }, + { label: "Betriebsausgaben", href: `/companies/${data.companyId}/ausgaben` }, + { label: "Kategorien" }, + ], +}; + +interface Kategorie { + id: string; + name: string; + inUse: boolean; +} + +export async function loader({ request, params }: { request: Request; params: { id: string } }) { + const user = await requireUser(request); + const company = await prisma.company.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true, name: true }, + }); + if (!company) throw new Response("Not Found", { status: 404 }); + + // Auto-seed Standardkategorien wenn noch keine vorhanden + const existing = await prisma.buchungKategorie.count({ + where: { companyId: params.id, typ: "AUSGABE" }, + }); + if (existing === 0) { + await prisma.buchungKategorie.createMany({ + data: DEFAULT_AUSGABE_KATEGORIEN.map((name) => ({ + companyId: params.id, + name, + typ: "AUSGABE", + })), + skipDuplicates: true, + }); + } + + const kats = await prisma.buchungKategorie.findMany({ + where: { companyId: params.id, typ: "AUSGABE" }, + orderBy: { name: "asc" }, + }); + + const withUsage = await Promise.all( + kats.map(async (k) => ({ + id: k.id, + name: k.name, + inUse: + (await prisma.buchung.count({ + where: { companyId: params.id, kategorie: k.name, isBusinessRecord: true }, + })) > 0, + })) + ); + + return { + companyId: company.id, + companyName: company.name, + kategorien: withUsage, + }; +} + +export default function AusgabenKategorienPage() { + const { kategorien: initialKategorien, companyId, companyName } = + useLoaderData(); + const { revalidate } = useRevalidator(); + + const [kategorien, setKategorien] = useState(initialKategorien); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [nameError, setNameError] = useState(""); + + async function reload() { + const res = await fetch(`/api/companies/${companyId}/buchungkategorien?typ=AUSGABE`); + const data: Kategorie[] = await res.json(); + setKategorien(data); + } + + function openCreate() { + setEditingId(null); + setName(""); + setNameError(""); + setDialogOpen(true); + } + + function openEdit(k: Kategorie) { + setEditingId(k.id); + setName(k.name); + setNameError(""); + setDialogOpen(true); + } + + async function handleSave() { + if (!name.trim()) { setNameError("Name ist erforderlich"); return; } + setSaving(true); + try { + if (editingId) { + const res = await fetch(`/api/companies/${companyId}/buchungkategorien/${editingId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (!res.ok) { setNameError("Fehler beim Speichern"); return; } + } else { + const res = await fetch(`/api/companies/${companyId}/buchungkategorien`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), typ: "AUSGABE" }), + }); + if (!res.ok) { setNameError("Kategorie existiert bereits"); return; } + } + setDialogOpen(false); + await reload(); + revalidate(); + } finally { + setSaving(false); + } + } + + async function handleDelete(k: Kategorie) { + if (!confirm(`Kategorie "${k.name}" wirklich löschen?`)) return; + setDeleting(k.id); + const res = await fetch(`/api/companies/${companyId}/buchungkategorien/${k.id}`, { + method: "DELETE", + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error ?? "Löschen fehlgeschlagen"); + } else { + await reload(); + revalidate(); + } + setDeleting(null); + } + + return ( +
+ + Zurück zu Betriebsausgaben + + +
+
+

Ausgaben-Kategorien

+

{companyName}

+
+ +
+ + {kategorien.length === 0 ? ( + + +

Noch keine Kategorien vorhanden.

+ +
+
+ ) : ( + +
+ + + + + + + + + {kategorien.map((k) => ( + + + + + + ))} + +
+ Name + + In Verwendung + +
{k.name} + {k.inUse ? ( + Ja + ) : ( + Nein + )} + +
+ + +
+
+
+
+ )} + + + + + {editingId ? "Kategorie umbenennen" : "Neue Kategorie"} + +
+ + { setName(e.target.value); setNameError(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + placeholder="z.B. Marketingkosten" + 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" + autoFocus + /> + {nameError &&

{nameError}

} +
+
+ + +
+
+
+
+ ); +} diff --git a/app/routes/companies.$id.ausgaben.tsx b/app/routes/companies.$id.ausgaben.tsx index 17fe522..09a5361 100644 --- a/app/routes/companies.$id.ausgaben.tsx +++ b/app/routes/companies.$id.ausgaben.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Link, useLoaderData, useRevalidator } from "react-router"; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; @@ -8,7 +8,7 @@ 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"; +import { DEFAULT_AUSGABE_KATEGORIEN } from "@/lib/kategorie-defaults"; export const handle = { breadcrumbs: (data: { companyId: string; companyName: string }) => [ @@ -18,6 +18,8 @@ export const handle = { ], }; +const MONAT_LABELS = ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]; + const STEUERSAETZE = [ { label: "Keine (0 %)", value: 0 }, { label: "7 %", value: 7 }, @@ -26,7 +28,7 @@ const STEUERSAETZE = [ interface Ausgabe { id: string; - kategorie: AusgabeKategorieKey; + kategorie: string; betrag: number; steuersatz: number; zahlungsart: "KASSE" | "BANK"; @@ -35,7 +37,7 @@ interface Ausgabe { } const emptyForm = { - kategorie: "SONSTIGER_BETRIEBSBEDARF" as AusgabeKategorieKey, + kategorie: "", betrag: "", steuersatz: 19, zahlungsart: "BANK" as "KASSE" | "BANK", @@ -51,6 +53,27 @@ export async function loader({ request, params }: { request: Request; params: { }); if (!company) throw new Response("Not Found", { status: 404 }); + // Auto-seed Standardkategorien wenn noch keine vorhanden + const katCount = await prisma.buchungKategorie.count({ + where: { companyId: params.id, typ: "AUSGABE" }, + }); + if (katCount === 0) { + await prisma.buchungKategorie.createMany({ + data: DEFAULT_AUSGABE_KATEGORIEN.map((name) => ({ + companyId: params.id, + name, + typ: "AUSGABE", + })), + skipDuplicates: true, + }); + } + + const kategorien = await prisma.buchungKategorie.findMany({ + where: { companyId: params.id, typ: "AUSGABE" }, + orderBy: { name: "asc" }, + select: { name: true }, + }); + const year = new Date().getFullYear(); const ausgaben = await prisma.buchung.findMany({ where: { @@ -66,11 +89,12 @@ export async function loader({ request, params }: { request: Request; params: { companyId: company.id, companyName: company.name, initialYear: year, + kategorien: kategorien.map((k) => k.name), ausgaben: ausgaben.map((a) => ({ id: a.id, - kategorie: (a.kategorie || "SONSTIGER_BETRIEBSBEDARF") as AusgabeKategorieKey, + kategorie: a.kategorie ?? "", betrag: Number(a.amount), - steuersatz: a.steuersatz || 19, + steuersatz: (a.steuersatz as number | null) ?? 19, zahlungsart: (a.zahlungsart as "KASSE" | "BANK") || "BANK", datum: a.date.toISOString(), beschreibung: a.description, @@ -79,7 +103,7 @@ export async function loader({ request, params }: { request: Request; params: { } export default function AusgabenPage() { - const { ausgaben: initialAusgaben, companyId, companyName, initialYear } = + const { ausgaben: initialAusgaben, companyId, companyName, initialYear, kategorien } = useLoaderData(); const { revalidate } = useRevalidator(); @@ -92,6 +116,7 @@ export default function AusgabenPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); + const [cellModal, setCellModal] = useState<{ kategorie: string; monat: number } | null>(null); const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); @@ -99,14 +124,22 @@ export default function AusgabenPage() { setYear(y); setLoadingYear(true); const res = await fetch(`/api/ausgaben?companyId=${companyId}&year=${y}`); - const data: Ausgabe[] = await res.json(); - setAusgaben(data); + const raw: Array> = await res.json(); + setAusgaben(raw.map((a) => ({ + id: a.id as string, + kategorie: (a.kategorie as string) ?? "", + betrag: Number(a.amount), + steuersatz: (a.steuersatz as number | null) ?? 19, + zahlungsart: ((a.zahlungsart as string) || "BANK") as "KASSE" | "BANK", + datum: a.date as string, + beschreibung: (a.description as string | null) ?? null, + }))); setLoadingYear(false); } function openCreate() { setEditingId(null); - setForm({ ...emptyForm, datum: `${year}-01-01` }); + setForm({ ...emptyForm, datum: `${year}-01-01`, kategorie: kategorien[0] ?? "" }); setDialogOpen(true); } @@ -173,6 +206,25 @@ export default function AusgabenPage() { return s + (rate > 0 ? Math.round((a.betrag / (1 + rate)) * rate * 100) / 100 : 0); }, 0); + const activeMonate = useMemo(() => { + const set = new Set(ausgaben.map((a) => new Date(a.datum).getMonth())); + return Array.from({ length: 12 }, (_, i) => i).filter((m) => set.has(m)); + }, [ausgaben]); + + const pivot = useMemo(() => { + const map = new Map>(); + for (const a of ausgaben) { + if (!map.has(a.kategorie)) map.set(a.kategorie, new Map()); + const monat = new Date(a.datum).getMonth(); + const inner = map.get(a.kategorie)!; + if (!inner.has(monat)) inner.set(monat, []); + inner.get(monat)!.push(a); + } + return map; + }, [ausgaben]); + + const activeKategorien = useMemo(() => Array.from(pivot.keys()), [pivot]); + const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0; return ( @@ -197,6 +249,12 @@ export default function AusgabenPage() { > {years.map((y) => )} + + Kategorien verwalten + + + + {kategorien.length === 0 ? ( + + +

Noch keine Kategorien vorhanden.

+ +
+
+ ) : ( + +
+ + + + + + + + + {kategorien.map((k) => ( + + + + + + ))} + +
+ Name + + In Verwendung + +
{k.name} + {k.inUse ? ( + Ja + ) : ( + Nein + )} + +
+ + +
+
+
+
+ )} + + + + + {editingId ? "Kategorie umbenennen" : "Neue Kategorie"} + +
+ + { setName(e.target.value); setNameError(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + placeholder="z.B. Beratungseinnahmen" + 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" + autoFocus + /> + {nameError &&

{nameError}

} +
+
+ + +
+
+
+ + ); +} diff --git a/app/routes/companies.$id.einnahmen.tsx b/app/routes/companies.$id.einnahmen.tsx index 285ae15..ca1835c 100644 --- a/app/routes/companies.$id.einnahmen.tsx +++ b/app/routes/companies.$id.einnahmen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Link, useLoaderData, useRevalidator } from "react-router"; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; @@ -8,7 +8,7 @@ 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"; +import { DEFAULT_EINNAHME_KATEGORIEN } from "@/lib/kategorie-defaults"; export const handle = { breadcrumbs: (data: { companyId: string; companyName: string }) => [ @@ -18,6 +18,8 @@ export const handle = { ], }; +const MONAT_LABELS = ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]; + const STEUERSAETZE = [ { label: "Keine (0 %)", value: 0 }, { label: "7 %", value: 7 }, @@ -26,7 +28,7 @@ const STEUERSAETZE = [ interface Einnahme { id: string; - kategorie: EinnahmeKategorieKey; + kategorie: string; betrag: number; steuersatz: number; zahlungsart: "KASSE" | "BANK"; @@ -35,7 +37,7 @@ interface Einnahme { } const emptyForm = { - kategorie: "SONSTIGE_EINNAHMEN" as EinnahmeKategorieKey, + kategorie: "", betrag: "", steuersatz: 0, zahlungsart: "BANK" as "KASSE" | "BANK", @@ -51,6 +53,27 @@ export async function loader({ request, params }: { request: Request; params: { }); if (!company) throw new Response("Not Found", { status: 404 }); + // Auto-seed Standardkategorien wenn noch keine vorhanden + const katCount = await prisma.buchungKategorie.count({ + where: { companyId: params.id, typ: "EINNAHME" }, + }); + if (katCount === 0) { + await prisma.buchungKategorie.createMany({ + data: DEFAULT_EINNAHME_KATEGORIEN.map((name) => ({ + companyId: params.id, + name, + typ: "EINNAHME", + })), + skipDuplicates: true, + }); + } + + const kategorien = await prisma.buchungKategorie.findMany({ + where: { companyId: params.id, typ: "EINNAHME" }, + orderBy: { name: "asc" }, + select: { name: true }, + }); + const year = new Date().getFullYear(); const einnahmen = await prisma.buchung.findMany({ where: { @@ -66,11 +89,12 @@ export async function loader({ request, params }: { request: Request; params: { companyId: company.id, companyName: company.name, initialYear: year, + kategorien: kategorien.map((k) => k.name), einnahmen: einnahmen.map((e) => ({ id: e.id, - kategorie: (e.kategorie || "SONSTIGE_EINNAHMEN") as EinnahmeKategorieKey, + kategorie: e.kategorie ?? "", betrag: Number(e.amount), - steuersatz: e.steuersatz || 0, + steuersatz: (e.steuersatz as number | null) ?? 0, zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK", datum: e.date.toISOString(), beschreibung: e.description, @@ -79,7 +103,7 @@ export async function loader({ request, params }: { request: Request; params: { } export default function EinnahmenPage() { - const { einnahmen: initialEinnahmen, companyId, companyName, initialYear } = + const { einnahmen: initialEinnahmen, companyId, companyName, initialYear, kategorien } = useLoaderData(); const { revalidate } = useRevalidator(); @@ -92,6 +116,7 @@ export default function EinnahmenPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); + const [cellModal, setCellModal] = useState<{ kategorie: string; monat: number } | null>(null); const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); @@ -99,14 +124,22 @@ export default function EinnahmenPage() { setYear(y); setLoadingYear(true); const res = await fetch(`/api/einnahmen?companyId=${companyId}&year=${y}`); - const data: Einnahme[] = await res.json(); - setEinnahmen(data); + const raw: Array> = await res.json(); + setEinnahmen(raw.map((e) => ({ + id: e.id as string, + kategorie: (e.kategorie as string) ?? "", + betrag: Number(e.amount), + steuersatz: (e.steuersatz as number | null) ?? 0, + zahlungsart: ((e.zahlungsart as string) || "BANK") as "KASSE" | "BANK", + datum: e.date as string, + beschreibung: (e.description as string | null) ?? null, + }))); setLoadingYear(false); } function openCreate() { setEditingId(null); - setForm({ ...emptyForm, datum: `${year}-01-01` }); + setForm({ ...emptyForm, datum: `${year}-01-01`, kategorie: kategorien[0] ?? "" }); setDialogOpen(true); } @@ -173,6 +206,25 @@ export default function EinnahmenPage() { return s + (rate > 0 ? Math.round((e.betrag / (1 + rate)) * rate * 100) / 100 : 0); }, 0); + const activeMonate = useMemo(() => { + const set = new Set(einnahmen.map((e) => new Date(e.datum).getMonth())); + return Array.from({ length: 12 }, (_, i) => i).filter((m) => set.has(m)); + }, [einnahmen]); + + const pivot = useMemo(() => { + const map = new Map>(); + for (const e of einnahmen) { + if (!map.has(e.kategorie)) map.set(e.kategorie, new Map()); + const monat = new Date(e.datum).getMonth(); + const inner = map.get(e.kategorie)!; + if (!inner.has(monat)) inner.set(monat, []); + inner.get(monat)!.push(e); + } + return map; + }, [einnahmen]); + + const activeKategorien = useMemo(() => Array.from(pivot.keys()), [pivot]); + const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0; return ( @@ -197,6 +249,12 @@ export default function EinnahmenPage() { > {years.map((y) => )} + + Kategorien verwalten +