feat: add financial transactions management for companies
- Implemented a new route for managing financial transactions (money) for companies, including creating, editing, and deleting transactions. - Added a new model `Buchung` to represent transactions with fields for date, account type, transaction type, amount, and description. - Updated the `companies` model to include a relation to the new `Buchung` model. - Enhanced the company overview page to link to the new financial transactions page. - Added migration scripts to create the necessary database tables and fields for the new functionality. - Created utility scripts for resetting the admin password and setting up the initial admin user.
This commit is contained in:
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx react-router typegen)",
|
|
||||||
"Bash(npx react-router build)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/home/henry/.claude/projects/-home-henry-code-AnnasRechnungsManager"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,16 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/companies/:id/anlagevermoegen": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/companies/:id/money": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"/archiv": {
|
"/archiv": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -129,6 +139,11 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/api/companies/:id/money": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"/api/customers": {
|
"/api/customers": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -185,12 +200,20 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/api/anlagevermoegen": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/api/anlagevermoegen/: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" | "/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";
|
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/einnahmen" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password" | "/admin/mandanten" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/companies/:id/money" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/invoices/:id/xml" | "/api/reports" | "/api/bilanzen" | "/api/ausgaben" | "/api/ausgaben/:id" | "/api/einnahmen" | "/api/einnahmen/:id" | "/api/anlagevermoegen" | "/api/anlagevermoegen/:id";
|
||||||
};
|
};
|
||||||
"routes/login.tsx": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
@@ -202,7 +225,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" | "/companies/:id/bilanzen" | "/companies/:id/ausgaben" | "/companies/:id/einnahmen" | "/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" | "/companies/:id/anlagevermoegen" | "/companies/:id/money" | "/archiv" | "/settings/password";
|
||||||
};
|
};
|
||||||
"routes/home.tsx": {
|
"routes/home.tsx": {
|
||||||
id: "routes/home";
|
id: "routes/home";
|
||||||
@@ -264,6 +287,14 @@ type RouteFiles = {
|
|||||||
id: "routes/companies.$id.einnahmen";
|
id: "routes/companies.$id.einnahmen";
|
||||||
page: "/companies/:id/einnahmen";
|
page: "/companies/:id/einnahmen";
|
||||||
};
|
};
|
||||||
|
"routes/companies.$id.anlagevermoegen.tsx": {
|
||||||
|
id: "routes/companies.$id.anlagevermoegen";
|
||||||
|
page: "/companies/:id/anlagevermoegen";
|
||||||
|
};
|
||||||
|
"routes/companies.$id.money.tsx": {
|
||||||
|
id: "routes/companies.$id.money";
|
||||||
|
page: "/companies/:id/money";
|
||||||
|
};
|
||||||
"routes/archiv.tsx": {
|
"routes/archiv.tsx": {
|
||||||
id: "routes/archiv";
|
id: "routes/archiv";
|
||||||
page: "/archiv";
|
page: "/archiv";
|
||||||
@@ -312,6 +343,10 @@ type RouteFiles = {
|
|||||||
id: "routes/api.companies.$id.invoices";
|
id: "routes/api.companies.$id.invoices";
|
||||||
page: "/api/companies/:id/invoices";
|
page: "/api/companies/:id/invoices";
|
||||||
};
|
};
|
||||||
|
"routes/api.companies.$id.money.ts": {
|
||||||
|
id: "routes/api.companies.$id.money";
|
||||||
|
page: "/api/companies/:id/money";
|
||||||
|
};
|
||||||
"routes/api.customers.ts": {
|
"routes/api.customers.ts": {
|
||||||
id: "routes/api.customers";
|
id: "routes/api.customers";
|
||||||
page: "/api/customers";
|
page: "/api/customers";
|
||||||
@@ -368,6 +403,14 @@ type RouteFiles = {
|
|||||||
id: "routes/api.einnahmen.$id";
|
id: "routes/api.einnahmen.$id";
|
||||||
page: "/api/einnahmen/:id";
|
page: "/api/einnahmen/:id";
|
||||||
};
|
};
|
||||||
|
"routes/api.anlagevermoegen.ts": {
|
||||||
|
id: "routes/api.anlagevermoegen";
|
||||||
|
page: "/api/anlagevermoegen";
|
||||||
|
};
|
||||||
|
"routes/api.anlagevermoegen.$id.ts": {
|
||||||
|
id: "routes/api.anlagevermoegen.$id";
|
||||||
|
page: "/api/anlagevermoegen/:id";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type RouteModules = {
|
type RouteModules = {
|
||||||
@@ -390,6 +433,8 @@ type RouteModules = {
|
|||||||
"routes/companies.$id.bilanzen": typeof import("./app/routes/companies.$id.bilanzen.tsx");
|
"routes/companies.$id.bilanzen": typeof import("./app/routes/companies.$id.bilanzen.tsx");
|
||||||
"routes/companies.$id.ausgaben": typeof import("./app/routes/companies.$id.ausgaben.tsx");
|
"routes/companies.$id.ausgaben": typeof import("./app/routes/companies.$id.ausgaben.tsx");
|
||||||
"routes/companies.$id.einnahmen": typeof import("./app/routes/companies.$id.einnahmen.tsx");
|
"routes/companies.$id.einnahmen": typeof import("./app/routes/companies.$id.einnahmen.tsx");
|
||||||
|
"routes/companies.$id.anlagevermoegen": typeof import("./app/routes/companies.$id.anlagevermoegen.tsx");
|
||||||
|
"routes/companies.$id.money": typeof import("./app/routes/companies.$id.money.tsx");
|
||||||
"routes/archiv": typeof import("./app/routes/archiv.tsx");
|
"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");
|
||||||
@@ -402,6 +447,7 @@ type RouteModules = {
|
|||||||
"routes/api.companies.$id": typeof import("./app/routes/api.companies.$id.ts");
|
"routes/api.companies.$id": typeof import("./app/routes/api.companies.$id.ts");
|
||||||
"routes/api.companies.$id.customers": typeof import("./app/routes/api.companies.$id.customers.ts");
|
"routes/api.companies.$id.customers": typeof import("./app/routes/api.companies.$id.customers.ts");
|
||||||
"routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts");
|
"routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts");
|
||||||
|
"routes/api.companies.$id.money": typeof import("./app/routes/api.companies.$id.money.ts");
|
||||||
"routes/api.customers": typeof import("./app/routes/api.customers.ts");
|
"routes/api.customers": typeof import("./app/routes/api.customers.ts");
|
||||||
"routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts");
|
"routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts");
|
||||||
"routes/api.services": typeof import("./app/routes/api.services.ts");
|
"routes/api.services": typeof import("./app/routes/api.services.ts");
|
||||||
@@ -416,4 +462,6 @@ type RouteModules = {
|
|||||||
"routes/api.ausgaben.$id": typeof import("./app/routes/api.ausgaben.$id.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": typeof import("./app/routes/api.einnahmen.ts");
|
||||||
"routes/api.einnahmen.$id": typeof import("./app/routes/api.einnahmen.$id.ts");
|
"routes/api.einnahmen.$id": typeof import("./app/routes/api.einnahmen.$id.ts");
|
||||||
|
"routes/api.anlagevermoegen": typeof import("./app/routes/api.anlagevermoegen.ts");
|
||||||
|
"routes/api.anlagevermoegen.$id": typeof import("./app/routes/api.anlagevermoegen.$id.ts");
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Generated by React Router
|
||||||
|
|
||||||
|
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||||
|
|
||||||
|
type Module = typeof import("../api.anlagevermoegen.$id.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/api.anlagevermoegen.$id.ts",
|
||||||
|
module: Module
|
||||||
|
}>
|
||||||
|
|
||||||
|
type Matches = [{
|
||||||
|
id: "root";
|
||||||
|
module: typeof import("../../root.js");
|
||||||
|
}, {
|
||||||
|
id: "routes/api.anlagevermoegen.$id";
|
||||||
|
module: typeof import("../api.anlagevermoegen.$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.anlagevermoegen.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/api.anlagevermoegen.ts",
|
||||||
|
module: Module
|
||||||
|
}>
|
||||||
|
|
||||||
|
type Matches = [{
|
||||||
|
id: "root";
|
||||||
|
module: typeof import("../../root.js");
|
||||||
|
}, {
|
||||||
|
id: "routes/api.anlagevermoegen";
|
||||||
|
module: typeof import("../api.anlagevermoegen.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.companies.$id.money.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/api.companies.$id.money.ts",
|
||||||
|
module: Module
|
||||||
|
}>
|
||||||
|
|
||||||
|
type Matches = [{
|
||||||
|
id: "root";
|
||||||
|
module: typeof import("../../root.js");
|
||||||
|
}, {
|
||||||
|
id: "routes/api.companies.$id.money";
|
||||||
|
module: typeof import("../api.companies.$id.money.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.anlagevermoegen.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/companies.$id.anlagevermoegen.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.anlagevermoegen";
|
||||||
|
module: typeof import("../companies.$id.anlagevermoegen.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.money.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/companies.$id.money.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.money";
|
||||||
|
module: typeof import("../companies.$id.money.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"];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { startCleanupScheduler } from "@/lib/cleanup.server";
|
import { startCleanupScheduler } from "./lib/cleanup.server";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import type { AppLoadContext, EntryContext } from "react-router";
|
import type { AppLoadContext, EntryContext } from "react-router";
|
||||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export default [
|
|||||||
route("companies/:id/bilanzen", "routes/companies.$id.bilanzen.tsx"),
|
route("companies/:id/bilanzen", "routes/companies.$id.bilanzen.tsx"),
|
||||||
route("companies/:id/ausgaben", "routes/companies.$id.ausgaben.tsx"),
|
route("companies/:id/ausgaben", "routes/companies.$id.ausgaben.tsx"),
|
||||||
route("companies/:id/einnahmen", "routes/companies.$id.einnahmen.tsx"),
|
route("companies/:id/einnahmen", "routes/companies.$id.einnahmen.tsx"),
|
||||||
|
route("companies/:id/anlagevermoegen", "routes/companies.$id.anlagevermoegen.tsx"),
|
||||||
|
route("companies/:id/money", "routes/companies.$id.money.tsx"),
|
||||||
route("archiv", "routes/archiv.tsx"),
|
route("archiv", "routes/archiv.tsx"),
|
||||||
route("settings/password", "routes/settings.password.tsx"),
|
route("settings/password", "routes/settings.password.tsx"),
|
||||||
]),
|
]),
|
||||||
@@ -38,6 +40,7 @@ export default [
|
|||||||
route("api/companies/:id", "routes/api.companies.$id.ts"),
|
route("api/companies/:id", "routes/api.companies.$id.ts"),
|
||||||
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
|
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
|
||||||
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
|
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
|
||||||
|
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"),
|
||||||
route("api/customers", "routes/api.customers.ts"),
|
route("api/customers", "routes/api.customers.ts"),
|
||||||
route("api/customers/:id", "routes/api.customers.$id.ts"),
|
route("api/customers/:id", "routes/api.customers.$id.ts"),
|
||||||
route("api/services", "routes/api.services.ts"),
|
route("api/services", "routes/api.services.ts"),
|
||||||
@@ -52,4 +55,7 @@ export default [
|
|||||||
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
|
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
|
||||||
route("api/einnahmen", "routes/api.einnahmen.ts"),
|
route("api/einnahmen", "routes/api.einnahmen.ts"),
|
||||||
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
|
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
|
||||||
|
route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"),
|
||||||
|
route("api/anlagevermoegen/:id", "routes/api.anlagevermoegen.$id.ts"),
|
||||||
|
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1),
|
||||||
|
anschaffungsdatum: z.string().min(1),
|
||||||
|
anschaffungskosten: z.number().positive(),
|
||||||
|
nutzungsdauerJahre: z.number().int().min(1),
|
||||||
|
restwert: z.number().min(0),
|
||||||
|
beschreibung: z.string().optional(),
|
||||||
|
aktiv: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const asset = await prisma.anlagegut.findFirst({
|
||||||
|
where: { id: params.id, company: { userId: user.id } },
|
||||||
|
});
|
||||||
|
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (request.method === "DELETE") {
|
||||||
|
await prisma.anlagegut.delete({ where: { id: params.id } });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const updated = await prisma.anlagegut.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: {
|
||||||
|
bezeichnung: parsed.data.bezeichnung,
|
||||||
|
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
|
||||||
|
anschaffungskosten: parsed.data.anschaffungskosten,
|
||||||
|
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
|
||||||
|
restwert: parsed.data.restwert,
|
||||||
|
beschreibung: parsed.data.beschreibung,
|
||||||
|
aktiv: parsed.data.aktiv,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
...updated,
|
||||||
|
anschaffungskosten: Number(updated.anschaffungskosten),
|
||||||
|
restwert: Number(updated.restwert),
|
||||||
|
anschaffungsdatum: updated.anschaffungsdatum.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { afaFuerJahr, buchwert, assetStatus } from "@/lib/afa";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
companyId: z.string().min(1),
|
||||||
|
bezeichnung: z.string().min(1),
|
||||||
|
anschaffungsdatum: z.string().min(1),
|
||||||
|
anschaffungskosten: z.number().positive(),
|
||||||
|
nutzungsdauerJahre: z.number().int().min(1),
|
||||||
|
restwert: z.number().min(0).default(0),
|
||||||
|
beschreibung: z.string().optional(),
|
||||||
|
aktiv: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
function toRaw(a: {
|
||||||
|
anschaffungskosten: unknown;
|
||||||
|
nutzungsdauerJahre: number;
|
||||||
|
restwert: unknown;
|
||||||
|
anschaffungsdatum: Date;
|
||||||
|
aktiv: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
anschaffungskosten: Number(a.anschaffungskosten),
|
||||||
|
nutzungsdauerJahre: a.nutzungsdauerJahre,
|
||||||
|
restwert: Number(a.restwert),
|
||||||
|
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
|
||||||
|
aktiv: a.aktiv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request }: { request: Request }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const companyId = searchParams.get("companyId");
|
||||||
|
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
|
||||||
|
|
||||||
|
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
||||||
|
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const assets = await prisma.anlagegut.findMany({
|
||||||
|
where: { companyId },
|
||||||
|
orderBy: { anschaffungsdatum: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
year,
|
||||||
|
assets: assets.map((a) => {
|
||||||
|
const raw = toRaw(a);
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
bezeichnung: a.bezeichnung,
|
||||||
|
beschreibung: a.beschreibung,
|
||||||
|
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
|
||||||
|
anschaffungskosten: raw.anschaffungskosten,
|
||||||
|
nutzungsdauerJahre: a.nutzungsdauerJahre,
|
||||||
|
restwert: raw.restwert,
|
||||||
|
aktiv: a.aktiv,
|
||||||
|
afaJahr: afaFuerJahr(raw, year),
|
||||||
|
buchwert: buchwert(raw, year),
|
||||||
|
status: assetStatus(raw, year),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: { request: Request }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: parsed.data.companyId, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const asset = await prisma.anlagegut.create({
|
||||||
|
data: {
|
||||||
|
companyId: parsed.data.companyId,
|
||||||
|
bezeichnung: parsed.data.bezeichnung,
|
||||||
|
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
|
||||||
|
anschaffungskosten: parsed.data.anschaffungskosten,
|
||||||
|
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
|
||||||
|
restwert: parsed.data.restwert,
|
||||||
|
beschreibung: parsed.data.beschreibung,
|
||||||
|
aktiv: parsed.data.aktiv,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
...asset,
|
||||||
|
anschaffungskosten: Number(asset.anschaffungskosten),
|
||||||
|
restwert: Number(asset.restwert),
|
||||||
|
anschaffungsdatum: asset.anschaffungsdatum.toISOString(),
|
||||||
|
}, { status: 201 });
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { AusgabeKategorie } from "@prisma/client";
|
|||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
kategorie: z.nativeEnum(AusgabeKategorie),
|
kategorie: z.nativeEnum(AusgabeKategorie),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
|
steuersatz: z.number().min(0).default(0),
|
||||||
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -33,10 +35,17 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
data: {
|
data: {
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
betrag: parsed.data.betrag,
|
||||||
|
steuersatz: parsed.data.steuersatz,
|
||||||
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
datum: new Date(parsed.data.datum),
|
||||||
beschreibung: parsed.data.beschreibung,
|
beschreibung: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
|
return Response.json({
|
||||||
|
...updated,
|
||||||
|
betrag: Number(updated.betrag),
|
||||||
|
steuersatz: Number(updated.steuersatz),
|
||||||
|
datum: updated.datum.toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const createSchema = z.object({
|
|||||||
companyId: z.string().min(1),
|
companyId: z.string().min(1),
|
||||||
kategorie: z.nativeEnum(AusgabeKategorie),
|
kategorie: z.nativeEnum(AusgabeKategorie),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
|
steuersatz: z.number().min(0).default(0),
|
||||||
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -41,6 +43,7 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
ausgaben.map((a) => ({
|
ausgaben.map((a) => ({
|
||||||
...a,
|
...a,
|
||||||
betrag: Number(a.betrag),
|
betrag: Number(a.betrag),
|
||||||
|
steuersatz: Number(a.steuersatz),
|
||||||
datum: a.datum.toISOString(),
|
datum: a.datum.toISOString(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -64,10 +67,17 @@ export async function action({ request }: { request: Request }) {
|
|||||||
companyId: parsed.data.companyId,
|
companyId: parsed.data.companyId,
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
betrag: parsed.data.betrag,
|
||||||
|
steuersatz: parsed.data.steuersatz,
|
||||||
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
datum: new Date(parsed.data.datum),
|
||||||
beschreibung: parsed.data.beschreibung,
|
beschreibung: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({ ...ausgabe, betrag: Number(ausgabe.betrag), datum: ausgabe.datum.toISOString() }, { status: 201 });
|
return Response.json({
|
||||||
|
...ausgabe,
|
||||||
|
betrag: Number(ausgabe.betrag),
|
||||||
|
steuersatz: Number(ausgabe.steuersatz),
|
||||||
|
datum: ausgabe.datum.toISOString(),
|
||||||
|
}, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-20
@@ -64,26 +64,47 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
const summeAktiva = forderungen + bank;
|
const summeAktiva = forderungen + bank;
|
||||||
|
|
||||||
// Betriebsausgaben für das Jahr
|
// Betriebsausgaben für das Jahr
|
||||||
const ausgabenAgg = await prisma.betriebsausgabe.aggregate({
|
const ausgaben = await prisma.betriebsausgabe.findMany({
|
||||||
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
||||||
_sum: { betrag: true },
|
|
||||||
_count: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ausgabenByKategorie = await prisma.betriebsausgabe.groupBy({
|
const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.betrag), 0);
|
||||||
by: ["kategorie"],
|
const ausgabenVorsteuer = ausgaben.reduce((s, a) => {
|
||||||
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
const brutto = Number(a.betrag);
|
||||||
_sum: { betrag: true },
|
const rate = Number(a.steuersatz) / 100;
|
||||||
});
|
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const ausgabenGesamt = Number(ausgabenAgg._sum.betrag ?? 0);
|
// Ausgaben nach Kategorie
|
||||||
|
const ausgabenByKategorieMap: Record<string, number> = {};
|
||||||
|
for (const a of ausgaben) {
|
||||||
|
const k = a.kategorie;
|
||||||
|
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.betrag);
|
||||||
|
}
|
||||||
|
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
|
||||||
|
|
||||||
// Sonstige Einnahmen für das Jahr
|
// Sonstige Einnahmen für das Jahr
|
||||||
const einnahmenAgg = await prisma.betriebseinnahme.aggregate({
|
const einnahmen = await prisma.betriebseinnahme.findMany({
|
||||||
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
||||||
_sum: { betrag: true },
|
|
||||||
});
|
});
|
||||||
const sonstigeEinnahmen = Number(einnahmenAgg._sum.betrag ?? 0);
|
const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.betrag), 0);
|
||||||
|
const einnahmenUst = einnahmen.reduce((s, e) => {
|
||||||
|
const brutto = Number(e.betrag);
|
||||||
|
const rate = Number(e.steuersatz) / 100;
|
||||||
|
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart)
|
||||||
|
const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.betrag), 0);
|
||||||
|
const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.betrag), 0);
|
||||||
|
const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.betrag), 0);
|
||||||
|
const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.betrag), 0);
|
||||||
|
|
||||||
|
// Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
|
||||||
|
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
|
||||||
|
const kasseNetto = einnahmenKasse - ausgabenKasse;
|
||||||
|
const bankNetto = bank + einnahmenBank - ausgabenBank;
|
||||||
|
const summeAktivaErweitert = forderungen + Math.max(0, bankNetto) + Math.max(0, kasseNetto);
|
||||||
|
|
||||||
const jahresergebnis = guvNetto + sonstigeEinnahmen - ausgabenGesamt;
|
const jahresergebnis = guvNetto + sonstigeEinnahmen - ausgabenGesamt;
|
||||||
|
|
||||||
@@ -97,22 +118,22 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
grossTotal: guvBrutto,
|
grossTotal: guvBrutto,
|
||||||
invoiceCount: guvInvoices.length,
|
invoiceCount: guvInvoices.length,
|
||||||
ausgabenGesamt,
|
ausgabenGesamt,
|
||||||
ausgabenByKategorie: ausgabenByKategorie.map((a) => ({
|
ausgabenVorsteuer,
|
||||||
kategorie: a.kategorie,
|
ausgabenByKategorie,
|
||||||
betrag: Number(a._sum.betrag ?? 0),
|
|
||||||
})),
|
|
||||||
sonstigeEinnahmen,
|
sonstigeEinnahmen,
|
||||||
|
einnahmenUst,
|
||||||
jahresergebnis,
|
jahresergebnis,
|
||||||
},
|
},
|
||||||
bilanz: {
|
bilanz: {
|
||||||
aktiva: {
|
aktiva: {
|
||||||
forderungen: { betrag: forderungen, anzahl: forderungenAgg._count },
|
forderungen: { betrag: forderungen, anzahl: forderungenAgg._count },
|
||||||
bank: { betrag: bank, anzahl: bankAgg._count },
|
bank: { betrag: Math.max(0, bankNetto), anzahl: bankAgg._count },
|
||||||
summe: summeAktiva,
|
kasse: { betrag: Math.max(0, kasseNetto) },
|
||||||
|
summe: summeAktivaErweitert,
|
||||||
},
|
},
|
||||||
passiva: {
|
passiva: {
|
||||||
eigenkapital: summeAktiva,
|
eigenkapital: summeAktivaErweitert,
|
||||||
summe: summeAktiva,
|
summe: summeAktivaErweitert,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
|
||||||
|
type Transaction = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
account: "kasse" | "bank";
|
||||||
|
type: "einlage" | "entnahme";
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; }): Transaction {
|
||||||
|
return {
|
||||||
|
id: buchung.id,
|
||||||
|
date: buchung.date.toISOString().split("T")[0],
|
||||||
|
account: buchung.account === "KASSE" ? "kasse" : "bank",
|
||||||
|
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
|
||||||
|
amount: Number(buchung.amount),
|
||||||
|
description: buchung.description || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const buchungen = await prisma.buchung.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = buchungen.map(toTransaction);
|
||||||
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
||||||
|
|
||||||
|
return Response.json({ transactions, balance });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const transactionId = url.searchParams.get("transactionId");
|
||||||
|
const method = request.method;
|
||||||
|
const data = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (method === "POST") {
|
||||||
|
const amount = Number(data.amount);
|
||||||
|
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
|
||||||
|
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.buchung.create({
|
||||||
|
data: {
|
||||||
|
companyId: id,
|
||||||
|
date: new Date(data.date),
|
||||||
|
account: data.account === "bank" ? "BANK" : "KASSE",
|
||||||
|
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
|
||||||
|
amount: amount,
|
||||||
|
description: data.description || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (method === "PUT") {
|
||||||
|
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
||||||
|
const amount = Number(data.amount);
|
||||||
|
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
|
||||||
|
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
|
||||||
|
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.buchung.update({
|
||||||
|
where: { id: transactionId },
|
||||||
|
data: {
|
||||||
|
date: new Date(data.date),
|
||||||
|
account: data.account === "bank" ? "BANK" : "KASSE",
|
||||||
|
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
|
||||||
|
amount: amount,
|
||||||
|
description: data.description || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (method === "DELETE") {
|
||||||
|
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
|
||||||
|
} else {
|
||||||
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buchungen = await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" } });
|
||||||
|
const transactions = buchungen.map(toTransaction);
|
||||||
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
||||||
|
|
||||||
|
return Response.json({ transactions, balance });
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { EinnahmeKategorie } from "@prisma/client";
|
|||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
kategorie: z.nativeEnum(EinnahmeKategorie),
|
kategorie: z.nativeEnum(EinnahmeKategorie),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
|
steuersatz: z.number().min(0).default(0),
|
||||||
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -46,10 +48,17 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
data: {
|
data: {
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
betrag: parsed.data.betrag,
|
||||||
|
steuersatz: parsed.data.steuersatz,
|
||||||
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
datum: new Date(parsed.data.datum),
|
||||||
beschreibung: parsed.data.beschreibung,
|
beschreibung: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({ ...updated, betrag: Number(updated.betrag), datum: updated.datum.toISOString() });
|
return Response.json({
|
||||||
|
...updated,
|
||||||
|
betrag: Number(updated.betrag),
|
||||||
|
steuersatz: Number(updated.steuersatz),
|
||||||
|
datum: updated.datum.toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const createSchema = z.object({
|
|||||||
companyId: z.string().min(1),
|
companyId: z.string().min(1),
|
||||||
kategorie: z.nativeEnum(EinnahmeKategorie),
|
kategorie: z.nativeEnum(EinnahmeKategorie),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
|
steuersatz: z.number().min(0).default(0),
|
||||||
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -52,6 +54,7 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
einnahmen.map((e) => ({
|
einnahmen.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
betrag: Number(e.betrag),
|
betrag: Number(e.betrag),
|
||||||
|
steuersatz: Number(e.steuersatz),
|
||||||
datum: e.datum.toISOString(),
|
datum: e.datum.toISOString(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -93,13 +96,20 @@ export async function action({ request }: { request: Request }) {
|
|||||||
companyId: parsed.data.companyId,
|
companyId: parsed.data.companyId,
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
betrag: parsed.data.betrag,
|
||||||
|
steuersatz: parsed.data.steuersatz,
|
||||||
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
datum: new Date(parsed.data.datum),
|
||||||
beschreibung: parsed.data.beschreibung,
|
beschreibung: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ ...einnahme, betrag: Number(einnahme.betrag), datum: einnahme.datum.toISOString() },
|
{
|
||||||
|
...einnahme,
|
||||||
|
betrag: Number(einnahme.betrag),
|
||||||
|
steuersatz: Number(einnahme.steuersatz),
|
||||||
|
datum: einnahme.datum.toISOString(),
|
||||||
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||||
import { requireUser } from "@/session.server";
|
import { requireUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import {
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
Dialog,
|
||||||
import { ChevronLeft, Plus, Edit, Trash2, Layers } from "lucide-react";
|
DialogContent,
|
||||||
import { useForm } from "react-hook-form";
|
DialogHeader,
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
DialogTitle,
|
||||||
import { z } from "zod";
|
} from "@/components/ui/dialog";
|
||||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
import { ChevronLeft, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/tax";
|
||||||
|
import { afaFuerJahr, buchwert, assetStatus, type AnlagegutRaw } from "@/lib/afa";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
||||||
@@ -22,17 +23,6 @@ export const handle = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
bezeichnung: z.string().min(1, "Pflichtfeld"),
|
|
||||||
anschaffungsdatum: z.string().min(1, "Pflichtfeld"),
|
|
||||||
anschaffungskosten: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }).positive("Betrag muss größer 0 sein"),
|
|
||||||
nutzungsdauerJahre: z.coerce.number().int().min(1, "Mindestens 1 Jahr"),
|
|
||||||
restwert: z.coerce.number().min(0).default(0),
|
|
||||||
beschreibung: z.string().optional(),
|
|
||||||
aktiv: z.boolean().default(true),
|
|
||||||
});
|
|
||||||
type FormData = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
interface Asset {
|
interface Asset {
|
||||||
id: string;
|
id: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
@@ -42,11 +32,46 @@ interface Asset {
|
|||||||
nutzungsdauerJahre: number;
|
nutzungsdauerJahre: number;
|
||||||
restwert: number;
|
restwert: number;
|
||||||
aktiv: boolean;
|
aktiv: boolean;
|
||||||
afaJahr: number;
|
|
||||||
buchwert: number;
|
|
||||||
status: "aktiv" | "vollständig abgeschrieben" | "inaktiv";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssetWithAfa extends Asset {
|
||||||
|
afaJahr: number;
|
||||||
|
buchwertJahr: number;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichAsset(a: Asset, year: number): AssetWithAfa {
|
||||||
|
const raw: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: a.anschaffungskosten,
|
||||||
|
nutzungsdauerJahre: a.nutzungsdauerJahre,
|
||||||
|
restwert: a.restwert,
|
||||||
|
anschaffungsdatum: a.anschaffungsdatum,
|
||||||
|
aktiv: a.aktiv,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
afaJahr: afaFuerJahr(raw, year),
|
||||||
|
buchwertJahr: buchwert(raw, year),
|
||||||
|
statusLabel: assetStatus(raw, year),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANTS: Record<string, "success" | "secondary" | "outline"> = {
|
||||||
|
aktiv: "success",
|
||||||
|
"vollständig abgeschrieben": "secondary",
|
||||||
|
inaktiv: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
bezeichnung: "",
|
||||||
|
anschaffungsdatum: "",
|
||||||
|
anschaffungskosten: "",
|
||||||
|
nutzungsdauerJahre: "",
|
||||||
|
restwert: "0",
|
||||||
|
beschreibung: "",
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
const company = await prisma.company.findFirst({
|
const company = await prisma.company.findFirst({
|
||||||
@@ -54,136 +79,138 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
});
|
});
|
||||||
if (!company) throw new Response("Not Found", { status: 404 });
|
if (!company) throw new Response("Not Found", { status: 404 });
|
||||||
return { companyId: company.id, companyName: company.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnlagegutForm({
|
const assets = await prisma.anlagegut.findMany({
|
||||||
defaultValues,
|
where: { companyId: params.id },
|
||||||
onSubmit,
|
orderBy: { anschaffungsdatum: "asc" },
|
||||||
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 (
|
return {
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
companyId: company.id,
|
||||||
<div className="space-y-1.5">
|
companyName: company.name,
|
||||||
<Label>Bezeichnung *</Label>
|
initialYear: new Date().getFullYear(),
|
||||||
<Input {...register("bezeichnung")} placeholder="z.B. Laptop, Firmenwagen, Maschine" />
|
assets: assets.map((a) => ({
|
||||||
{errors.bezeichnung && <p className="text-xs text-red-600">{errors.bezeichnung.message}</p>}
|
id: a.id,
|
||||||
</div>
|
bezeichnung: a.bezeichnung,
|
||||||
<div className="grid grid-cols-2 gap-4">
|
beschreibung: a.beschreibung,
|
||||||
<div className="space-y-1.5">
|
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
|
||||||
<Label>Anschaffungsdatum *</Label>
|
anschaffungskosten: Number(a.anschaffungskosten),
|
||||||
<Input {...register("anschaffungsdatum")} type="date" />
|
nutzungsdauerJahre: a.nutzungsdauerJahre,
|
||||||
{errors.anschaffungsdatum && <p className="text-xs text-red-600">{errors.anschaffungsdatum.message}</p>}
|
restwert: Number(a.restwert),
|
||||||
</div>
|
aktiv: a.aktiv,
|
||||||
<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() {
|
export default function AnlagevermoegenPage() {
|
||||||
const { companyId, companyName } = useLoaderData<typeof loader>();
|
const { assets: initialAssets, companyId, companyName, initialYear } =
|
||||||
|
useLoaderData<typeof loader>();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
const [year, setYear] = useState(new Date().getFullYear());
|
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [editAsset, setEditAsset] = useState<Asset | null>(null);
|
|
||||||
|
|
||||||
const years = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
|
const [year, setYear] = useState(initialYear);
|
||||||
|
const [assets, setAssets] = useState<Asset[]>(initialAssets);
|
||||||
|
const [loadingYear, setLoadingYear] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
async function fetchAssets(y = year) {
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
setLoading(true);
|
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
|
||||||
|
const years = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() + 2 - i);
|
||||||
|
|
||||||
|
async function loadYear(y: number) {
|
||||||
|
setYear(y);
|
||||||
|
setLoadingYear(true);
|
||||||
const res = await fetch(`/api/anlagevermoegen?companyId=${companyId}&year=${y}`);
|
const res = await fetch(`/api/anlagevermoegen?companyId=${companyId}&year=${y}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAssets(data.assets ?? []);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
setLoading(false);
|
setAssets(data.assets.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
bezeichnung: a.bezeichnung,
|
||||||
|
beschreibung: a.beschreibung,
|
||||||
|
anschaffungsdatum: a.anschaffungsdatum,
|
||||||
|
anschaffungskosten: a.anschaffungskosten,
|
||||||
|
nutzungsdauerJahre: a.nutzungsdauerJahre,
|
||||||
|
restwert: a.restwert,
|
||||||
|
aktiv: a.aktiv,
|
||||||
|
})));
|
||||||
|
setLoadingYear(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchAssets(); }, [companyId, year]);
|
function openCreate() {
|
||||||
|
setEditingAsset(null);
|
||||||
async function handleCreate(data: FormData) {
|
setForm(emptyForm);
|
||||||
await fetch("/api/anlagevermoegen", {
|
setDialogOpen(true);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ...data, companyId }),
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
fetchAssets();
|
|
||||||
revalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(data: FormData) {
|
function openEdit(asset: Asset) {
|
||||||
if (!editAsset) return;
|
setEditingAsset(asset);
|
||||||
await fetch(`/api/anlagevermoegen/${editAsset.id}`, {
|
setForm({
|
||||||
method: "PUT",
|
bezeichnung: asset.bezeichnung,
|
||||||
headers: { "Content-Type": "application/json" },
|
anschaffungsdatum: asset.anschaffungsdatum.slice(0, 10),
|
||||||
body: JSON.stringify(data),
|
anschaffungskosten: String(asset.anschaffungskosten),
|
||||||
|
nutzungsdauerJahre: String(asset.nutzungsdauerJahre),
|
||||||
|
restwert: String(asset.restwert),
|
||||||
|
beschreibung: asset.beschreibung ?? "",
|
||||||
|
aktiv: asset.aktiv,
|
||||||
});
|
});
|
||||||
setEditAsset(null);
|
setDialogOpen(true);
|
||||||
fetchAssets();
|
}
|
||||||
revalidate();
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
bezeichnung: form.bezeichnung,
|
||||||
|
anschaffungsdatum: form.anschaffungsdatum,
|
||||||
|
anschaffungskosten: parseFloat(form.anschaffungskosten),
|
||||||
|
nutzungsdauerJahre: parseInt(form.nutzungsdauerJahre),
|
||||||
|
restwert: parseFloat(form.restwert) || 0,
|
||||||
|
beschreibung: form.beschreibung || undefined,
|
||||||
|
aktiv: form.aktiv,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingAsset) {
|
||||||
|
await fetch(`/api/anlagevermoegen/${editingAsset.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch("/api/anlagevermoegen", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...payload, companyId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadYear(year);
|
||||||
|
revalidate();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Anlagegut wirklich löschen?")) return;
|
if (!confirm("Anlagegut wirklich löschen?")) return;
|
||||||
|
setDeleting(id);
|
||||||
await fetch(`/api/anlagevermoegen/${id}`, { method: "DELETE" });
|
await fetch(`/api/anlagevermoegen/${id}`, { method: "DELETE" });
|
||||||
fetchAssets();
|
setDeleting(null);
|
||||||
|
await loadYear(year);
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalAK = assets.reduce((s, a) => s + a.anschaffungskosten, 0);
|
const enriched = assets.map((a) => enrichAsset(a, year));
|
||||||
const totalBW = assets.filter((a) => a.aktiv).reduce((s, a) => s + a.buchwert, 0);
|
const aktiveAnlagen = enriched.filter((a) => a.aktiv && a.statusLabel === "aktiv").length;
|
||||||
const totalAfa = assets.reduce((s, a) => s + a.afaJahr, 0);
|
const gesamtAfa = enriched.reduce((s, a) => s + a.afaJahr, 0);
|
||||||
|
const gesamtBuchwert = enriched.reduce((s, a) => s + a.buchwertJahr, 0);
|
||||||
|
|
||||||
|
const formValid =
|
||||||
|
form.bezeichnung.trim().length > 0 &&
|
||||||
|
form.anschaffungsdatum.length > 0 &&
|
||||||
|
parseFloat(form.anschaffungskosten) > 0 &&
|
||||||
|
parseInt(form.nutzungsdauerJahre) >= 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -197,29 +224,26 @@ export default function AnlagevermoegenPage() {
|
|||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Anlagevermögen</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Anlagevermögen</h1>
|
||||||
<p className="text-gray-500 mt-1">{companyName} · Lineare Abschreibung (AfA)</p>
|
<p className="text-gray-500 mt-1">
|
||||||
|
{companyName} · {year}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => setYear(Number(e.target.value))}
|
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-amber-500"
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
>
|
>
|
||||||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Button onClick={openCreate}>
|
||||||
<DialogTrigger asChild>
|
<Plus className="h-4 w-4" />
|
||||||
<Button className="bg-amber-600 hover:bg-amber-700">
|
Neues Anlagegut
|
||||||
<Plus className="h-4 w-4" /> Anlagegut anlegen
|
</Button>
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Neues Anlagegut</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<AnlagegutForm onSubmit={handleCreate} submitLabel="Anlegen" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,140 +251,306 @@ export default function AnlagevermoegenPage() {
|
|||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">Anschaffungskosten gesamt</p>
|
<p className="text-xs text-gray-500 mb-1">Aktive Anlagen</p>
|
||||||
<p className="text-xl font-bold text-gray-900">{formatCurrency(totalAK)}</p>
|
<p className="text-xl font-bold text-gray-900">{aktiveAnlagen}</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-5 pb-5">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">Buchwert gesamt ({year})</p>
|
|
||||||
<p className="text-xl font-bold text-amber-600">{formatCurrency(totalBW)}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">AfA gesamt {year}</p>
|
<p className="text-xs text-gray-500 mb-1">AfA gesamt {year}</p>
|
||||||
<p className="text-xl font-bold text-gray-900">{formatCurrency(totalAfa)}</p>
|
<p className="text-xl font-bold text-rose-600">{formatCurrency(gesamtAfa)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Gesamter Buchwert</p>
|
||||||
|
<p className="text-xl font-bold text-indigo-600">{formatCurrency(gesamtBuchwert)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Tabelle */}
|
||||||
<Dialog open={!!editAsset} onOpenChange={(o) => !o && setEditAsset(null)}>
|
{loadingYear ? (
|
||||||
<DialogContent>
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||||
<DialogHeader>
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
<DialogTitle>Anlagegut bearbeiten</DialogTitle>
|
Lade Anlagen...
|
||||||
</DialogHeader>
|
</div>
|
||||||
{editAsset && (
|
) : enriched.length === 0 ? (
|
||||||
<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>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-16 text-center text-gray-400">
|
||||||
<Layers className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
<p className="text-sm">Noch keine Anlagegüter erfasst.</p>
|
||||||
<p className="text-gray-500 text-sm">Noch keine Anlagegüter erfasst</p>
|
<Button variant="outline" className="mt-4" onClick={openCreate}>
|
||||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setOpen(true)}>
|
<Plus className="h-4 w-4" />
|
||||||
<Plus className="h-4 w-4" /> Erstes Anlagegut anlegen
|
Erstes Anlagegut hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
<th className="px-4 py-3 text-left">Bezeichnung</th>
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-4 py-3 text-left">Anschaffung</th>
|
Bezeichnung
|
||||||
<th className="px-4 py-3 text-right">Anschaffungskosten</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right">Nutzungsdauer</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-4 py-3 text-right">AfA {year}</th>
|
Anschaffung
|
||||||
<th className="px-4 py-3 text-right">Buchwert {year}</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-center">Status</th>
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-4 py-3"></th>
|
AK
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
ND (J)
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
AfA {year}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Buchwert 31.12.{year}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 w-16" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{assets.map((asset) => {
|
{enriched.map((asset) => (
|
||||||
const s = statusConfig[asset.status];
|
<tr key={asset.id} className="hover:bg-slate-50/60 group">
|
||||||
return (
|
<td className="px-4 py-2.5">
|
||||||
<tr key={asset.id} className="hover:bg-slate-50 transition-colors">
|
<p className="font-medium text-slate-800">{asset.bezeichnung}</p>
|
||||||
<td className="px-4 py-3">
|
{asset.beschreibung && (
|
||||||
<p className="font-medium text-slate-800">{asset.bezeichnung}</p>
|
<p className="text-xs text-slate-400 truncate max-w-xs">
|
||||||
{asset.beschreibung && (
|
{asset.beschreibung}
|
||||||
<p className="text-xs text-slate-400 truncate max-w-xs">{asset.beschreibung}</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-600 whitespace-nowrap">
|
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
{formatDate(asset.anschaffungsdatum)}
|
{new Date(asset.anschaffungsdatum).toLocaleDateString("de-DE")}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-slate-800">
|
<td className="px-3 py-2.5 text-right text-slate-700 font-medium whitespace-nowrap">
|
||||||
{formatCurrency(asset.anschaffungskosten)}
|
{formatCurrency(asset.anschaffungskosten)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-slate-600">
|
<td className="px-3 py-2.5 text-right text-slate-600">
|
||||||
{asset.nutzungsdauerJahre} J.
|
{asset.nutzungsdauerJahre}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-slate-800">
|
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||||
{asset.afaJahr > 0 ? formatCurrency(asset.afaJahr) : "—"}
|
{asset.afaJahr > 0 ? (
|
||||||
</td>
|
<span className="text-rose-600">{formatCurrency(asset.afaJahr)}</span>
|
||||||
<td className="px-4 py-3 text-right font-medium text-amber-700">
|
) : (
|
||||||
{formatCurrency(asset.buchwert)}
|
<span className="text-slate-300">—</span>
|
||||||
</td>
|
)}
|
||||||
<td className="px-4 py-3 text-center">
|
</td>
|
||||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${s.className}`}>
|
<td className="px-3 py-2.5 text-right font-medium text-indigo-700 whitespace-nowrap">
|
||||||
{s.label}
|
{formatCurrency(asset.buchwertJahr)}
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-3 py-2.5 text-center">
|
||||||
<td className="px-4 py-3">
|
<Badge variant={STATUS_VARIANTS[asset.statusLabel] ?? "outline"}>
|
||||||
<div className="flex justify-end gap-1">
|
{asset.statusLabel}
|
||||||
<Button variant="ghost" size="icon" onClick={() => setEditAsset(asset)}>
|
</Badge>
|
||||||
<Edit className="h-4 w-4" />
|
</td>
|
||||||
</Button>
|
<td className="px-3 py-2.5">
|
||||||
<Button
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
onClick={() => openEdit(asset)}
|
||||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
onClick={() => handleDelete(asset.id)}
|
title="Bearbeiten"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</td>
|
onClick={() => handleDelete(asset.id)}
|
||||||
</tr>
|
disabled={deleting === asset.id}
|
||||||
);
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
})}
|
title="Löschen"
|
||||||
|
>
|
||||||
|
{deleting === asset.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t-2 border-slate-200 bg-slate-50">
|
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
||||||
<td colSpan={2} className="px-4 py-3 font-semibold text-slate-700">Gesamt</td>
|
<td colSpan={4} className="px-4 py-2.5 text-xs font-bold text-slate-700">
|
||||||
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(totalAK)}</td>
|
Gesamt
|
||||||
<td></td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(totalAfa)}</td>
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-rose-600">
|
||||||
<td className="px-4 py-3 text-right font-bold text-amber-700">{formatCurrency(totalBW)}</td>
|
{formatCurrency(gesamtAfa)}
|
||||||
<td colSpan={2}></td>
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-indigo-700">
|
||||||
|
{formatCurrency(gesamtBuchwert)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-3 py-2 border-t border-slate-100 text-xs text-slate-400">
|
||||||
|
AfA: lineare Abschreibung nach §7 EStG · Buchwert zum 31.12. des gewählten Jahres
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dialog: Anlegen / Bearbeiten */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingAsset ? "Anlagegut bearbeiten" : "Neues Anlagegut"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Bezeichnung <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.bezeichnung}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, bezeichnung: e.target.value }))}
|
||||||
|
placeholder="z.B. Laptop, Firmenwagen, Maschine"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Anschaffungsdatum <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.anschaffungsdatum}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, anschaffungsdatum: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nutzungsdauer (Jahre) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.nutzungsdauerJahre}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, nutzungsdauerJahre: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="z.B. 3"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Anschaffungskosten (€) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
value={form.anschaffungskosten}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, anschaffungskosten: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Restwert (€)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.restwert}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, restwert: e.target.value }))}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||||
|
placeholder="Optionale Notizen"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="aktiv"
|
||||||
|
checked={form.aktiv}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, aktiv: e.target.checked }))}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="aktiv" className="text-sm text-gray-700">
|
||||||
|
Anlagegut ist aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AfA-Vorschau */}
|
||||||
|
{formValid && (() => {
|
||||||
|
const raw: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: parseFloat(form.anschaffungskosten),
|
||||||
|
nutzungsdauerJahre: parseInt(form.nutzungsdauerJahre),
|
||||||
|
restwert: parseFloat(form.restwert) || 0,
|
||||||
|
anschaffungsdatum: form.anschaffungsdatum,
|
||||||
|
aktiv: form.aktiv,
|
||||||
|
};
|
||||||
|
const afa = afaFuerJahr(raw, year);
|
||||||
|
const bw = buchwert(raw, year);
|
||||||
|
const jahresAfaVoll =
|
||||||
|
Math.round(
|
||||||
|
((raw.anschaffungskosten - raw.restwert) / raw.nutzungsdauerJahre) * 100
|
||||||
|
) / 100;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-indigo-50 border border-indigo-100 px-3 py-2 text-xs text-indigo-700 space-y-1">
|
||||||
|
<p>
|
||||||
|
<strong>Jährliche AfA:</strong> {formatCurrency(jahresAfaVoll)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>AfA {year}:</strong> {formatCurrency(afa)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Buchwert 31.12.{year}:</strong> {formatCurrency(bw)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !formValid}>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editingAsset ? "Speichern" : "Hinzufügen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||||
import { requireUser } from "@/session.server";
|
import { requireUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react";
|
||||||
import { formatCurrency } from "@/lib/tax";
|
import { formatCurrency } from "@/lib/tax";
|
||||||
import { AUSGABE_KATEGORIEN, KATEGORIE_LABELS, type AusgabeKategorieKey } from "@/lib/ausgaben";
|
import { AUSGABE_KATEGORIEN, KATEGORIE_LABELS, type AusgabeKategorieKey } from "@/lib/ausgaben";
|
||||||
|
|
||||||
export { KATEGORIE_LABELS };
|
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
||||||
{ label: "Mandanten", href: "/companies" },
|
{ label: "Mandanten", href: "/companies" },
|
||||||
@@ -17,34 +18,30 @@ export const handle = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
const STEUERSAETZE = [
|
||||||
|
{ label: "Keine (0 %)", value: 0 },
|
||||||
|
{ label: "7 %", value: 7 },
|
||||||
|
{ label: "19 %", value: 19 },
|
||||||
|
];
|
||||||
|
|
||||||
interface Ausgabe {
|
interface Ausgabe {
|
||||||
id: string;
|
id: string;
|
||||||
kategorie: AusgabeKategorieKey;
|
kategorie: AusgabeKategorieKey;
|
||||||
betrag: number;
|
betrag: number;
|
||||||
|
steuersatz: number;
|
||||||
|
zahlungsart: "KASSE" | "BANK";
|
||||||
datum: string;
|
datum: string;
|
||||||
|
beschreibung: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// { [kategorie]: { [month 1-12]: { ids: string[]; betrag: number } } }
|
const emptyForm = {
|
||||||
type GridCell = { ids: string[]; betrag: number };
|
kategorie: "SONSTIGER_BETRIEBSBEDARF" as AusgabeKategorieKey,
|
||||||
type GridData = Record<string, Record<number, GridCell>>;
|
betrag: "",
|
||||||
|
steuersatz: 19,
|
||||||
function buildGrid(ausgaben: Ausgabe[]): GridData {
|
zahlungsart: "BANK" as "KASSE" | "BANK",
|
||||||
const grid: GridData = {};
|
datum: new Date().toISOString().slice(0, 10),
|
||||||
for (const k of AUSGABE_KATEGORIEN) {
|
beschreibung: "",
|
||||||
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 } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
@@ -60,7 +57,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
companyId: params.id,
|
companyId: params.id,
|
||||||
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
||||||
},
|
},
|
||||||
orderBy: { datum: "asc" },
|
orderBy: { datum: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -71,148 +68,110 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
id: a.id,
|
id: a.id,
|
||||||
kategorie: a.kategorie as AusgabeKategorieKey,
|
kategorie: a.kategorie as AusgabeKategorieKey,
|
||||||
betrag: Number(a.betrag),
|
betrag: Number(a.betrag),
|
||||||
|
steuersatz: Number(a.steuersatz),
|
||||||
|
zahlungsart: a.zahlungsart as "KASSE" | "BANK",
|
||||||
datum: a.datum.toISOString(),
|
datum: a.datum.toISOString(),
|
||||||
|
beschreibung: a.beschreibung,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AusgabenPage() {
|
export default function AusgabenPage() {
|
||||||
const { ausgaben: initialAusgaben, companyId, companyName, initialYear } = useLoaderData<typeof loader>();
|
const { ausgaben: initialAusgaben, companyId, companyName, initialYear } =
|
||||||
|
useLoaderData<typeof loader>();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [year, setYear] = useState(initialYear);
|
const [year, setYear] = useState(initialYear);
|
||||||
const [grid, setGrid] = useState<GridData>(() => buildGrid(initialAusgaben));
|
const [ausgaben, setAusgaben] = useState<Ausgabe[]>(initialAusgaben);
|
||||||
const [loadingYear, setLoadingYear] = useState(false);
|
const [loadingYear, setLoadingYear] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<{ kategorie: string; month: number } | null>(null);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [cellInput, setCellInput] = useState("");
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [savingCell, setSavingCell] = useState<{ kategorie: string; month: number } | null>(null);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
|
||||||
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||||
|
|
||||||
async function loadYear(y: number) {
|
async function loadYear(y: number) {
|
||||||
setEditingCell(null);
|
|
||||||
setYear(y);
|
setYear(y);
|
||||||
setLoadingYear(true);
|
setLoadingYear(true);
|
||||||
const res = await fetch(`/api/ausgaben?companyId=${companyId}&year=${y}`);
|
const res = await fetch(`/api/ausgaben?companyId=${companyId}&year=${y}`);
|
||||||
const data: Ausgabe[] = await res.json();
|
const data: Ausgabe[] = await res.json();
|
||||||
setGrid(buildGrid(data));
|
setAusgaben(data);
|
||||||
setLoadingYear(false);
|
setLoadingYear(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(kategorie: string, month: number) {
|
function openCreate() {
|
||||||
if (savingCell) return;
|
setEditingId(null);
|
||||||
const cell = grid[kategorie]?.[month];
|
setForm({ ...emptyForm, datum: `${year}-01-01` });
|
||||||
setEditingCell({ kategorie, month });
|
setDialogOpen(true);
|
||||||
setCellInput(cell?.betrag ? String(cell.betrag) : "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCell = useCallback(async () => {
|
function openEdit(a: Ausgabe) {
|
||||||
if (!editingCell) return;
|
setEditingId(a.id);
|
||||||
const { kategorie, month } = editingCell;
|
setForm({
|
||||||
setEditingCell(null);
|
kategorie: a.kategorie,
|
||||||
|
betrag: String(a.betrag),
|
||||||
const newBetrag = parseFloat(cellInput.replace(",", ".")) || 0;
|
steuersatz: a.steuersatz,
|
||||||
const cell = grid[kategorie]?.[month];
|
zahlungsart: a.zahlungsart,
|
||||||
const oldBetrag = cell?.betrag ?? 0;
|
datum: a.datum.slice(0, 10),
|
||||||
|
beschreibung: a.beschreibung ?? "",
|
||||||
if (newBetrag === oldBetrag) return;
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
setSavingCell({ kategorie, month });
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
kategorie: form.kategorie,
|
||||||
|
betrag: parseFloat(form.betrag),
|
||||||
|
steuersatz: form.steuersatz,
|
||||||
|
zahlungsart: form.zahlungsart,
|
||||||
|
datum: form.datum,
|
||||||
|
beschreibung: form.beschreibung || undefined,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (newBetrag <= 0 && cell?.ids.length) {
|
if (editingId) {
|
||||||
// Löschen aller Records für diese Zelle
|
await fetch(`/api/ausgaben/${editingId}`, {
|
||||||
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",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
kategorie,
|
|
||||||
betrag: newBetrag,
|
|
||||||
datum: `${year}-${String(month).padStart(2, "0")}-01`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
setGrid((g) => {
|
} else {
|
||||||
const next = { ...g };
|
await fetch("/api/ausgaben", {
|
||||||
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ ...payload, companyId }),
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadYear(year);
|
||||||
revalidate();
|
revalidate();
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCell(null);
|
setSaving(false);
|
||||||
}
|
|
||||||
}, [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]
|
async function handleDelete(id: string) {
|
||||||
.filter((k) => rowTotals[k] > 0)
|
if (!confirm("Eintrag wirklich löschen?")) return;
|
||||||
.sort((a, b) => rowTotals[b] - rowTotals[a])
|
setDeleting(id);
|
||||||
.slice(0, 2);
|
await fetch(`/api/ausgaben/${id}`, { method: "DELETE" });
|
||||||
|
setDeleting(null);
|
||||||
|
await loadYear(year);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnungen
|
||||||
|
const gesamt = ausgaben.reduce((s, a) => s + a.betrag, 0);
|
||||||
|
const kasseGesamt = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + a.betrag, 0);
|
||||||
|
const bankGesamt = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + a.betrag, 0);
|
||||||
|
const vorstGesamt = ausgaben.reduce((s, a) => {
|
||||||
|
const rate = a.steuersatz / 100;
|
||||||
|
return s + (rate > 0 ? Math.round((a.betrag / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -228,13 +187,19 @@ export default function AusgabenPage() {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900">Betriebsausgaben</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Betriebsausgaben</h1>
|
||||||
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div className="flex items-center gap-3">
|
||||||
value={year}
|
<select
|
||||||
onChange={(e) => loadYear(Number(e.target.value))}
|
value={year}
|
||||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
onChange={(e) => loadYear(Number(e.target.value))}
|
||||||
>
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
>
|
||||||
</select>
|
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button onClick={openCreate} className="bg-rose-600 hover:bg-rose-700">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neue Ausgabe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zusammenfassung */}
|
{/* Zusammenfassung */}
|
||||||
@@ -242,145 +207,271 @@ export default function AusgabenPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
|
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
|
||||||
<p className="text-xl font-bold text-rose-600">{formatCurrency(grandTotal)}</p>
|
<p className="text-xl font-bold text-rose-600">{formatCurrency(gesamt)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">Kategorien mit Einträgen</p>
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<p className="text-xl font-bold text-gray-900">
|
<Landmark className="h-3 w-3 text-gray-400" />
|
||||||
{AUSGABE_KATEGORIEN.filter((k) => rowTotals[k] > 0).length}
|
<p className="text-xs text-gray-500">Bank</p>
|
||||||
</p>
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(bankGesamt)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<Banknote className="h-3 w-3 text-gray-400" />
|
||||||
|
<p className="text-xs text-gray-500">Kasse</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(kasseGesamt)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Vorsteuer (enthalten)</p>
|
||||||
|
<p className="text-xl font-bold text-amber-600">{formatCurrency(vorstGesamt)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Matrix-Tabelle */}
|
{/* Liste */}
|
||||||
{loadingYear ? (
|
{loadingYear ? (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
Lade Ausgaben...
|
Lade Ausgaben...
|
||||||
</div>
|
</div>
|
||||||
|
) : ausgaben.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center text-gray-400">
|
||||||
|
<p className="text-sm">Noch keine Ausgaben für {year} erfasst.</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Erste Ausgabe hinzufügen
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm table-fixed border-collapse">
|
<table className="w-full text-sm border-collapse">
|
||||||
<colgroup>
|
|
||||||
<col style={{ width: "180px" }} />
|
|
||||||
{MONTHS.map((_, i) => <col key={i} style={{ width: "72px" }} />)}
|
|
||||||
<col style={{ width: "88px" }} />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-slate-50">
|
<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">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Datum</th>
|
||||||
Kategorie
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Kategorie</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Brutto</th>
|
||||||
{MONTHS.map((m) => (
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">MwSt.</th>
|
||||||
<th key={m} className="px-1 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Netto</th>
|
||||||
{m}
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">Zahlung</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Notiz</th>
|
||||||
))}
|
<th className="px-3 py-2.5 w-16" />
|
||||||
<th className="px-2 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
|
||||||
Gesamt
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-50">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{AUSGABE_KATEGORIEN.map((kat) => (
|
{ausgaben.map((a) => {
|
||||||
<tr key={kat} className="hover:bg-slate-50/60 group">
|
const rate = a.steuersatz / 100;
|
||||||
<td className="px-3 py-1.5 text-xs font-medium text-slate-700 truncate">
|
const netto = rate > 0 ? Math.round((a.betrag / (1 + rate)) * 100) / 100 : a.betrag;
|
||||||
{KATEGORIE_LABELS[kat]}
|
return (
|
||||||
</td>
|
<tr key={a.id} className="hover:bg-slate-50/60 group">
|
||||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
const cell = grid[kat]?.[month];
|
{new Date(a.datum).toLocaleDateString("de-DE")}
|
||||||
const betrag = cell?.betrag ?? 0;
|
</td>
|
||||||
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
|
<td className="px-3 py-2.5 text-slate-700 font-medium">
|
||||||
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
|
{KATEGORIE_LABELS[a.kategorie]}
|
||||||
|
</td>
|
||||||
return (
|
<td className="px-3 py-2.5 text-right font-medium text-rose-700 whitespace-nowrap">
|
||||||
<td
|
{formatCurrency(a.betrag)}
|
||||||
key={month}
|
</td>
|
||||||
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-indigo-50" : ""}`}
|
<td className="px-3 py-2.5 text-center">
|
||||||
>
|
{a.steuersatz > 0 ? (
|
||||||
{isSaving ? (
|
<Badge variant="secondary">{a.steuersatz} %</Badge>
|
||||||
<span className="flex justify-end pr-1">
|
) : (
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-slate-400" />
|
<span className="text-slate-300 text-xs">—</span>
|
||||||
</span>
|
)}
|
||||||
) : isEditing ? (
|
</td>
|
||||||
<input
|
<td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
|
||||||
type="number"
|
{formatCurrency(netto)}
|
||||||
step="0.01"
|
</td>
|
||||||
min="0"
|
<td className="px-3 py-2.5 text-center">
|
||||||
autoFocus
|
{a.zahlungsart === "BANK" ? (
|
||||||
value={cellInput}
|
<span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
|
||||||
onChange={(e) => setCellInput(e.target.value)}
|
<Landmark className="h-3 w-3" /> Bank
|
||||||
onBlur={commitCell}
|
</span>
|
||||||
onKeyDown={(e) => {
|
) : (
|
||||||
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
|
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||||
if (e.key === "Escape") setEditingCell(null);
|
<Banknote className="h-3 w-3" /> Kasse
|
||||||
}}
|
</span>
|
||||||
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"
|
)}
|
||||||
/>
|
</td>
|
||||||
) : (
|
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
|
||||||
<button
|
{a.beschreibung ?? ""}
|
||||||
type="button"
|
</td>
|
||||||
onClick={() => startEdit(kat, month)}
|
<td className="px-3 py-2.5">
|
||||||
disabled={!!savingCell}
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
|
<button
|
||||||
${betrag > 0
|
onClick={() => openEdit(a)}
|
||||||
? "text-slate-800 font-medium"
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
|
title="Bearbeiten"
|
||||||
hover:bg-indigo-50 focus:outline-none focus:ring-1 focus:ring-indigo-300`}
|
>
|
||||||
>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
{betrag > 0
|
</button>
|
||||||
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
<button
|
||||||
: "—"}
|
onClick={() => handleDelete(a.id)}
|
||||||
</button>
|
disabled={deleting === a.id}
|
||||||
)}
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
</td>
|
title="Löschen"
|
||||||
);
|
>
|
||||||
})}
|
{deleting === a.id ? (
|
||||||
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-rose-600" : "text-slate-300"}`}>
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
{rowTotals[kat] > 0
|
) : (
|
||||||
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
: "—"}
|
)}
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
<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>
|
<td colSpan={2} className="px-4 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
|
||||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-rose-600">{formatCurrency(gesamt)}</td>
|
||||||
<td key={month} className={`px-1 py-2.5 text-right text-xs font-bold ${colTotals[month] > 0 ? "text-slate-800" : "text-slate-300"}`}>
|
<td />
|
||||||
{colTotals[month] > 0
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-slate-600">
|
||||||
? colTotals[month].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
{formatCurrency(gesamt - vorstGesamt)}
|
||||||
: "—"}
|
|
||||||
</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>
|
</td>
|
||||||
|
<td colSpan={3} />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "Ausgabe bearbeiten" : "Neue Ausgabe"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.datum}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Betrag (brutto, €) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
value={form.betrag}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, betrag: e.target.value }))}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kategorie <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.kategorie}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, kategorie: e.target.value as AusgabeKategorieKey }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
>
|
||||||
|
{AUSGABE_KATEGORIEN.map((k) => (
|
||||||
|
<option key={k} value={k}>{KATEGORIE_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Zahlungsweg</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["BANK", "KASSE"] as const).map((za) => (
|
||||||
|
<button
|
||||||
|
key={za}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, zahlungsart: za }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||||
|
${form.zahlungsart === za
|
||||||
|
? za === "BANK"
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-700"
|
||||||
|
: "bg-amber-50 border-amber-300 text-amber-700"
|
||||||
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{za === "BANK" ? <Landmark className="h-3.5 w-3.5" /> : <Banknote className="h-3.5 w-3.5" />}
|
||||||
|
{za === "BANK" ? "Bank" : "Kasse"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Steuersatz</label>
|
||||||
|
<select
|
||||||
|
value={form.steuersatz}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, steuersatz: Number(e.target.value) }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
>
|
||||||
|
{STEUERSAETZE.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vorschau Nettobetrag */}
|
||||||
|
{parseFloat(form.betrag) > 0 && form.steuersatz > 0 && (
|
||||||
|
<div className="rounded-lg bg-rose-50 border border-rose-100 px-3 py-2 text-xs text-rose-700 space-y-0.5">
|
||||||
|
<p><strong>Brutto:</strong> {formatCurrency(parseFloat(form.betrag))}</p>
|
||||||
|
<p><strong>Vorsteuer ({form.steuersatz} %):</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * (form.steuersatz / 100) * 100) / 100)}</p>
|
||||||
|
<p><strong>Netto:</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * 100) / 100)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !formValid}
|
||||||
|
className="bg-rose-600 hover:bg-rose-700"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editingId ? "Speichern" : "Hinzufügen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { requireUser } from "@/session.server";
|
|||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { formatCurrency } from "@/lib/tax";
|
import { formatCurrency } from "@/lib/tax";
|
||||||
import { ChevronLeft, Scale, TrendingUp, Info } from "lucide-react";
|
import { ChevronLeft, Scale, TrendingUp, Info, Banknote, Landmark } from "lucide-react";
|
||||||
import { KATEGORIE_LABELS } from "@/lib/ausgaben";
|
import { KATEGORIE_LABELS } from "@/lib/ausgaben";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
@@ -41,14 +41,17 @@ interface BilanzenData {
|
|||||||
grossTotal: number;
|
grossTotal: number;
|
||||||
invoiceCount: number;
|
invoiceCount: number;
|
||||||
ausgabenGesamt: number;
|
ausgabenGesamt: number;
|
||||||
|
ausgabenVorsteuer: number;
|
||||||
ausgabenByKategorie: { kategorie: string; betrag: number }[];
|
ausgabenByKategorie: { kategorie: string; betrag: number }[];
|
||||||
sonstigeEinnahmen: number;
|
sonstigeEinnahmen: number;
|
||||||
|
einnahmenUst: number;
|
||||||
jahresergebnis: number;
|
jahresergebnis: number;
|
||||||
};
|
};
|
||||||
bilanz: {
|
bilanz: {
|
||||||
aktiva: {
|
aktiva: {
|
||||||
forderungen: { betrag: number; anzahl: number };
|
forderungen: { betrag: number; anzahl: number };
|
||||||
bank: { betrag: number; anzahl: number };
|
bank: { betrag: number; anzahl: number };
|
||||||
|
kasse: { betrag: number };
|
||||||
summe: number;
|
summe: number;
|
||||||
};
|
};
|
||||||
passiva: {
|
passiva: {
|
||||||
@@ -187,7 +190,10 @@ export default function BilanzenPage() {
|
|||||||
{data.guv.sonstigeEinnahmen > 0 && (
|
{data.guv.sonstigeEinnahmen > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Sonstige Einnahmen</p>
|
<p className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-3">Sonstige Einnahmen</p>
|
||||||
<Row label="Privateinlagen, Erstattungen u.a." value={data.guv.sonstigeEinnahmen} indent />
|
<Row label="Privateinlagen, Erstattungen u.a. (brutto)" value={data.guv.sonstigeEinnahmen} indent />
|
||||||
|
{data.guv.einnahmenUst > 0 && (
|
||||||
|
<Row label="Umsatzsteuer (enthalten)" value={data.guv.einnahmenUst} indent muted />
|
||||||
|
)}
|
||||||
<Row label="Summe Sonstige Einnahmen" value={data.guv.sonstigeEinnahmen} bold />
|
<Row label="Summe Sonstige Einnahmen" value={data.guv.sonstigeEinnahmen} bold />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +214,9 @@ export default function BilanzenPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Row label="Betriebsausgaben" value={0} indent muted />
|
<Row label="Betriebsausgaben" value={0} indent muted />
|
||||||
)}
|
)}
|
||||||
|
{data.guv.ausgabenVorsteuer > 0 && (
|
||||||
|
<Row label="Vorsteuer (enthalten)" value={data.guv.ausgabenVorsteuer} indent muted />
|
||||||
|
)}
|
||||||
<Row label="Summe Aufwendungen" value={data.guv.ausgabenGesamt} bold />
|
<Row label="Summe Aufwendungen" value={data.guv.ausgabenGesamt} bold />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -252,17 +261,26 @@ export default function BilanzenPage() {
|
|||||||
value={data.bilanz.aktiva.forderungen.betrag}
|
value={data.bilanz.aktiva.forderungen.betrag}
|
||||||
indent
|
indent
|
||||||
/>
|
/>
|
||||||
<Row
|
<div className="flex justify-between py-2 border-b border-gray-50">
|
||||||
label={`Bank / Kasse (${data.bilanz.aktiva.bank.anzahl} bezahlt)`}
|
<span className="text-sm ml-4 text-gray-700 flex items-center gap-1.5">
|
||||||
value={data.bilanz.aktiva.bank.betrag}
|
<Landmark className="h-3.5 w-3.5 text-blue-500" />
|
||||||
indent
|
{`Bank (${data.bilanz.aktiva.bank.anzahl} bezahlte Rechnungen + Einnahmen)`}
|
||||||
/>
|
</span>
|
||||||
|
<span className="text-sm tabular-nums text-gray-800">{formatCurrency(data.bilanz.aktiva.bank.betrag)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-50">
|
||||||
|
<span className="text-sm ml-4 text-gray-700 flex items-center gap-1.5">
|
||||||
|
<Banknote className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
Kasse (Saldo sonstige Belege)
|
||||||
|
</span>
|
||||||
|
<span className="text-sm tabular-nums text-gray-800">{formatCurrency(data.bilanz.aktiva.kasse.betrag)}</span>
|
||||||
|
</div>
|
||||||
<Row label="Summe Aktiva" value={data.bilanz.aktiva.summe} bold />
|
<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">
|
<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" />
|
<Info className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Bank/Kasse ist eine Näherung auf Basis bezahlter Rechnungen (kumuliert bis Jahresende).
|
Bank enthält bezahlte Rechnungen + sonstige Bankeinnahmen abzgl. Bankausgaben. Kasse = sonstige Kasseneinnahmen abzgl. Kassenausgaben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||||
import { requireUser } from "@/session.server";
|
import { requireUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react";
|
||||||
import { formatCurrency } from "@/lib/tax";
|
import { formatCurrency } from "@/lib/tax";
|
||||||
import { EINNAHME_KATEGORIEN, EINNAHME_LABELS, type EinnahmeKategorieKey } from "@/lib/einnahmen";
|
import { EINNAHME_KATEGORIEN, EINNAHME_LABELS, type EinnahmeKategorieKey } from "@/lib/einnahmen";
|
||||||
|
|
||||||
@@ -15,53 +18,31 @@ export const handle = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
const STEUERSAETZE = [
|
||||||
|
{ label: "Keine (0 %)", value: 0 },
|
||||||
|
{ label: "7 %", value: 7 },
|
||||||
|
{ label: "19 %", value: 19 },
|
||||||
|
];
|
||||||
|
|
||||||
interface Einnahme {
|
interface Einnahme {
|
||||||
id: string;
|
id: string;
|
||||||
kategorie: EinnahmeKategorieKey;
|
kategorie: EinnahmeKategorieKey;
|
||||||
betrag: number;
|
betrag: number;
|
||||||
|
steuersatz: number;
|
||||||
|
zahlungsart: "KASSE" | "BANK";
|
||||||
datum: string;
|
datum: string;
|
||||||
|
beschreibung: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GridCell = { ids: string[]; betrag: number };
|
const emptyForm = {
|
||||||
type GridData = Record<string, Record<number, GridCell>>;
|
kategorie: "SONSTIGE_EINNAHMEN" as EinnahmeKategorieKey,
|
||||||
|
betrag: "",
|
||||||
|
steuersatz: 0,
|
||||||
|
zahlungsart: "BANK" as "KASSE" | "BANK",
|
||||||
|
datum: new Date().toISOString().slice(0, 10),
|
||||||
|
beschreibung: "",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a grid data structure from the given einnahmen array.
|
|
||||||
* The grid has the shape of { [kategorie]: { [month]: { ids: string[]; betrag: number } } }
|
|
||||||
* where each month has a list of einnahmen ids and the sum of their betrage.
|
|
||||||
*
|
|
||||||
* @param {Einnahme[]} einnahmen - The array of einnahmen to build the grid from.
|
|
||||||
* @returns {GridData} - The built grid data structure.
|
|
||||||
*/
|
|
||||||
function buildGrid(einnahmen: Einnahme[]): GridData {
|
|
||||||
const grid: GridData = {};
|
|
||||||
for (const k of EINNAHME_KATEGORIEN) {
|
|
||||||
grid[k] = {};
|
|
||||||
for (let m = 1; m <= 12; m++) grid[k][m] = { ids: [], betrag: 0 };
|
|
||||||
}
|
|
||||||
for (const e of einnahmen) {
|
|
||||||
const month = new Date(e.datum).getMonth() + 1;
|
|
||||||
if (grid[e.kategorie]?.[month] !== undefined) {
|
|
||||||
grid[e.kategorie][month].ids.push(e.id);
|
|
||||||
grid[e.kategorie][month].betrag += e.betrag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the data for the EinnahmenPage.
|
|
||||||
*
|
|
||||||
* @param {Request} request - The request object.
|
|
||||||
* @param {Object} params - The route parameters.
|
|
||||||
* @param {string} params.id - The id of the company.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Object>} - A promise resolving to an object containing the company data and the initial year.
|
|
||||||
*
|
|
||||||
* @throws {Response} - If the company is not found.
|
|
||||||
*/
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
const company = await prisma.company.findFirst({
|
const company = await prisma.company.findFirst({
|
||||||
@@ -76,7 +57,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
companyId: params.id,
|
companyId: params.id,
|
||||||
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
||||||
},
|
},
|
||||||
orderBy: { datum: "asc" },
|
orderBy: { datum: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,156 +68,110 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
id: e.id,
|
id: e.id,
|
||||||
kategorie: e.kategorie as EinnahmeKategorieKey,
|
kategorie: e.kategorie as EinnahmeKategorieKey,
|
||||||
betrag: Number(e.betrag),
|
betrag: Number(e.betrag),
|
||||||
|
steuersatz: Number(e.steuersatz),
|
||||||
|
zahlungsart: e.zahlungsart as "KASSE" | "BANK",
|
||||||
datum: e.datum.toISOString(),
|
datum: e.datum.toISOString(),
|
||||||
|
beschreibung: e.beschreibung,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The EinnahmenPage component displays a table of the company's expenses
|
|
||||||
* for the selected year. It allows the user to edit the expenses and save
|
|
||||||
* the changes.
|
|
||||||
*
|
|
||||||
* @returns {JSX.Element} - The EinnahmenPage component.
|
|
||||||
*/
|
|
||||||
export default function EinnahmenPage() {
|
export default function EinnahmenPage() {
|
||||||
const { einnahmen: initialEinnahmen, companyId, companyName, initialYear } = useLoaderData<typeof loader>();
|
const { einnahmen: initialEinnahmen, companyId, companyName, initialYear } =
|
||||||
|
useLoaderData<typeof loader>();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [year, setYear] = useState(initialYear);
|
const [year, setYear] = useState(initialYear);
|
||||||
const [grid, setGrid] = useState<GridData>(() => buildGrid(initialEinnahmen));
|
const [einnahmen, setEinnahmen] = useState<Einnahme[]>(initialEinnahmen);
|
||||||
const [loadingYear, setLoadingYear] = useState(false);
|
const [loadingYear, setLoadingYear] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<{ kategorie: string; month: number } | null>(null);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [cellInput, setCellInput] = useState("");
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [savingCell, setSavingCell] = useState<{ kategorie: string; month: number } | null>(null);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
|
||||||
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
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) {
|
async function loadYear(y: number) {
|
||||||
setEditingCell(null);
|
|
||||||
setYear(y);
|
setYear(y);
|
||||||
setLoadingYear(true);
|
setLoadingYear(true);
|
||||||
const res = await fetch(`/api/einnahmen?companyId=${companyId}&year=${y}`);
|
const res = await fetch(`/api/einnahmen?companyId=${companyId}&year=${y}`);
|
||||||
const data: Einnahme[] = await res.json();
|
const data: Einnahme[] = await res.json();
|
||||||
setGrid(buildGrid(data));
|
setEinnahmen(data);
|
||||||
setLoadingYear(false);
|
setLoadingYear(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(kategorie: string, month: number) {
|
function openCreate() {
|
||||||
if (savingCell) return;
|
setEditingId(null);
|
||||||
const cell = grid[kategorie]?.[month];
|
setForm({ ...emptyForm, datum: `${year}-01-01` });
|
||||||
setEditingCell({ kategorie, month });
|
setDialogOpen(true);
|
||||||
setCellInput(cell?.betrag ? String(cell.betrag) : "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCell = useCallback(async () => {
|
function openEdit(e: Einnahme) {
|
||||||
if (!editingCell) return;
|
setEditingId(e.id);
|
||||||
const { kategorie, month } = editingCell;
|
setForm({
|
||||||
setEditingCell(null);
|
kategorie: e.kategorie,
|
||||||
|
betrag: String(e.betrag),
|
||||||
const newBetrag = parseFloat(cellInput.replace(",", ".")) || 0;
|
steuersatz: e.steuersatz,
|
||||||
const cell = grid[kategorie]?.[month];
|
zahlungsart: e.zahlungsart,
|
||||||
const oldBetrag = cell?.betrag ?? 0;
|
datum: e.datum.slice(0, 10),
|
||||||
|
beschreibung: e.beschreibung ?? "",
|
||||||
if (newBetrag === oldBetrag) return;
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
setSavingCell({ kategorie, month });
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
kategorie: form.kategorie,
|
||||||
|
betrag: parseFloat(form.betrag),
|
||||||
|
steuersatz: form.steuersatz,
|
||||||
|
zahlungsart: form.zahlungsart,
|
||||||
|
datum: form.datum,
|
||||||
|
beschreibung: form.beschreibung || undefined,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (newBetrag <= 0 && cell?.ids.length) {
|
if (editingId) {
|
||||||
await Promise.all(
|
await fetch(`/api/einnahmen/${editingId}`, {
|
||||||
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",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
kategorie,
|
|
||||||
betrag: newBetrag,
|
|
||||||
datum: `${year}-${String(month).padStart(2, "0")}-01`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
setGrid((g) => {
|
} else {
|
||||||
const next = { ...g };
|
await fetch("/api/einnahmen", {
|
||||||
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ ...payload, companyId }),
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadYear(year);
|
||||||
revalidate();
|
revalidate();
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCell(null);
|
setSaving(false);
|
||||||
}
|
|
||||||
}, [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]
|
async function handleDelete(id: string) {
|
||||||
.filter((k) => rowTotals[k] > 0)
|
if (!confirm("Eintrag wirklich löschen?")) return;
|
||||||
.sort((a, b) => rowTotals[b] - rowTotals[a])
|
setDeleting(id);
|
||||||
.slice(0, 2);
|
await fetch(`/api/einnahmen/${id}`, { method: "DELETE" });
|
||||||
|
setDeleting(null);
|
||||||
|
await loadYear(year);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnungen
|
||||||
|
const gesamt = einnahmen.reduce((s, e) => s + e.betrag, 0);
|
||||||
|
const kasseGesamt = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + e.betrag, 0);
|
||||||
|
const bankGesamt = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + e.betrag, 0);
|
||||||
|
const ustGesamt = einnahmen.reduce((s, e) => {
|
||||||
|
const rate = e.steuersatz / 100;
|
||||||
|
return s + (rate > 0 ? Math.round((e.betrag / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -252,13 +187,19 @@ export default function EinnahmenPage() {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900">Sonstige Einnahmen</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Sonstige Einnahmen</h1>
|
||||||
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div className="flex items-center gap-3">
|
||||||
value={year}
|
<select
|
||||||
onChange={(e) => loadYear(Number(e.target.value))}
|
value={year}
|
||||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
onChange={(e) => loadYear(Number(e.target.value))}
|
||||||
>
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
>
|
||||||
</select>
|
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button onClick={openCreate} className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neue Einnahme
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zusammenfassung */}
|
{/* Zusammenfassung */}
|
||||||
@@ -266,145 +207,271 @@ export default function EinnahmenPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
|
<p className="text-xs text-gray-500 mb-1">Gesamt {year}</p>
|
||||||
<p className="text-xl font-bold text-emerald-600">{formatCurrency(grandTotal)}</p>
|
<p className="text-xl font-bold text-emerald-600">{formatCurrency(gesamt)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5 pb-5">
|
<CardContent className="pt-5 pb-5">
|
||||||
<p className="text-xs text-gray-500 mb-1">Kategorien mit Einträgen</p>
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<p className="text-xl font-bold text-gray-900">
|
<Landmark className="h-3 w-3 text-gray-400" />
|
||||||
{EINNAHME_KATEGORIEN.filter((k) => rowTotals[k] > 0).length}
|
<p className="text-xs text-gray-500">Bank</p>
|
||||||
</p>
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(bankGesamt)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<Banknote className="h-3 w-3 text-gray-400" />
|
||||||
|
<p className="text-xs text-gray-500">Kasse</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(kasseGesamt)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Umsatzsteuer (enthalten)</p>
|
||||||
|
<p className="text-xl font-bold text-amber-600">{formatCurrency(ustGesamt)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Matrix-Tabelle */}
|
{/* Liste */}
|
||||||
{loadingYear ? (
|
{loadingYear ? (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
Lade Einnahmen...
|
Lade Einnahmen...
|
||||||
</div>
|
</div>
|
||||||
|
) : einnahmen.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center text-gray-400">
|
||||||
|
<p className="text-sm">Noch keine Einnahmen für {year} erfasst.</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Erste Einnahme hinzufügen
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm table-fixed border-collapse">
|
<table className="w-full text-sm border-collapse">
|
||||||
<colgroup>
|
|
||||||
<col style={{ width: "180px" }} />
|
|
||||||
{MONTHS.map((_, i) => <col key={i} style={{ width: "72px" }} />)}
|
|
||||||
<col style={{ width: "88px" }} />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-slate-50">
|
<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">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Datum</th>
|
||||||
Kategorie
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Kategorie</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Brutto</th>
|
||||||
{MONTHS.map((m) => (
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">MwSt.</th>
|
||||||
<th key={m} className="px-1 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Netto</th>
|
||||||
{m}
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">Zahlung</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Notiz</th>
|
||||||
))}
|
<th className="px-3 py-2.5 w-16" />
|
||||||
<th className="px-2 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
|
||||||
Gesamt
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-50">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{EINNAHME_KATEGORIEN.map((kat) => (
|
{einnahmen.map((e) => {
|
||||||
<tr key={kat} className="hover:bg-slate-50/60 group">
|
const rate = e.steuersatz / 100;
|
||||||
<td className="px-3 py-1.5 text-xs font-medium text-slate-700 truncate">
|
const netto = rate > 0 ? Math.round((e.betrag / (1 + rate)) * 100) / 100 : e.betrag;
|
||||||
{EINNAHME_LABELS[kat]}
|
return (
|
||||||
</td>
|
<tr key={e.id} className="hover:bg-slate-50/60 group">
|
||||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
const cell = grid[kat]?.[month];
|
{new Date(e.datum).toLocaleDateString("de-DE")}
|
||||||
const betrag = cell?.betrag ?? 0;
|
</td>
|
||||||
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
|
<td className="px-3 py-2.5 text-slate-700 font-medium">
|
||||||
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
|
{EINNAHME_LABELS[e.kategorie]}
|
||||||
|
</td>
|
||||||
return (
|
<td className="px-3 py-2.5 text-right font-medium text-emerald-700 whitespace-nowrap">
|
||||||
<td
|
{formatCurrency(e.betrag)}
|
||||||
key={month}
|
</td>
|
||||||
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-emerald-50" : ""}`}
|
<td className="px-3 py-2.5 text-center">
|
||||||
>
|
{e.steuersatz > 0 ? (
|
||||||
{isSaving ? (
|
<Badge variant="secondary">{e.steuersatz} %</Badge>
|
||||||
<span className="flex justify-end pr-1">
|
) : (
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-slate-400" />
|
<span className="text-slate-300 text-xs">—</span>
|
||||||
</span>
|
)}
|
||||||
) : isEditing ? (
|
</td>
|
||||||
<input
|
<td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
|
||||||
type="number"
|
{formatCurrency(netto)}
|
||||||
step="0.01"
|
</td>
|
||||||
min="0"
|
<td className="px-3 py-2.5 text-center">
|
||||||
autoFocus
|
{e.zahlungsart === "BANK" ? (
|
||||||
value={cellInput}
|
<span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
|
||||||
onChange={(e) => setCellInput(e.target.value)}
|
<Landmark className="h-3 w-3" /> Bank
|
||||||
onBlur={commitCell}
|
</span>
|
||||||
onKeyDown={(e) => {
|
) : (
|
||||||
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
|
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||||
if (e.key === "Escape") setEditingCell(null);
|
<Banknote className="h-3 w-3" /> Kasse
|
||||||
}}
|
</span>
|
||||||
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"
|
)}
|
||||||
/>
|
</td>
|
||||||
) : (
|
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
|
||||||
<button
|
{e.beschreibung ?? ""}
|
||||||
type="button"
|
</td>
|
||||||
onClick={() => startEdit(kat, month)}
|
<td className="px-3 py-2.5">
|
||||||
disabled={!!savingCell}
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
|
<button
|
||||||
${betrag > 0
|
onClick={() => openEdit(e)}
|
||||||
? "text-slate-800 font-medium"
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
|
title="Bearbeiten"
|
||||||
hover:bg-emerald-50 focus:outline-none focus:ring-1 focus:ring-emerald-300`}
|
>
|
||||||
>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
{betrag > 0
|
</button>
|
||||||
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
<button
|
||||||
: "—"}
|
onClick={() => handleDelete(e.id)}
|
||||||
</button>
|
disabled={deleting === e.id}
|
||||||
)}
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
</td>
|
title="Löschen"
|
||||||
);
|
>
|
||||||
})}
|
{deleting === e.id ? (
|
||||||
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-emerald-600" : "text-slate-300"}`}>
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
{rowTotals[kat] > 0
|
) : (
|
||||||
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
: "—"}
|
)}
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
<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>
|
<td colSpan={2} className="px-4 py-2.5 text-xs font-bold text-slate-700">Gesamt</td>
|
||||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-emerald-600">{formatCurrency(gesamt)}</td>
|
||||||
<td key={month} className={`px-1 py-2.5 text-right text-xs font-bold ${colTotals[month] > 0 ? "text-slate-800" : "text-slate-300"}`}>
|
<td />
|
||||||
{colTotals[month] > 0
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-slate-600">
|
||||||
? colTotals[month].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
{formatCurrency(gesamt - ustGesamt)}
|
||||||
: "—"}
|
|
||||||
</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>
|
</td>
|
||||||
|
<td colSpan={3} />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "Einnahme bearbeiten" : "Neue Einnahme"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.datum}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Betrag (brutto, €) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
value={form.betrag}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, betrag: e.target.value }))}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kategorie <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.kategorie}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, kategorie: e.target.value as EinnahmeKategorieKey }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
{EINNAHME_KATEGORIEN.map((k) => (
|
||||||
|
<option key={k} value={k}>{EINNAHME_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Zahlungsweg</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["BANK", "KASSE"] as const).map((za) => (
|
||||||
|
<button
|
||||||
|
key={za}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, zahlungsart: za }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||||
|
${form.zahlungsart === za
|
||||||
|
? za === "BANK"
|
||||||
|
? "bg-blue-50 border-blue-300 text-blue-700"
|
||||||
|
: "bg-amber-50 border-amber-300 text-amber-700"
|
||||||
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{za === "BANK" ? <Landmark className="h-3.5 w-3.5" /> : <Banknote className="h-3.5 w-3.5" />}
|
||||||
|
{za === "BANK" ? "Bank" : "Kasse"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Steuersatz</label>
|
||||||
|
<select
|
||||||
|
value={form.steuersatz}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, steuersatz: Number(e.target.value) }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
{STEUERSAETZE.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vorschau Nettobetrag */}
|
||||||
|
{parseFloat(form.betrag) > 0 && form.steuersatz > 0 && (
|
||||||
|
<div className="rounded-lg bg-emerald-50 border border-emerald-100 px-3 py-2 text-xs text-emerald-700 space-y-0.5">
|
||||||
|
<p><strong>Brutto:</strong> {formatCurrency(parseFloat(form.betrag))}</p>
|
||||||
|
<p><strong>USt. ({form.steuersatz} %):</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * (form.steuersatz / 100) * 100) / 100)}</p>
|
||||||
|
<p><strong>Netto:</strong> {formatCurrency(Math.round((parseFloat(form.betrag) / (1 + form.steuersatz / 100)) * 100) / 100)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !formValid}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editingId ? "Speichern" : "Hinzufügen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { requireUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { ChevronLeft, Loader2, Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { formatCurrency } from "@/lib/tax";
|
||||||
|
import { useLoaderData, Link, useRevalidator } from "react-router";
|
||||||
|
|
||||||
|
type Transaction = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
account: 'kasse' | 'bank';
|
||||||
|
type: 'einlage' | 'entnahme';
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await requireUser(request);
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!company) throw new Response("Company not Found", { status: 404 });
|
||||||
|
|
||||||
|
const buchungen = await prisma.buchung.findMany({
|
||||||
|
where: { companyId: company.id },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
|
||||||
|
id: b.id,
|
||||||
|
date: b.date.toISOString().split('T')[0],
|
||||||
|
account: b.account === 'BANK' ? 'bank' : 'kasse',
|
||||||
|
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
|
||||||
|
amount: Number(b.amount),
|
||||||
|
description: b.description || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
||||||
|
return {
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
transactions,
|
||||||
|
balance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyMoney() {
|
||||||
|
const { transactions: initialTransactions, companyId, companyName, balance } =
|
||||||
|
useLoaderData<typeof loader>();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
account: 'kasse' as 'kasse' | 'bank',
|
||||||
|
type: 'einlage' as 'einlage' | 'entnahme',
|
||||||
|
amount: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingTransaction(null);
|
||||||
|
setForm({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
account: 'kasse',
|
||||||
|
type: 'einlage',
|
||||||
|
amount: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(transaction: Transaction) {
|
||||||
|
setEditingTransaction(transaction);
|
||||||
|
setForm({
|
||||||
|
date: transaction.date,
|
||||||
|
account: transaction.account,
|
||||||
|
type: transaction.type,
|
||||||
|
amount: String(transaction.amount),
|
||||||
|
description: transaction.description,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
date: form.date,
|
||||||
|
account: form.account,
|
||||||
|
type: form.type,
|
||||||
|
amount: parseFloat(form.amount),
|
||||||
|
description: form.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingTransaction) {
|
||||||
|
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch(`/api/companies/${companyId}/money`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
revalidate();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Transaktion wirklich löschen?")) return;
|
||||||
|
setDeleting(id);
|
||||||
|
await fetch(`/api/companies/${companyId}/money?transactionId=${id}`, { method: "DELETE" });
|
||||||
|
setDeleting(null);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTransactions = [...initialTransactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
const kasseBalance = initialTransactions
|
||||||
|
.filter((t) => t.account === 'kasse')
|
||||||
|
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
||||||
|
const bankBalance = initialTransactions
|
||||||
|
.filter((t) => t.account === 'bank')
|
||||||
|
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
||||||
|
|
||||||
|
const formValid = form.date && form.amount && parseFloat(form.amount) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to={`/companies/${companyId}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Kasse und Bank</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
{companyName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neue Transaktion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zusammenfassung */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Kasse (Saldo)</p>
|
||||||
|
<p className="text-xl font-bold text-indigo-700">{formatCurrency(kasseBalance)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Bank (Saldo)</p>
|
||||||
|
<p className="text-xl font-bold text-teal-700">{formatCurrency(bankBalance)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5 pb-5">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Gesamter Kontostand</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{formatCurrency(balance)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
{sortedTransactions.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center text-gray-400">
|
||||||
|
<p className="text-sm">Noch keine Transaktionen erfasst.</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Erste Transaktion hinzufügen
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Konto
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Beschreibung
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Betrag
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 w-16" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{sortedTransactions.map((transaction) => (
|
||||||
|
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||||
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
{transaction.date}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||||
|
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-700">
|
||||||
|
{transaction.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||||
|
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(transaction)}
|
||||||
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(transaction.id)}
|
||||||
|
disabled={deleting === transaction.id}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
{deleting === transaction.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog: Anlegen / Bearbeiten */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingTransaction ? "Transaktion bearbeiten" : "Neue Transaktion"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Konto <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.account}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="kasse">Kasse</option>
|
||||||
|
<option value="bank">Bank</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Typ <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="einlage">Einlage</option>
|
||||||
|
<option value="entnahme">Entnahme</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Betrag (€) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="z.B. Barentnahme, Gehalt"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !formValid}>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{editingTransaction ? "Speichern" : "Hinzufügen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, Scale, TrendingDown, TrendingUp
|
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase, Scale, TrendingDown, TrendingUp, PackageSearch, DollarSign
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -263,6 +263,26 @@ export default function CompanyPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to={`/companies/${id}/anlagevermoegen`} className="block">
|
||||||
|
<Card className="hover:border-violet-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-violet-50">
|
||||||
|
<PackageSearch className="h-4 w-4 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Anlagevermögen</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/companies/${id}/money`} className="block">
|
||||||
|
<Card className="hover:border-cyan-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-50">
|
||||||
|
<DollarSign className="h-4 w-4 text-cyan-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Finanzmittel</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `betriebsausgaben` ADD COLUMN `steuersatz` DECIMAL(5, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN `zahlungsart` ENUM('KASSE', 'BANK') NOT NULL DEFAULT 'BANK';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `betriebseinnahmen` ADD COLUMN `steuersatz` DECIMAL(5, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN `zahlungsart` ENUM('KASSE', 'BANK') NOT NULL DEFAULT 'BANK';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `companies` ADD COLUMN `money` JSON NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `money` on the `companies` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `companies` DROP COLUMN `money`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `buchungen` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`companyId` VARCHAR(191) NOT NULL,
|
||||||
|
`date` DATETIME(3) NOT NULL,
|
||||||
|
`account` ENUM('KASSE', 'BANK') NOT NULL,
|
||||||
|
`type` ENUM('EINLAGE', 'ENTNAHME') NOT NULL,
|
||||||
|
`amount` DECIMAL(10, 2) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `buchungen_companyId_idx`(`companyId`),
|
||||||
|
INDEX `buchungen_date_idx`(`date`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -73,12 +73,40 @@ model Company {
|
|||||||
betriebsausgaben Betriebsausgabe[]
|
betriebsausgaben Betriebsausgabe[]
|
||||||
betriebseinnahmen Betriebseinnahme[]
|
betriebseinnahmen Betriebseinnahme[]
|
||||||
anlagegueter Anlagegut[]
|
anlagegueter Anlagegut[]
|
||||||
|
buchungen Buchung[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@map("companies")
|
@@map("companies")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TransactionAccount {
|
||||||
|
KASSE
|
||||||
|
BANK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionType {
|
||||||
|
EINLAGE
|
||||||
|
ENTNAHME
|
||||||
|
}
|
||||||
|
|
||||||
|
model Buchung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime
|
||||||
|
account TransactionAccount
|
||||||
|
type TransactionType
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
description String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([companyId])
|
||||||
|
@@index([date])
|
||||||
|
@@map("buchungen")
|
||||||
|
}
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
companyId String
|
companyId String
|
||||||
@@ -146,6 +174,11 @@ enum InvoiceStatus {
|
|||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Zahlungsart {
|
||||||
|
KASSE
|
||||||
|
BANK
|
||||||
|
}
|
||||||
|
|
||||||
enum EinnahmeKategorie {
|
enum EinnahmeKategorie {
|
||||||
FUSSPFLEGE
|
FUSSPFLEGE
|
||||||
PRIVATEINLAGEN
|
PRIVATEINLAGEN
|
||||||
@@ -165,6 +198,8 @@ model Betriebseinnahme {
|
|||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
kategorie EinnahmeKategorie
|
kategorie EinnahmeKategorie
|
||||||
betrag Decimal @db.Decimal(10, 2)
|
betrag Decimal @db.Decimal(10, 2)
|
||||||
|
steuersatz Decimal @db.Decimal(5, 2) @default(0)
|
||||||
|
zahlungsart Zahlungsart @default(BANK)
|
||||||
datum DateTime
|
datum DateTime
|
||||||
beschreibung String? @db.Text
|
beschreibung String? @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -219,6 +254,8 @@ model Betriebsausgabe {
|
|||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
kategorie AusgabeKategorie
|
kategorie AusgabeKategorie
|
||||||
betrag Decimal @db.Decimal(10, 2)
|
betrag Decimal @db.Decimal(10, 2)
|
||||||
|
steuersatz Decimal @db.Decimal(5, 2) @default(0)
|
||||||
|
zahlungsart Zahlungsart @default(BANK)
|
||||||
datum DateTime
|
datum DateTime
|
||||||
beschreibung String? @db.Text
|
beschreibung String? @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* reset-password.ts
|
||||||
|
*
|
||||||
|
* Emergency recovery script – resets the password for any user.
|
||||||
|
* Run directly inside the container via docker exec.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* docker exec -it annas_app node scripts/reset-password.js --username admin --password newpassword
|
||||||
|
*
|
||||||
|
* During development:
|
||||||
|
* npx ts-node --compiler-options '{"module":"CommonJS"}' scripts/reset-password.ts --username admin --password newpassword
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const get = (flag) => {
|
||||||
|
const i = args.indexOf(flag);
|
||||||
|
return i !== -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const username = get("--username");
|
||||||
|
const password = get("--password");
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error("Usage: reset-password --username <username> --password <newpassword>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const { username, password } = parseArgs();
|
||||||
|
if (password.length < 8) {
|
||||||
|
console.error("ERROR: Password must be at least 8 characters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findFirst({ where: { username } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`ERROR: No user found with username "${username}".`);
|
||||||
|
const all = await prisma.user.findMany({ select: { username: true, email: true, role: true } });
|
||||||
|
console.error("Available users:");
|
||||||
|
all.forEach((u) => console.error(` - ${u.username} (${u.email}) [${u.role}]`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||||
|
console.log(`✅ Password reset for user "${username}" (${user.email}).`);
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* setup-admin.ts
|
||||||
|
*
|
||||||
|
* Ensures the initial admin user (username: "admin") exists.
|
||||||
|
* Called automatically on every container start.
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - ADMIN_PASSWORD set → create or update admin with that password
|
||||||
|
* - ADMIN_PASSWORD not set, admin exists → nothing to do, skip silently
|
||||||
|
* - ADMIN_PASSWORD not set, admin missing → generate a random password,
|
||||||
|
* create the user, print to logs
|
||||||
|
*
|
||||||
|
* Manual usage:
|
||||||
|
* ADMIN_PASSWORD=secret npx ts-node --compiler-options '{"module":"CommonJS"}' scripts/setup-admin.ts
|
||||||
|
* docker exec -e ADMIN_PASSWORD=secret annas_app node scripts/setup-admin.cjs
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
function generatePassword(length = 16) {
|
||||||
|
// URL-safe characters only so the password is easy to copy from logs
|
||||||
|
return randomBytes(Math.ceil((length * 3) / 4))
|
||||||
|
.toString("base64url")
|
||||||
|
.slice(0, length);
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const explicitPassword = process.env.ADMIN_PASSWORD ?? process.argv[2];
|
||||||
|
const existing = await prisma.user.findUnique({ where: { username: "admin" } });
|
||||||
|
// Admin exists and no explicit password override → nothing to do
|
||||||
|
if (existing && !explicitPassword) {
|
||||||
|
console.log("[setup-admin] Admin user already exists – skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let password;
|
||||||
|
let generated = false;
|
||||||
|
if (explicitPassword) {
|
||||||
|
if (explicitPassword.length < 8) {
|
||||||
|
console.error("[setup-admin] ERROR: ADMIN_PASSWORD must be at least 8 characters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
password = explicitPassword;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No admin user yet and no password given → auto-generate
|
||||||
|
password = generatePassword(16);
|
||||||
|
generated = true;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { username: "admin" },
|
||||||
|
update: { passwordHash, role: "ADMIN" },
|
||||||
|
create: {
|
||||||
|
username: "admin",
|
||||||
|
email: "admin@localhost",
|
||||||
|
name: "Administrator",
|
||||||
|
passwordHash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (generated) {
|
||||||
|
console.log("");
|
||||||
|
console.log("╔══════════════════════════════════════════════════╗");
|
||||||
|
console.log("║ ADMIN-ZUGANGSDATEN (einmalig) ║");
|
||||||
|
console.log("╠══════════════════════════════════════════════════╣");
|
||||||
|
console.log(`║ Benutzername : admin ║`);
|
||||||
|
console.log(`║ Passwort : ${password.padEnd(32)} ║`);
|
||||||
|
console.log("╠══════════════════════════════════════════════════╣");
|
||||||
|
console.log("║ Bitte sofort nach dem ersten Login ändern! ║");
|
||||||
|
console.log("╚══════════════════════════════════════════════════╝");
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log(`[setup-admin] ✅ Admin user ${existing ? "updated" : "created"} (username: admin).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("[setup-admin] FATAL:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
Reference in New Issue
Block a user