ADD: added einnahmen, ausgaben and bilanz

This commit is contained in:
hwinkel
2026-03-24 14:48:32 +01:00
parent 1bbeaf2c34
commit 6d8c4b615f
32 changed files with 2982 additions and 10 deletions
+85 -3
View File
@@ -73,12 +73,30 @@ type Pages = {
"id": string;
};
};
"/companies/:id/bilanzen": {
params: {
"id": string;
};
};
"/companies/:id/ausgaben": {
params: {
"id": string;
};
};
"/companies/:id/einnahmen": {
params: {
"id": string;
};
};
"/archiv": {
params: {};
};
"/settings/password": {
params: {};
};
"/admin/mandanten": {
params: {};
};
"/admin/users": {
params: {};
};
@@ -148,12 +166,31 @@ type Pages = {
"/api/reports": {
params: {};
};
"/api/bilanzen": {
params: {};
};
"/api/ausgaben": {
params: {};
};
"/api/ausgaben/:id": {
params: {
"id": string;
};
};
"/api/einnahmen": {
params: {};
};
"/api/einnahmen/:id": {
params: {
"id": string;
};
};
};
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" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/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";
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" | "/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/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";
};
"routes/login.tsx": {
id: "routes/login";
@@ -165,7 +202,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" | "/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/einnahmen" | "/archiv" | "/settings/password";
};
"routes/home.tsx": {
id: "routes/home";
@@ -215,6 +252,18 @@ type RouteFiles = {
id: "routes/companies.$id.reports";
page: "/companies/:id/reports";
};
"routes/companies.$id.bilanzen.tsx": {
id: "routes/companies.$id.bilanzen";
page: "/companies/:id/bilanzen";
};
"routes/companies.$id.ausgaben.tsx": {
id: "routes/companies.$id.ausgaben";
page: "/companies/:id/ausgaben";
};
"routes/companies.$id.einnahmen.tsx": {
id: "routes/companies.$id.einnahmen";
page: "/companies/:id/einnahmen";
};
"routes/archiv.tsx": {
id: "routes/archiv";
page: "/archiv";
@@ -225,7 +274,11 @@ type RouteFiles = {
};
"routes/admin-layout.tsx": {
id: "routes/admin-layout";
page: "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs";
page: "/admin/mandanten" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs";
};
"routes/admin.mandanten.tsx": {
id: "routes/admin.mandanten";
page: "/admin/mandanten";
};
"routes/admin.users.tsx": {
id: "routes/admin.users";
@@ -295,6 +348,26 @@ type RouteFiles = {
id: "routes/api.reports";
page: "/api/reports";
};
"routes/api.bilanzen.ts": {
id: "routes/api.bilanzen";
page: "/api/bilanzen";
};
"routes/api.ausgaben.ts": {
id: "routes/api.ausgaben";
page: "/api/ausgaben";
};
"routes/api.ausgaben.$id.ts": {
id: "routes/api.ausgaben.$id";
page: "/api/ausgaben/:id";
};
"routes/api.einnahmen.ts": {
id: "routes/api.einnahmen";
page: "/api/einnahmen";
};
"routes/api.einnahmen.$id.ts": {
id: "routes/api.einnahmen.$id";
page: "/api/einnahmen/:id";
};
};
type RouteModules = {
@@ -314,9 +387,13 @@ type RouteModules = {
"routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
"routes/companies.$id.invoices.$invoiceId.edit": typeof import("./app/routes/companies.$id.invoices.$invoiceId.edit.tsx");
"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.einnahmen": typeof import("./app/routes/companies.$id.einnahmen.tsx");
"routes/archiv": typeof import("./app/routes/archiv.tsx");
"routes/settings.password": typeof import("./app/routes/settings.password.tsx");
"routes/admin-layout": typeof import("./app/routes/admin-layout.tsx");
"routes/admin.mandanten": typeof import("./app/routes/admin.mandanten.tsx");
"routes/admin.users": typeof import("./app/routes/admin.users.tsx");
"routes/admin.users.new": typeof import("./app/routes/admin.users.new.tsx");
"routes/admin.users.$id": typeof import("./app/routes/admin.users.$id.tsx");
@@ -334,4 +411,9 @@ type RouteModules = {
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
"routes/api.invoices.$id.xml": typeof import("./app/routes/api.invoices.$id.xml.ts");
"routes/api.reports": typeof import("./app/routes/api.reports.ts");
"routes/api.bilanzen": typeof import("./app/routes/api.bilanzen.ts");
"routes/api.ausgaben": typeof import("./app/routes/api.ausgaben.ts");
"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");
};
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin.mandanten.js")
type Info = GetInfo<{
file: "routes/admin.mandanten.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}, {
id: "routes/admin.mandanten";
module: typeof import("../admin.mandanten.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.ausgaben.$id.js")
type Info = GetInfo<{
file: "routes/api.ausgaben.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.ausgaben.$id";
module: typeof import("../api.ausgaben.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.ausgaben.js")
type Info = GetInfo<{
file: "routes/api.ausgaben.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.ausgaben";
module: typeof import("../api.ausgaben.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.bilanzen.js")
type Info = GetInfo<{
file: "routes/api.bilanzen.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.bilanzen";
module: typeof import("../api.bilanzen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.einnahmen.$id.js")
type Info = GetInfo<{
file: "routes/api.einnahmen.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.einnahmen.$id";
module: typeof import("../api.einnahmen.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.einnahmen.js")
type Info = GetInfo<{
file: "routes/api.einnahmen.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.einnahmen";
module: typeof import("../api.einnahmen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.ausgaben.js")
type Info = GetInfo<{
file: "routes/companies.$id.ausgaben.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";
module: typeof import("../companies.$id.ausgaben.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.bilanzen.js")
type Info = GetInfo<{
file: "routes/companies.$id.bilanzen.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.bilanzen";
module: typeof import("../companies.$id.bilanzen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.einnahmen.js")
type Info = GetInfo<{
file: "routes/companies.$id.einnahmen.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";
module: typeof import("../companies.$id.einnahmen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
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"];
}
+10
View File
@@ -1,5 +1,15 @@
@import "tailwindcss";
/* Pfeile bei number-inputs ausblenden */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
:root {
--background: #f8fafc;
--foreground: #0f172a;
+67
View File
@@ -0,0 +1,67 @@
/**
* Lineare Abschreibung (AfA) nach §7 EStG
* Alle Geldwerte in Euro, 2 Dezimalstellen.
*/
export interface AnlagegutRaw {
anschaffungskosten: number;
nutzungsdauerJahre: number;
restwert: number;
anschaffungsdatum: string; // ISO-Datumsstring
aktiv: boolean;
}
/** Volle Jahres-AfA */
export function jahresAfa(ak: number, restwert: number, nd: number): number {
return Math.round(((ak - restwert) / nd) * 100) / 100;
}
/** Pro-rata AfA im Anschaffungsjahr: verbleibende Monate (inkl. Anschaffungsmonat) / 12 */
export function erwerbsjahrAfa(ak: number, restwert: number, nd: number, datum: Date): number {
const verbleibendeMonathe = 12 - datum.getMonth(); // getMonth() = 0-basiert, Jan=0
return Math.round((jahresAfa(ak, restwert, nd) * verbleibendeMonathe) / 12 * 100) / 100;
}
/** AfA für ein bestimmtes Kalenderjahr (0 wenn nicht erworben oder vollständig abgeschrieben) */
export function afaFuerJahr(asset: AnlagegutRaw, year: number): number {
const acqDate = new Date(asset.anschaffungsdatum);
const acqYear = acqDate.getFullYear();
const lastDepYear = acqYear + asset.nutzungsdauerJahre - 1;
if (year < acqYear || year > lastDepYear) return 0;
const ak = asset.anschaffungskosten;
const rv = asset.restwert;
const nd = asset.nutzungsdauerJahre;
return year === acqYear
? erwerbsjahrAfa(ak, rv, nd, acqDate)
: jahresAfa(ak, rv, nd);
}
/** Kumulierte AfA vom Anschaffungsjahr bis inkl. gegebenem Jahr */
export function kumulierteAfa(asset: AnlagegutRaw, bisJahr: number): number {
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
let total = 0;
for (let y = acqYear; y <= bisJahr; y++) {
total += afaFuerJahr(asset, y);
}
return Math.round(total * 100) / 100;
}
/** Buchwert zum 31.12. des gegebenen Jahres (Minimum: Restwert) */
export function buchwert(asset: AnlagegutRaw, year: number): number {
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
if (year < acqYear) return asset.anschaffungskosten;
const bw = asset.anschaffungskosten - kumulierteAfa(asset, year);
return Math.max(Math.round(bw * 100) / 100, asset.restwert);
}
/** Anzeige-Status eines Anlageguts */
export function assetStatus(asset: AnlagegutRaw, currentYear: number): "aktiv" | "vollständig abgeschrieben" | "inaktiv" {
if (!asset.aktiv) return "inaktiv";
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
const lastDepYear = acqYear + asset.nutzungsdauerJahre - 1;
if (currentYear > lastDepYear) return "vollständig abgeschrieben";
return "aktiv";
}
+41
View File
@@ -0,0 +1,41 @@
export const AUSGABE_KATEGORIEN = [
"WAREN_ROHSTOFFE",
"GERINGWERTIGE_WIRTSCHAFTSGUETER",
"ABSCHREIBUNGEN",
"MIETE",
"STROM_WASSER",
"TELEKOMMUNIKATION",
"FORTBILDUNG_MESSEN",
"BEITRAEGE",
"VERSICHERUNGEN",
"WERBEKOSTEN",
"ZINSEN",
"REISEKOSTEN",
"REPARATUREN_INSTANDHALTUNG",
"BUEROBEDARF",
"REPRAESENTATIONSKOSTEN",
"SONSTIGER_BETRIEBSBEDARF",
"NEBENKOSTEN_GELDVERKEHR",
] as const;
export type AusgabeKategorieKey = typeof AUSGABE_KATEGORIEN[number];
export const KATEGORIE_LABELS: Record<AusgabeKategorieKey, string> = {
WAREN_ROHSTOFFE: "Waren, Rohstoffe, Hilfsstoffe",
GERINGWERTIGE_WIRTSCHAFTSGUETER: "Geringwertige Wirtschaftsgüter",
ABSCHREIBUNGEN: "Abschreibungen",
MIETE: "Miete",
STROM_WASSER: "Strom, Wasser",
TELEKOMMUNIKATION: "Telekommunikationskosten",
FORTBILDUNG_MESSEN: "Fortbildungskosten/Messen",
BEITRAEGE: "Beiträge",
VERSICHERUNGEN: "Versicherungen",
WERBEKOSTEN: "Werbekosten",
ZINSEN: "Zinsen",
REISEKOSTEN: "Reisekosten",
REPARATUREN_INSTANDHALTUNG: "Reparaturen / Instandhaltung",
BUEROBEDARF: "Bürobedarf",
REPRAESENTATIONSKOSTEN: "Repräsentationskosten",
SONSTIGER_BETRIEBSBEDARF: "Sonstiger Betriebsbedarf",
NEBENKOSTEN_GELDVERKEHR: "Nebenkosten des Geldverkehrs",
};
+27
View File
@@ -0,0 +1,27 @@
export const EINNAHME_KATEGORIEN = [
"FUSSPFLEGE",
"PRIVATEINLAGEN",
"DARLEHEN",
"STEUERERSTATTUNGEN",
"VERSICHERUNGSERSTATTUNGEN",
"ZINSERTRAEGE",
"VERMIETUNG_VERPACHTUNG",
"VERAEUSSERUNGSERLOES",
"EIGENVERBRAUCH",
"SONSTIGE_EINNAHMEN",
] as const;
export type EinnahmeKategorieKey = typeof EINNAHME_KATEGORIEN[number];
export const EINNAHME_LABELS: Record<EinnahmeKategorieKey, string> = {
FUSSPFLEGE: "Fußpflege/Verkauf/Gutscheine",
PRIVATEINLAGEN: "Privateinlagen",
DARLEHEN: "Darlehen",
STEUERERSTATTUNGEN: "Steuererstattungen",
VERSICHERUNGSERSTATTUNGEN: "Versicherungserstattungen",
ZINSERTRAEGE: "Zinserträge",
VERMIETUNG_VERPACHTUNG: "Miet-/Pachteinnahmen",
VERAEUSSERUNGSERLOES: "Veräußerungserlöse",
EIGENVERBRAUCH: "Eigenverbrauch",
SONSTIGE_EINNAHMEN: "Sonstige Einnahmen",
};
+9
View File
@@ -17,12 +17,16 @@ export default [
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
route("companies/:id/invoices/:invoiceId/edit", "routes/companies.$id.invoices.$invoiceId.edit.tsx"),
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/einnahmen", "routes/companies.$id.einnahmen.tsx"),
route("archiv", "routes/archiv.tsx"),
route("settings/password", "routes/settings.password.tsx"),
]),
// Admin routes
layout("routes/admin-layout.tsx", [
route("admin/mandanten", "routes/admin.mandanten.tsx"),
route("admin/users", "routes/admin.users.tsx"),
route("admin/users/new", "routes/admin.users.new.tsx"),
route("admin/users/:id", "routes/admin.users.$id.tsx"),
@@ -43,4 +47,9 @@ export default [
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
route("api/reports", "routes/api.reports.ts"),
route("api/bilanzen", "routes/api.bilanzen.ts"),
route("api/ausgaben", "routes/api.ausgaben.ts"),
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"),
] satisfies RouteConfig;
+2 -1
View File
@@ -1,6 +1,6 @@
import { Outlet, useLoaderData, Link, useLocation } from "react-router";
import { requireAdmin } from "@/session.server";
import { Shield, Users, ScrollText, LayoutDashboard } from "lucide-react";
import { Shield, Users, ScrollText, LayoutDashboard, Building2 } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const user = await requireAdmin(request);
@@ -12,6 +12,7 @@ export default function AdminLayout() {
const location = useLocation();
const navItems = [
{ to: "/admin/mandanten", label: "Mandanten", icon: Building2 },
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
];
+131
View File
@@ -0,0 +1,131 @@
import { Link, useLoaderData } from "react-router";
import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Badge } from "@/components/ui/badge";
import { Building2, Archive } from "lucide-react";
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
const companies = await prisma.company.findMany({
include: {
user: { select: { id: true, name: true, email: true } },
_count: { select: { invoices: true, customers: true } },
},
orderBy: [{ archived: "asc" }, { name: "asc" }],
});
return {
companies: companies.map((c) => ({
...c,
archivedAt: c.archivedAt?.toISOString() ?? null,
})),
};
}
export default function AdminMandanten() {
const { companies } = useLoaderData<typeof loader>();
const active = companies.filter((c) => !c.archived);
const archived = companies.filter((c) => c.archived);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Alle Mandanten</h1>
<p className="text-sm text-slate-500 mt-1">
{companies.length} Mandanten gesamt · {active.length} aktiv · {archived.length} archiviert
</p>
</div>
<MandantenTabelle companies={active} title="Aktive Mandanten" />
{archived.length > 0 && (
<div className="mt-8">
<MandantenTabelle companies={archived} title="Archivierte Mandanten" archived />
</div>
)}
</div>
);
}
type Company = {
id: string;
name: string;
legalForm: string | null;
city: string;
email: string | null;
archived: boolean;
user: { id: string; name: string; email: string };
_count: { invoices: number; customers: number };
};
function MandantenTabelle({
companies,
title,
archived = false,
}: {
companies: Company[];
title: string;
archived?: boolean;
}) {
if (companies.length === 0) return null;
return (
<div>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{title}
</h2>
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-600">Mandant</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Ort</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Benutzer</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Rechnungen</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Kunden</th>
<th className="text-right px-4 py-3 font-medium text-slate-600"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{companies.map((company) => (
<tr key={company.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
<div>
<div className="font-medium text-slate-900 flex items-center gap-2">
{company.name}
{archived && (
<Archive className="w-3.5 h-3.5 text-slate-400" />
)}
</div>
{company.legalForm && (
<div className="text-xs text-slate-400">{company.legalForm}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">{company.city}</td>
<td className="px-4 py-3">
<div className="text-slate-700">{company.user.name}</div>
<div className="text-xs text-slate-400">{company.user.email}</div>
</td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td>
<td className="px-4 py-3 text-right">
<Link
to={`/companies/${company.id}`}
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
>
Öffnen
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { AusgabeKategorie } from "@prisma/client";
const updateSchema = z.object({
kategorie: z.nativeEnum(AusgabeKategorie),
betrag: z.number().positive(),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
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 ausgabe = await prisma.betriebsausgabe.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!ausgabe) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.betriebsausgabe.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.betriebsausgabe.update({
where: { id: params.id },
data: {
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
}
+73
View File
@@ -0,0 +1,73 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { AusgabeKategorie } from "@prisma/client";
const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.nativeEnum(AusgabeKategorie),
betrag: z.number().positive(),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
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 = searchParams.get("year") ? parseInt(searchParams.get("year")!) : null;
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 ausgaben = await prisma.betriebsausgabe.findMany({
where: {
companyId,
...(year ? {
datum: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
} : {}),
},
orderBy: { datum: "desc" },
});
return Response.json(
ausgaben.map((a) => ({
...a,
betrag: Number(a.betrag),
datum: a.datum.toISOString(),
}))
);
}
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 ausgabe = await prisma.betriebsausgabe.create({
data: {
companyId: parsed.data.companyId,
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...ausgabe, betrag: Number(ausgabe.betrag), datum: ausgabe.datum.toISOString() }, { status: 201 });
}
+119
View File
@@ -0,0 +1,119 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { InvoiceStatus } from "@prisma/client";
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 yearStart = new Date(`${year}-01-01`);
const yearEnd = new Date(`${year + 1}-01-01`);
// GuV: alle Rechnungen des Jahres (PAID + SENT)
const guvInvoices = await prisma.invoice.findMany({
where: {
companyId,
status: { in: [InvoiceStatus.PAID, InvoiceStatus.SENT] },
issueDate: { gte: yearStart, lt: yearEnd },
},
include: { items: true },
});
// Umsatzerlöse nach Steuersatz
const erloeseByRate: Record<string, { netAmount: number; taxAmount: number; grossAmount: number }> = {};
for (const invoice of guvInvoices) {
for (const item of invoice.items) {
const rate = String(Number(item.taxRate));
if (!erloeseByRate[rate]) erloeseByRate[rate] = { netAmount: 0, taxAmount: 0, grossAmount: 0 };
erloeseByRate[rate].netAmount += Number(item.netAmount);
erloeseByRate[rate].taxAmount += Number(item.taxAmount);
erloeseByRate[rate].grossAmount += Number(item.grossAmount);
}
}
const guvNetto = guvInvoices.reduce((s, i) => s + Number(i.netTotal), 0);
const guvSteuer = guvInvoices.reduce((s, i) => s + Number(i.taxTotal), 0);
const guvBrutto = guvInvoices.reduce((s, i) => s + Number(i.grossTotal), 0);
// Bilanz-Stichtag: 31.12. des gewählten Jahres
// Forderungen = offene (SENT) Rechnungen bis Jahresende
const forderungenAgg = await prisma.invoice.aggregate({
where: { companyId, status: InvoiceStatus.SENT, issueDate: { lt: yearEnd } },
_sum: { grossTotal: true },
_count: true,
});
// Bank/Kasse-Näherung = bezahlte Rechnungen (brutto) bis Jahresende
const bankAgg = await prisma.invoice.aggregate({
where: { companyId, status: InvoiceStatus.PAID, issueDate: { lt: yearEnd } },
_sum: { grossTotal: true },
_count: true,
});
const forderungen = Number(forderungenAgg._sum.grossTotal ?? 0);
const bank = Number(bankAgg._sum.grossTotal ?? 0);
const summeAktiva = forderungen + bank;
// Betriebsausgaben für das Jahr
const ausgabenAgg = await prisma.betriebsausgabe.aggregate({
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 = Number(ausgabenAgg._sum.betrag ?? 0);
// Sonstige Einnahmen für das Jahr
const einnahmenAgg = await prisma.betriebseinnahme.aggregate({
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
_sum: { betrag: true },
});
const sonstigeEinnahmen = Number(einnahmenAgg._sum.betrag ?? 0);
const jahresergebnis = guvNetto + sonstigeEinnahmen - ausgabenGesamt;
return Response.json({
year,
kleinunternehmer: company.kleinunternehmer,
guv: {
erloeseByRate,
netTotal: guvNetto,
taxTotal: guvSteuer,
grossTotal: guvBrutto,
invoiceCount: guvInvoices.length,
ausgabenGesamt,
ausgabenByKategorie: ausgabenByKategorie.map((a) => ({
kategorie: a.kategorie,
betrag: Number(a._sum.betrag ?? 0),
})),
sonstigeEinnahmen,
jahresergebnis,
},
bilanz: {
aktiva: {
forderungen: { betrag: forderungen, anzahl: forderungenAgg._count },
bank: { betrag: bank, anzahl: bankAgg._count },
summe: summeAktiva,
},
passiva: {
eigenkapital: summeAktiva,
summe: summeAktiva,
},
},
});
}
+55
View File
@@ -0,0 +1,55 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { EinnahmeKategorie } from "@prisma/client";
const updateSchema = z.object({
kategorie: z.nativeEnum(EinnahmeKategorie),
betrag: z.number().positive(),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
/**
* Handles an API request to create, update or delete a einnahme.
*
* @param {Request} request - The request object.
* @param {Object} params - The route parameters.
* @param {string} params.id - The id of the einnahme to update or delete.
*
* @returns {Promise<Response>} - A promise resolving to a Response object.
*
* @throws {Response} - If the request is unauthorized, returns a 401 response with an error message.
* @throws {Response} - If the einnahme is not found, returns a 404 response with an error message.
* @throws {Response} - If the request body is invalid, returns a 400 response with an error message containing the validation errors.
*/
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 einnahme = await prisma.betriebseinnahme.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!einnahme) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.betriebseinnahme.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.betriebseinnahme.update({
where: { id: params.id },
data: {
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
}
+105
View File
@@ -0,0 +1,105 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { EinnahmeKategorie } from "@prisma/client";
const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.nativeEnum(EinnahmeKategorie),
betrag: z.number().positive(),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
/**
* Loads the data for the EinnahmenPage.
*
* Requires a companyId search parameter. If year is provided, filters einnahmen for the given year.
*
* Returns a list of einnahmen as a JSON object.
*
* If the request is unauthorized, returns a 401 response with an error message.
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
* If the company is not found, returns a 404 response with an error message.
*/
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 = searchParams.get("year") ? parseInt(searchParams.get("year")!) : null;
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 einnahmen = await prisma.betriebseinnahme.findMany({
where: {
companyId,
...(year ? {
datum: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
} : {}),
},
orderBy: { datum: "asc" },
});
return Response.json(
einnahmen.map((e) => ({
...e,
betrag: Number(e.betrag),
datum: e.datum.toISOString(),
}))
);
}
/**
* Creates a new einnahme for a given company.
*
* Requires a JSON object in the request body with the following shape:
* {
* companyId: string,
* kategorie: EinnahmeKategorie,
* betrag: number,
* datum: string,
* beschreibung: string,
* }
*
* Returns the created einnahme as a JSON object.
*
* If the request is unauthorized, returns a 401 response with an error message.
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
* If the company is not found, returns a 404 response with an error message.
*/
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 einnahme = await prisma.betriebseinnahme.create({
data: {
companyId: parsed.data.companyId,
kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
datum: new Date(parsed.data.datum),
beschreibung: parsed.data.beschreibung,
},
});
return Response.json(
{ ...einnahme, betrag: Number(einnahme.betrag), datum: einnahme.datum.toISOString() },
{ status: 201 }
);
}
@@ -0,0 +1,366 @@
import { useState, useEffect } 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";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Anlagevermögen" },
],
};
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;
beschreibung: string | null;
anschaffungsdatum: string;
anschaffungskosten: number;
nutzungsdauerJahre: number;
restwert: number;
aktiv: boolean;
afaJahr: number;
buchwert: number;
status: "aktiv" | "vollständig abgeschrieben" | "inaktiv";
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true, name: true },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { companyId: company.id, companyName: company.name };
}
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,
},
});
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>
);
}
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 { 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);
async function fetchAssets(y = year) {
setLoading(true);
const res = await fetch(`/api/anlagevermoegen?companyId=${companyId}&year=${y}`);
const data = await res.json();
setAssets(data.assets ?? []);
setLoading(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();
}
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),
});
setEditAsset(null);
fetchAssets();
revalidate();
}
async function handleDelete(id: string) {
if (!confirm("Anlagegut wirklich löschen?")) return;
await fetch(`/api/anlagevermoegen/${id}`, { method: "DELETE" });
fetchAssets();
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);
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">Anlagevermögen</h1>
<p className="text-gray-500 mt-1">{companyName} · Lineare Abschreibung (AfA)</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"
>
{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>
</div>
</div>
{/* Zusammenfassung */}
<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>
</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>
</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 ? (
<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
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<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>
</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>
);
})}
</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>
</tfoot>
</table>
</div>
</Card>
)}
</div>
);
}
+386
View File
@@ -0,0 +1,386 @@
import { useState, useCallback } 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 { 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" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Betriebsausgaben" },
],
};
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
interface Ausgabe {
id: string;
kategorie: AusgabeKategorieKey;
betrag: number;
datum: string;
}
// { [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;
}
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 });
const year = new Date().getFullYear();
const ausgaben = await prisma.betriebsausgabe.findMany({
where: {
companyId: params.id,
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
},
orderBy: { datum: "asc" },
});
return {
companyId: company.id,
companyName: company.name,
initialYear: year,
ausgaben: ausgaben.map((a) => ({
id: a.id,
kategorie: a.kategorie as AusgabeKategorieKey,
betrag: Number(a.betrag),
datum: a.datum.toISOString(),
})),
};
}
export default function AusgabenPage() {
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 [loadingYear, setLoadingYear] = useState(false);
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 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));
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) : "");
}
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 });
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]}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
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", {
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;
});
}
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;
}
}
const topKategorien = [...AUSGABE_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
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">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>
{/* Zusammenfassung */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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>
</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>
</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 */}
{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>
) : (
<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>
<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>
</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>
<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>
</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>
)}
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
import { useState, useEffect } from "react";
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency } from "@/lib/tax";
import { ChevronLeft, Scale, TrendingUp, Info } from "lucide-react";
import { KATEGORIE_LABELS } from "@/lib/ausgaben";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Bilanzen" },
],
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true, name: true },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { companyId: company.id, companyName: company.name };
}
interface ErloeseByRate {
netAmount: number;
taxAmount: number;
grossAmount: number;
}
interface BilanzenData {
year: number;
kleinunternehmer: boolean;
guv: {
erloeseByRate: Record<string, ErloeseByRate>;
netTotal: number;
taxTotal: number;
grossTotal: number;
invoiceCount: number;
ausgabenGesamt: number;
ausgabenByKategorie: { kategorie: string; betrag: number }[];
sonstigeEinnahmen: number;
jahresergebnis: number;
};
bilanz: {
aktiva: {
forderungen: { betrag: number; anzahl: number };
bank: { betrag: number; anzahl: number };
summe: number;
};
passiva: {
eigenkapital: number;
summe: number;
};
};
}
function Row({ label, value, bold, indent, muted }: {
label: string;
value?: number;
bold?: boolean;
indent?: boolean;
muted?: boolean;
}) {
return (
<div className={`flex justify-between py-2 ${bold ? "border-t border-gray-200 mt-1" : "border-b border-gray-50"}`}>
<span className={`text-sm ${indent ? "ml-4" : ""} ${bold ? "font-semibold text-gray-900" : muted ? "text-gray-400" : "text-gray-700"}`}>
{label}
</span>
{value !== undefined ? (
<span className={`text-sm tabular-nums ${bold ? "font-bold text-gray-900" : muted ? "text-gray-400" : "text-gray-800"}`}>
{formatCurrency(value)}
</span>
) : null}
</div>
);
}
export default function BilanzenPage() {
const { companyId } = useLoaderData<typeof loader>();
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<BilanzenData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/bilanzen?companyId=${companyId}&year=${year}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [companyId, year]);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Bilanzen</h1>
<p className="text-gray-500 mt-1">Bilanz und Gewinn- & Verlustrechnung</p>
</div>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{loading ? (
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
) : data && (
<div className="space-y-6">
{/* Zusammenfassung */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.guv.netTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Betriebsausgaben</p>
<p className="text-2xl font-bold text-rose-600">{formatCurrency(data.guv.ausgabenGesamt)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Jahresergebnis</p>
<p className={`text-2xl font-bold ${data.guv.jahresergebnis >= 0 ? "text-teal-600" : "text-red-600"}`}>
{formatCurrency(data.guv.jahresergebnis)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Bilanzsumme</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.bilanz.aktiva.summe)}</p>
</CardContent>
</Card>
</div>
{/* GuV */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-teal-600" />
<CardTitle>Gewinn- und Verlustrechnung (GuV) {year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="max-w-lg">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Erträge</p>
{Object.entries(data.guv.erloeseByRate)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rate, group]) => (
<Row
key={rate}
label={
data.kleinunternehmer
? "Umsatzerlöse (steuerfrei)"
: `Umsatzerlöse ${Number(rate) > 0 ? `${rate}% MwSt.` : "steuerfrei"}`
}
value={group.netAmount}
indent
/>
))}
{!data.kleinunternehmer && data.guv.taxTotal > 0 && (
<Row label="Umsatzsteuer" value={data.guv.taxTotal} indent muted />
)}
<Row label="Summe Umsatzerlöse (netto)" value={data.guv.netTotal} bold />
{data.guv.sonstigeEinnahmen > 0 && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Sonstige Einnahmen</p>
<Row label="Privateinlagen, Erstattungen u.a." value={data.guv.sonstigeEinnahmen} indent />
<Row label="Summe Sonstige Einnahmen" value={data.guv.sonstigeEinnahmen} bold />
</div>
)}
<div className="mt-6">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Aufwendungen</p>
{data.guv.ausgabenByKategorie.length > 0 ? (
data.guv.ausgabenByKategorie
.sort((a, b) => b.betrag - a.betrag)
.map((a) => (
<Row
key={a.kategorie}
label={KATEGORIE_LABELS[a.kategorie as keyof typeof KATEGORIE_LABELS] ?? a.kategorie}
value={a.betrag}
indent
/>
))
) : (
<Row label="Betriebsausgaben" value={0} indent muted />
)}
<Row label="Summe Aufwendungen" value={data.guv.ausgabenGesamt} bold />
</div>
<div className="mt-6 pt-3 border-t-2 border-teal-200">
<div className="flex justify-between py-2">
<span className="text-base font-bold text-gray-900">
{data.guv.jahresergebnis >= 0 ? "Jahresüberschuss" : "Jahresfehlbetrag"}
</span>
<span className={`text-base font-bold ${data.guv.jahresergebnis >= 0 ? "text-teal-600" : "text-red-600"}`}>
{formatCurrency(data.guv.jahresergebnis)}
</span>
</div>
</div>
{data.guv.ausgabenGesamt === 0 && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-100">
<Info className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700">
Noch keine Betriebsausgaben für {data.year} erfasst.
Ausgaben können über die <a href={`/companies/${companyId}/ausgaben`} className="underline">Ausgaben-Seite</a> gepflegt werden.
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Bilanz */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Aktiva */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-teal-600" />
<CardTitle>Aktiva Stichtag 31.12.{year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Umlaufvermögen</p>
<Row
label={`Forderungen aus L+L (${data.bilanz.aktiva.forderungen.anzahl} offen)`}
value={data.bilanz.aktiva.forderungen.betrag}
indent
/>
<Row
label={`Bank / Kasse (${data.bilanz.aktiva.bank.anzahl} bezahlt)`}
value={data.bilanz.aktiva.bank.betrag}
indent
/>
<Row label="Summe Aktiva" value={data.bilanz.aktiva.summe} bold />
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-gray-50 border border-gray-100">
<Info className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-500">
Bank/Kasse ist eine Näherung auf Basis bezahlter Rechnungen (kumuliert bis Jahresende).
</p>
</div>
</CardContent>
</Card>
{/* Passiva */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-teal-600" />
<CardTitle>Passiva Stichtag 31.12.{year}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Eigenkapital</p>
<Row label="Eigenkapital (vereinfacht)" value={data.bilanz.passiva.eigenkapital} indent />
<div className="mt-4">
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Verbindlichkeiten</p>
<Row label="Verbindlichkeiten" value={0} indent muted />
</div>
<Row label="Summe Passiva" value={data.bilanz.passiva.summe} bold />
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-gray-50 border border-gray-100">
<Info className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-500">
Verbindlichkeiten werden nicht erfasst. Das Eigenkapital entspricht vereinfacht der Aktivseite.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
import { useState, useCallback } 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 { formatCurrency } from "@/lib/tax";
import { EINNAHME_KATEGORIEN, EINNAHME_LABELS, type EinnahmeKategorieKey } from "@/lib/einnahmen";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Sonstige Einnahmen" },
],
};
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
interface Einnahme {
id: string;
kategorie: EinnahmeKategorieKey;
betrag: number;
datum: string;
}
type GridCell = { ids: string[]; betrag: number };
type GridData = Record<string, Record<number, GridCell>>;
/**
* 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({
where: { id: params.id, userId: user.id },
select: { id: true, name: true },
});
if (!company) throw new Response("Not Found", { status: 404 });
const year = new Date().getFullYear();
const einnahmen = await prisma.betriebseinnahme.findMany({
where: {
companyId: params.id,
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
},
orderBy: { datum: "asc" },
});
return {
companyId: company.id,
companyName: company.name,
initialYear: year,
einnahmen: einnahmen.map((e) => ({
id: e.id,
kategorie: e.kategorie as EinnahmeKategorieKey,
betrag: Number(e.betrag),
datum: e.datum.toISOString(),
})),
};
}
/**
* 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 { revalidate } = useRevalidator();
const [year, setYear] = useState(initialYear);
const [grid, setGrid] = useState<GridData>(() => buildGrid(initialEinnahmen));
const [loadingYear, setLoadingYear] = useState(false);
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 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));
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) : "");
}
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 });
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]}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kategorie,
betrag: newBetrag,
datum: `${year}-${String(month).padStart(2, "0")}-01`,
}),
});
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", {
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;
});
}
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;
}
}
const topKategorien = [...EINNAHME_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
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">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>
{/* Zusammenfassung */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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>
</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>
</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 */}
{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>
) : (
<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>
<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>
</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>
<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>
</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>
)}
</div>
);
}
+34 -3
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
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase, Scale, TrendingDown, TrendingUp
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
import { useState } from "react";
@@ -50,9 +50,10 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
const isAdmin = user.role === "ADMIN";
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
where: isAdmin ? { id } : { id, userId: user.id },
include: {
invoices: {
where: { status: { not: InvoiceStatus.DELETED } },
@@ -72,7 +73,7 @@ export async function loader({ request, params }: { request: Request; params: {
});
return {
isAdmin: user.role === "ADMIN",
isAdmin,
company: {
...company,
archivedAt: company.archivedAt?.toISOString() ?? null,
@@ -232,6 +233,36 @@ export default function CompanyPage() {
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/bilanzen`} className="block">
<Card className="hover:border-teal-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-teal-50">
<Scale className="h-4 w-4 text-teal-600" />
</div>
<span className="text-sm font-medium text-gray-700">Bilanzen</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/ausgaben`} className="block">
<Card className="hover:border-rose-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-rose-50">
<TrendingDown className="h-4 w-4 text-rose-600" />
</div>
<span className="text-sm font-medium text-gray-700">Ausgaben</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/einnahmen`} className="block">
<Card className="hover:border-emerald-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-emerald-50">
<TrendingUp className="h-4 w-4 text-emerald-600" />
</div>
<span className="text-sm font-medium text-gray-700">Einnahmen</span>
</CardContent>
</Card>
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `betriebsausgaben` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`kategorie` ENUM('WAREN_ROHSTOFFE', 'GERINGWERTIGE_WIRTSCHAFTSGUETER', 'ABSCHREIBUNGEN', 'MIETE', 'STROM_WASSER', 'TELEKOMMUNIKATION', 'FORTBILDUNG_MESSEN', 'BEITRAEGE', 'VERSICHERUNGEN', 'WERBEKOSTEN', 'ZINSEN', 'REISEKOSTEN', 'REPARATUREN_INSTANDHALTUNG', 'BUEROBEDARF', 'REPRAESENTATIONSKOSTEN', 'SONSTIGER_BETRIEBSBEDARF', 'NEBENKOSTEN_GELDVERKEHR') NOT NULL,
`betrag` DECIMAL(10, 2) NOT NULL,
`datum` DATETIME(3) NOT NULL,
`beschreibung` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `betriebsausgaben_companyId_idx`(`companyId`),
INDEX `betriebsausgaben_datum_idx`(`datum`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `betriebsausgaben` ADD CONSTRAINT `betriebsausgaben_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `betriebseinnahmen` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`kategorie` ENUM('PRIVATEINLAGEN', 'DARLEHEN', 'STEUERERSTATTUNGEN', 'VERSICHERUNGSERSTATTUNGEN', 'ZINSERTRAEGE', 'VERMIETUNG_VERPACHTUNG', 'VERAEUSSERUNGSERLOES', 'EIGENVERBRAUCH', 'SONSTIGE_EINNAHMEN') NOT NULL,
`betrag` DECIMAL(10, 2) NOT NULL,
`datum` DATETIME(3) NOT NULL,
`beschreibung` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `betriebseinnahmen_companyId_idx`(`companyId`),
INDEX `betriebseinnahmen_datum_idx`(`datum`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `betriebseinnahmen` ADD CONSTRAINT `betriebseinnahmen_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE `anlagegueter` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`bezeichnung` VARCHAR(191) NOT NULL,
`anschaffungsdatum` DATETIME(3) NOT NULL,
`anschaffungskosten` DECIMAL(10, 2) NOT NULL,
`nutzungsdauerJahre` INTEGER NOT NULL,
`restwert` DECIMAL(10, 2) NOT NULL DEFAULT 0,
`beschreibung` TEXT NULL,
`aktiv` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `anlagegueter_companyId_idx`(`companyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `anlagegueter` ADD CONSTRAINT `anlagegueter_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `betriebseinnahmen` MODIFY `kategorie` ENUM('FUSSPFLEGE', 'PRIVATEINLAGEN', 'DARLEHEN', 'STEUERERSTATTUNGEN', 'VERSICHERUNGSERSTATTUNGEN', 'ZINSERTRAEGE', 'VERMIETUNG_VERPACHTUNG', 'VERAEUSSERUNGSERLOES', 'EIGENVERBRAUCH', 'SONSTIGE_EINNAHMEN') NOT NULL;
+89 -3
View File
@@ -67,9 +67,12 @@ model Company {
archivedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customers Customer[]
invoices Invoice[]
services Service[]
customers Customer[]
invoices Invoice[]
services Service[]
betriebsausgaben Betriebsausgabe[]
betriebseinnahmen Betriebseinnahme[]
anlagegueter Anlagegut[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -143,6 +146,89 @@ enum InvoiceStatus {
DELETED
}
enum EinnahmeKategorie {
FUSSPFLEGE
PRIVATEINLAGEN
DARLEHEN
STEUERERSTATTUNGEN
VERSICHERUNGSERSTATTUNGEN
ZINSERTRAEGE
VERMIETUNG_VERPACHTUNG
VERAEUSSERUNGSERLOES
EIGENVERBRAUCH
SONSTIGE_EINNAHMEN
}
model Betriebseinnahme {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
kategorie EinnahmeKategorie
betrag Decimal @db.Decimal(10, 2)
datum DateTime
beschreibung String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([datum])
@@map("betriebseinnahmen")
}
model Anlagegut {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
bezeichnung String
anschaffungsdatum DateTime
anschaffungskosten Decimal @db.Decimal(10, 2)
nutzungsdauerJahre Int
restwert Decimal @db.Decimal(10, 2) @default(0)
beschreibung String? @db.Text
aktiv Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("anlagegueter")
}
enum AusgabeKategorie {
WAREN_ROHSTOFFE
GERINGWERTIGE_WIRTSCHAFTSGUETER
ABSCHREIBUNGEN
MIETE
STROM_WASSER
TELEKOMMUNIKATION
FORTBILDUNG_MESSEN
BEITRAEGE
VERSICHERUNGEN
WERBEKOSTEN
ZINSEN
REISEKOSTEN
REPARATUREN_INSTANDHALTUNG
BUEROBEDARF
REPRAESENTATIONSKOSTEN
SONSTIGER_BETRIEBSBEDARF
NEBENKOSTEN_GELDVERKEHR
}
model Betriebsausgabe {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
kategorie AusgabeKategorie
betrag Decimal @db.Decimal(10, 2)
datum DateTime
beschreibung String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([datum])
@@map("betriebsausgaben")
}
model InvoiceItem {
id String @id @default(cuid())
invoiceId String