ADD: added einnahmen, ausgaben and bilanz
This commit is contained in:
@@ -73,12 +73,30 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/companies/:id/bilanzen": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/companies/:id/ausgaben": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/companies/:id/einnahmen": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"/archiv": {
|
"/archiv": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
"/settings/password": {
|
"/settings/password": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
|
"/admin/mandanten": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
"/admin/users": {
|
"/admin/users": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -148,12 +166,31 @@ type Pages = {
|
|||||||
"/api/reports": {
|
"/api/reports": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
|
"/api/bilanzen": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/api/ausgaben": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/api/ausgaben/:id": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/api/einnahmen": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/api/einnahmen/:id": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type RouteFiles = {
|
type RouteFiles = {
|
||||||
"root.tsx": {
|
"root.tsx": {
|
||||||
id: "root";
|
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": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
@@ -165,7 +202,7 @@ type RouteFiles = {
|
|||||||
};
|
};
|
||||||
"routes/dashboard-layout.tsx": {
|
"routes/dashboard-layout.tsx": {
|
||||||
id: "routes/dashboard-layout";
|
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": {
|
"routes/home.tsx": {
|
||||||
id: "routes/home";
|
id: "routes/home";
|
||||||
@@ -215,6 +252,18 @@ type RouteFiles = {
|
|||||||
id: "routes/companies.$id.reports";
|
id: "routes/companies.$id.reports";
|
||||||
page: "/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": {
|
"routes/archiv.tsx": {
|
||||||
id: "routes/archiv";
|
id: "routes/archiv";
|
||||||
page: "/archiv";
|
page: "/archiv";
|
||||||
@@ -225,7 +274,11 @@ type RouteFiles = {
|
|||||||
};
|
};
|
||||||
"routes/admin-layout.tsx": {
|
"routes/admin-layout.tsx": {
|
||||||
id: "routes/admin-layout";
|
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": {
|
"routes/admin.users.tsx": {
|
||||||
id: "routes/admin.users";
|
id: "routes/admin.users";
|
||||||
@@ -295,6 +348,26 @@ type RouteFiles = {
|
|||||||
id: "routes/api.reports";
|
id: "routes/api.reports";
|
||||||
page: "/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 = {
|
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": 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.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.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/archiv": typeof import("./app/routes/archiv.tsx");
|
||||||
"routes/settings.password": typeof import("./app/routes/settings.password.tsx");
|
"routes/settings.password": typeof import("./app/routes/settings.password.tsx");
|
||||||
"routes/admin-layout": typeof import("./app/routes/admin-layout.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": typeof import("./app/routes/admin.users.tsx");
|
||||||
"routes/admin.users.new": typeof import("./app/routes/admin.users.new.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");
|
"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.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.invoices.$id.xml": typeof import("./app/routes/api.invoices.$id.xml.ts");
|
||||||
"routes/api.reports": typeof import("./app/routes/api.reports.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
@@ -1,5 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
:root {
|
||||||
--background: #f8fafc;
|
--background: #f8fafc;
|
||||||
--foreground: #0f172a;
|
--foreground: #0f172a;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -17,12 +17,16 @@ export default [
|
|||||||
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
|
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/invoices/:invoiceId/edit", "routes/companies.$id.invoices.$invoiceId.edit.tsx"),
|
||||||
route("companies/:id/reports", "routes/companies.$id.reports.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("archiv", "routes/archiv.tsx"),
|
||||||
route("settings/password", "routes/settings.password.tsx"),
|
route("settings/password", "routes/settings.password.tsx"),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
layout("routes/admin-layout.tsx", [
|
layout("routes/admin-layout.tsx", [
|
||||||
|
route("admin/mandanten", "routes/admin.mandanten.tsx"),
|
||||||
route("admin/users", "routes/admin.users.tsx"),
|
route("admin/users", "routes/admin.users.tsx"),
|
||||||
route("admin/users/new", "routes/admin.users.new.tsx"),
|
route("admin/users/new", "routes/admin.users.new.tsx"),
|
||||||
route("admin/users/:id", "routes/admin.users.$id.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/pdf", "routes/api.invoices.$id.pdf.ts"),
|
||||||
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
|
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
|
||||||
route("api/reports", "routes/api.reports.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;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Outlet, useLoaderData, Link, useLocation } from "react-router";
|
import { Outlet, useLoaderData, Link, useLocation } from "react-router";
|
||||||
import { requireAdmin } from "@/session.server";
|
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 }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
const user = await requireAdmin(request);
|
const user = await requireAdmin(request);
|
||||||
@@ -12,6 +12,7 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ to: "/admin/mandanten", label: "Mandanten", icon: Building2 },
|
||||||
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
|
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
|
||||||
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
|
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||||
import {
|
import {
|
||||||
FileText, Users, BarChart3, Plus, Edit, Building2,
|
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";
|
} from "lucide-react";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
import { useState } from "react";
|
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 } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
|
||||||
const company = await prisma.company.findFirst({
|
const company = await prisma.company.findFirst({
|
||||||
where: { id, userId: user.id },
|
where: isAdmin ? { id } : { id, userId: user.id },
|
||||||
include: {
|
include: {
|
||||||
invoices: {
|
invoices: {
|
||||||
where: { status: { not: InvoiceStatus.DELETED } },
|
where: { status: { not: InvoiceStatus.DELETED } },
|
||||||
@@ -72,7 +73,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAdmin: user.role === "ADMIN",
|
isAdmin,
|
||||||
company: {
|
company: {
|
||||||
...company,
|
...company,
|
||||||
archivedAt: company.archivedAt?.toISOString() ?? null,
|
archivedAt: company.archivedAt?.toISOString() ?? null,
|
||||||
@@ -232,6 +233,36 @@ export default function CompanyPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<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
@@ -67,9 +67,12 @@ model Company {
|
|||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
customers Customer[]
|
customers Customer[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
services Service[]
|
services Service[]
|
||||||
|
betriebsausgaben Betriebsausgabe[]
|
||||||
|
betriebseinnahmen Betriebseinnahme[]
|
||||||
|
anlagegueter Anlagegut[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -143,6 +146,89 @@ enum InvoiceStatus {
|
|||||||
DELETED
|
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 {
|
model InvoiceItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
invoiceId String
|
invoiceId String
|
||||||
|
|||||||
Reference in New Issue
Block a user