feat: add financial transactions management for companies

- Implemented a new route for managing financial transactions (money) for companies, including creating, editing, and deleting transactions.
- Added a new model `Buchung` to represent transactions with fields for date, account type, transaction type, amount, and description.
- Updated the `companies` model to include a relation to the new `Buchung` model.
- Enhanced the company overview page to link to the new financial transactions page.
- Added migration scripts to create the necessary database tables and fields for the new functionality.
- Created utility scripts for resetting the admin password and setting up the initial admin user.
This commit is contained in:
hwinkel
2026-03-24 19:25:48 +01:00
parent 6d8c4b615f
commit d582c748a2
29 changed files with 2464 additions and 815 deletions
-11
View File
@@ -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"
]
}
}
+50 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { startCleanupScheduler } from "@/lib/cleanup.server"; import { startCleanupScheduler } from "./lib/cleanup.server";
import { PassThrough } from "node:stream"; import { 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";
+6
View File
@@ -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;
+52
View File
@@ -0,0 +1,52 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0),
beschreibung: z.string().optional(),
aktiv: z.boolean(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const asset = await prisma.anlagegut.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.anlagegut.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.anlagegut.update({
where: { id: params.id },
data: {
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...updated,
anschaffungskosten: Number(updated.anschaffungskosten),
restwert: Number(updated.restwert),
anschaffungsdatum: updated.anschaffungsdatum.toISOString(),
});
}
+104
View File
@@ -0,0 +1,104 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { afaFuerJahr, buchwert, assetStatus } from "@/lib/afa";
const createSchema = z.object({
companyId: z.string().min(1),
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0).default(0),
beschreibung: z.string().optional(),
aktiv: z.boolean().default(true),
});
function toRaw(a: {
anschaffungskosten: unknown;
nutzungsdauerJahre: number;
restwert: unknown;
anschaffungsdatum: Date;
aktiv: boolean;
}) {
return {
anschaffungskosten: Number(a.anschaffungskosten),
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: Number(a.restwert),
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
aktiv: a.aktiv,
};
}
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const assets = await prisma.anlagegut.findMany({
where: { companyId },
orderBy: { anschaffungsdatum: "asc" },
});
return Response.json({
year,
assets: assets.map((a) => {
const raw = toRaw(a);
return {
id: a.id,
bezeichnung: a.bezeichnung,
beschreibung: a.beschreibung,
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
anschaffungskosten: raw.anschaffungskosten,
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: raw.restwert,
aktiv: a.aktiv,
afaJahr: afaFuerJahr(raw, year),
buchwert: buchwert(raw, year),
status: assetStatus(raw, year),
};
}),
});
}
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const asset = await prisma.anlagegut.create({
data: {
companyId: parsed.data.companyId,
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...asset,
anschaffungskosten: Number(asset.anschaffungskosten),
restwert: Number(asset.restwert),
anschaffungsdatum: asset.anschaffungsdatum.toISOString(),
}, { status: 201 });
}
+10 -1
View File
@@ -6,6 +6,8 @@ import { AusgabeKategorie } from "@prisma/client";
const updateSchema = z.object({ 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(),
});
} }
+11 -1
View File
@@ -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
View File
@@ -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,
}, },
}, },
}); });
+110
View File
@@ -0,0 +1,110 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
type Transaction = {
id: string;
date: string;
account: "kasse" | "bank";
type: "einlage" | "entnahme";
amount: number;
description: string;
};
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; }): Transaction {
return {
id: buchung.id,
date: buchung.date.toISOString().split("T")[0],
account: buchung.account === "KASSE" ? "kasse" : "bank",
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
amount: Number(buchung.amount),
description: buchung.description || "",
};
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const buchungen = await prisma.buchung.findMany({
where: { companyId: id },
orderBy: { date: "desc" },
});
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const url = new URL(request.url);
const transactionId = url.searchParams.get("transactionId");
const method = request.method;
const data = await request.json().catch(() => ({}));
if (method === "POST") {
const amount = Number(data.amount);
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
await prisma.buchung.create({
data: {
companyId: id,
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
} else if (method === "PUT") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
const amount = Number(data.amount);
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
await prisma.buchung.update({
where: { id: transactionId },
data: {
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
} else if (method === "DELETE") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
} else {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const buchungen = await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" } });
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
+10 -1
View File
@@ -6,6 +6,8 @@ import { EinnahmeKategorie } from "@prisma/client";
const updateSchema = z.object({ 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(),
});
} }
+11 -1
View File
@@ -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 }
); );
} }
+424 -234
View File
@@ -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);
setForm(emptyForm);
setDialogOpen(true);
}
async function handleCreate(data: FormData) { function openEdit(asset: Asset) {
setEditingAsset(asset);
setForm({
bezeichnung: asset.bezeichnung,
anschaffungsdatum: asset.anschaffungsdatum.slice(0, 10),
anschaffungskosten: String(asset.anschaffungskosten),
nutzungsdauerJahre: String(asset.nutzungsdauerJahre),
restwert: String(asset.restwert),
beschreibung: asset.beschreibung ?? "",
aktiv: asset.aktiv,
});
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
bezeichnung: form.bezeichnung,
anschaffungsdatum: form.anschaffungsdatum,
anschaffungskosten: parseFloat(form.anschaffungskosten),
nutzungsdauerJahre: parseInt(form.nutzungsdauerJahre),
restwert: parseFloat(form.restwert) || 0,
beschreibung: form.beschreibung || undefined,
aktiv: form.aktiv,
};
try {
if (editingAsset) {
await fetch(`/api/anlagevermoegen/${editingAsset.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await fetch("/api/anlagevermoegen", { await fetch("/api/anlagevermoegen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, companyId }), body: JSON.stringify({ ...payload, companyId }),
}); });
setOpen(false);
fetchAssets();
revalidate();
} }
setDialogOpen(false);
async function handleEdit(data: FormData) { await loadYear(year);
if (!editAsset) return;
await fetch(`/api/anlagevermoegen/${editAsset.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setEditAsset(null);
fetchAssets();
revalidate(); 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">
<td className="px-4 py-3">
<p className="font-medium text-slate-800">{asset.bezeichnung}</p> <p className="font-medium text-slate-800">{asset.bezeichnung}</p>
{asset.beschreibung && ( {asset.beschreibung && (
<p className="text-xs text-slate-400 truncate max-w-xs">{asset.beschreibung}</p> <p className="text-xs text-slate-400 truncate max-w-xs">
{asset.beschreibung}
</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 ? (
<span className="text-rose-600">{formatCurrency(asset.afaJahr)}</span>
) : (
<span className="text-slate-300"></span>
)}
</td> </td>
<td className="px-4 py-3 text-right font-medium text-amber-700"> <td className="px-3 py-2.5 text-right font-medium text-indigo-700 whitespace-nowrap">
{formatCurrency(asset.buchwert)} {formatCurrency(asset.buchwertJahr)}
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-3 py-2.5 text-center">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${s.className}`}> <Badge variant={STATUS_VARIANTS[asset.statusLabel] ?? "outline"}>
{s.label} {asset.statusLabel}
</span> </Badge>
</td> </td>
<td className="px-4 py-3"> <td className="px-3 py-2.5">
<div className="flex justify-end gap-1"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" onClick={() => setEditAsset(asset)}> <button
<Edit className="h-4 w-4" /> onClick={() => openEdit(asset)}
</Button> className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
<Button title="Bearbeiten"
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(asset.id)}
> >
<Trash2 className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </button>
<button
onClick={() => handleDelete(asset.id)}
disabled={deleting === asset.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === asset.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div> </div>
</td> </td>
</tr> </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>
); );
} }
+320 -229
View File
@@ -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;
setSavingCell({ kategorie, month });
try {
if (newBetrag <= 0 && cell?.ids.length) {
// Löschen aller Records für diese Zelle
await Promise.all(
cell.ids.map((id) => fetch(`/api/ausgaben/${id}`, { method: "DELETE" }))
);
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [], betrag: 0 } };
return next;
}); });
} else if (newBetrag > 0 && cell?.ids.length === 1) { setDialogOpen(true);
// Update des bestehenden Records }
await fetch(`/api/ausgaben/${cell.ids[0]}`, {
async function handleSave() {
setSaving(true);
const payload = {
kategorie: form.kategorie,
betrag: parseFloat(form.betrag),
steuersatz: form.steuersatz,
zahlungsart: form.zahlungsart,
datum: form.datum,
beschreibung: form.beschreibung || undefined,
};
try {
if (editingId) {
await fetch(`/api/ausgaben/${editingId}`, {
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);
}
}
async function handleDelete(id: string) {
if (!confirm("Eintrag wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/ausgaben/${id}`, { method: "DELETE" });
setDeleting(null);
await loadYear(year);
revalidate();
} }
}, [editingCell, cellInput, grid, companyId, year, revalidate]);
// Berechnungen // Berechnungen
const rowTotals: Record<string, number> = {}; const gesamt = ausgaben.reduce((s, a) => s + a.betrag, 0);
const colTotals: Record<number, number> = {}; const kasseGesamt = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + a.betrag, 0);
let grandTotal = 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);
for (const k of AUSGABE_KATEGORIEN) { const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
rowTotals[k] = 0;
for (let m = 1; m <= 12; m++) {
const b = grid[k]?.[m]?.betrag ?? 0;
rowTotals[k] += b;
colTotals[m] = (colTotals[m] ?? 0) + b;
grandTotal += b;
}
}
const topKategorien = [...AUSGABE_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
return ( return (
<div> <div>
@@ -228,6 +187,7 @@ 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>
<div className="flex items-center gap-3">
<select <select
value={year} value={year}
onChange={(e) => loadYear(Number(e.target.value))} onChange={(e) => loadYear(Number(e.target.value))}
@@ -235,6 +195,11 @@ export default function AusgabenPage() {
> >
{years.map((y) => <option key={y} value={y}>{y}</option>)} {years.map((y) => <option key={y} value={y}>{y}</option>)}
</select> </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> </CardContent>
</Card> </Card>
{topKategorien.map((k) => ( <Card>
<Card key={k}>
<CardContent className="pt-5 pb-5"> <CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1 truncate">{KATEGORIE_LABELS[k]}</p> <div className="flex items-center gap-1 mb-1">
<p className="text-xl font-bold text-gray-900">{formatCurrency(rowTotals[k])}</p> <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.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]}
</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
const cell = grid[kat]?.[month];
const betrag = cell?.betrag ?? 0;
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
return ( return (
<td <tr key={a.id} className="hover:bg-slate-50/60 group">
key={month} <td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-indigo-50" : ""}`} {new Date(a.datum).toLocaleDateString("de-DE")}
> </td>
{isSaving ? ( <td className="px-3 py-2.5 text-slate-700 font-medium">
<span className="flex justify-end pr-1"> {KATEGORIE_LABELS[a.kategorie]}
<Loader2 className="h-3 w-3 animate-spin text-slate-400" /> </td>
</span> <td className="px-3 py-2.5 text-right font-medium text-rose-700 whitespace-nowrap">
) : isEditing ? ( {formatCurrency(a.betrag)}
<input </td>
type="number" <td className="px-3 py-2.5 text-center">
step="0.01" {a.steuersatz > 0 ? (
min="0" <Badge variant="secondary">{a.steuersatz} %</Badge>
autoFocus
value={cellInput}
onChange={(e) => setCellInput(e.target.value)}
onBlur={commitCell}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
if (e.key === "Escape") setEditingCell(null);
}}
className="w-full text-right text-sm border border-indigo-300 rounded px-1 py-0.5 focus:outline-none focus:ring-1 focus:ring-indigo-400"
/>
) : ( ) : (
<button <span className="text-slate-300 text-xs"></span>
type="button"
onClick={() => startEdit(kat, month)}
disabled={!!savingCell}
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
${betrag > 0
? "text-slate-800 font-medium"
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
hover:bg-indigo-50 focus:outline-none focus:ring-1 focus:ring-indigo-300`}
>
{betrag > 0
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</button>
)} )}
</td> </td>
); <td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
})} {formatCurrency(netto)}
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-rose-600" : "text-slate-300"}`}> </td>
{rowTotals[kat] > 0 <td className="px-3 py-2.5 text-center">
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) {a.zahlungsart === "BANK" ? (
: "—"} <span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
<Landmark className="h-3 w-3" /> Bank
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
<Banknote className="h-3 w-3" /> Kasse
</span>
)}
</td>
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
{a.beschreibung ?? ""}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(a)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(a.id)}
disabled={deleting === a.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === a.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td> </td>
</tr> </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>
); );
} }
+26 -8
View File
@@ -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>
+320 -253
View File
@@ -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;
setSavingCell({ kategorie, month });
try {
if (newBetrag <= 0 && cell?.ids.length) {
await Promise.all(
cell.ids.map((id) => fetch(`/api/einnahmen/${id}`, { method: "DELETE" }))
);
setGrid((g) => {
const next = { ...g };
next[kategorie] = { ...next[kategorie], [month]: { ids: [], betrag: 0 } };
return next;
}); });
} else if (newBetrag > 0 && cell?.ids.length === 1) { setDialogOpen(true);
await fetch(`/api/einnahmen/${cell.ids[0]}`, { }
async function handleSave() {
setSaving(true);
const payload = {
kategorie: form.kategorie,
betrag: parseFloat(form.betrag),
steuersatz: form.steuersatz,
zahlungsart: form.zahlungsart,
datum: form.datum,
beschreibung: form.beschreibung || undefined,
};
try {
if (editingId) {
await fetch(`/api/einnahmen/${editingId}`, {
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);
}
}
async function handleDelete(id: string) {
if (!confirm("Eintrag wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/einnahmen/${id}`, { method: "DELETE" });
setDeleting(null);
await loadYear(year);
revalidate();
} }
}, [editingCell, cellInput, grid, companyId, year, revalidate]);
// Berechnungen // Berechnungen
const rowTotals: Record<string, number> = {}; const gesamt = einnahmen.reduce((s, e) => s + e.betrag, 0);
const colTotals: Record<number, number> = {}; const kasseGesamt = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + e.betrag, 0);
let grandTotal = 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);
for (const k of EINNAHME_KATEGORIEN) { const formValid = form.kategorie && parseFloat(form.betrag) > 0 && form.datum.length > 0;
rowTotals[k] = 0;
for (let m = 1; m <= 12; m++) {
const b = grid[k]?.[m]?.betrag ?? 0;
rowTotals[k] += b;
colTotals[m] = (colTotals[m] ?? 0) + b;
grandTotal += b;
}
}
const topKategorien = [...EINNAHME_KATEGORIEN]
.filter((k) => rowTotals[k] > 0)
.sort((a, b) => rowTotals[b] - rowTotals[a])
.slice(0, 2);
return ( return (
<div> <div>
@@ -252,6 +187,7 @@ 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>
<div className="flex items-center gap-3">
<select <select
value={year} value={year}
onChange={(e) => loadYear(Number(e.target.value))} onChange={(e) => loadYear(Number(e.target.value))}
@@ -259,6 +195,11 @@ export default function EinnahmenPage() {
> >
{years.map((y) => <option key={y} value={y}>{y}</option>)} {years.map((y) => <option key={y} value={y}>{y}</option>)}
</select> </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> </CardContent>
</Card> </Card>
{topKategorien.map((k) => ( <Card>
<Card key={k}>
<CardContent className="pt-5 pb-5"> <CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1 truncate">{EINNAHME_LABELS[k]}</p> <div className="flex items-center gap-1 mb-1">
<p className="text-xl font-bold text-gray-900">{formatCurrency(rowTotals[k])}</p> <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.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]}
</td>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => {
const cell = grid[kat]?.[month];
const betrag = cell?.betrag ?? 0;
const isEditing = editingCell?.kategorie === kat && editingCell?.month === month;
const isSaving = savingCell?.kategorie === kat && savingCell?.month === month;
return ( return (
<td <tr key={e.id} className="hover:bg-slate-50/60 group">
key={month} <td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
className={`px-1 py-1 text-right align-middle ${isEditing ? "bg-emerald-50" : ""}`} {new Date(e.datum).toLocaleDateString("de-DE")}
> </td>
{isSaving ? ( <td className="px-3 py-2.5 text-slate-700 font-medium">
<span className="flex justify-end pr-1"> {EINNAHME_LABELS[e.kategorie]}
<Loader2 className="h-3 w-3 animate-spin text-slate-400" /> </td>
</span> <td className="px-3 py-2.5 text-right font-medium text-emerald-700 whitespace-nowrap">
) : isEditing ? ( {formatCurrency(e.betrag)}
<input </td>
type="number" <td className="px-3 py-2.5 text-center">
step="0.01" {e.steuersatz > 0 ? (
min="0" <Badge variant="secondary">{e.steuersatz} %</Badge>
autoFocus
value={cellInput}
onChange={(e) => setCellInput(e.target.value)}
onBlur={commitCell}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commitCell(); }
if (e.key === "Escape") setEditingCell(null);
}}
className="w-full text-right text-sm border border-emerald-300 rounded px-1 py-0.5 focus:outline-none focus:ring-1 focus:ring-emerald-400"
/>
) : ( ) : (
<button <span className="text-slate-300 text-xs"></span>
type="button"
onClick={() => startEdit(kat, month)}
disabled={!!savingCell}
className={`w-full text-right text-xs px-1 py-0.5 rounded transition-colors
${betrag > 0
? "text-slate-800 font-medium"
: "text-transparent group-hover:text-slate-300 hover:!text-slate-500"}
hover:bg-emerald-50 focus:outline-none focus:ring-1 focus:ring-emerald-300`}
>
{betrag > 0
? betrag.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: "—"}
</button>
)} )}
</td> </td>
); <td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
})} {formatCurrency(netto)}
<td className={`px-2 py-1.5 text-right text-xs font-semibold ${rowTotals[kat] > 0 ? "text-emerald-600" : "text-slate-300"}`}> </td>
{rowTotals[kat] > 0 <td className="px-3 py-2.5 text-center">
? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) {e.zahlungsart === "BANK" ? (
: "—"} <span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
<Landmark className="h-3 w-3" /> Bank
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
<Banknote className="h-3 w-3" /> Kasse
</span>
)}
</td>
<td className="px-3 py-2.5 text-slate-400 text-xs truncate max-w-xs">
{e.beschreibung ?? ""}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(e)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(e.id)}
disabled={deleting === e.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === e.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td> </td>
</tr> </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>
); );
} }
+370
View File
@@ -0,0 +1,370 @@
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { ChevronLeft, Loader2, Plus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { formatCurrency } from "@/lib/tax";
import { useLoaderData, Link, useRevalidator } from "react-router";
type Transaction = {
id: string;
date: string;
account: 'kasse' | 'bank';
type: 'einlage' | 'entnahme';
amount: number;
description: string;
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true, name: true },
});
if (!company) throw new Response("Company not Found", { status: 404 });
const buchungen = await prisma.buchung.findMany({
where: { companyId: company.id },
orderBy: { date: 'desc' },
});
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
id: b.id,
date: b.date.toISOString().split('T')[0],
account: b.account === 'BANK' ? 'bank' : 'kasse',
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
amount: Number(b.amount),
description: b.description || '',
}));
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
return {
companyId: company.id,
companyName: company.name,
transactions,
balance,
};
}
export default function CompanyMoney() {
const { transactions: initialTransactions, companyId, companyName, balance } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
account: 'kasse' as 'kasse' | 'bank',
type: 'einlage' as 'einlage' | 'entnahme',
amount: '',
description: '',
});
function openCreate() {
setEditingTransaction(null);
setForm({
date: new Date().toISOString().split('T')[0],
account: 'kasse',
type: 'einlage',
amount: '',
description: '',
});
setDialogOpen(true);
}
function openEdit(transaction: Transaction) {
setEditingTransaction(transaction);
setForm({
date: transaction.date,
account: transaction.account,
type: transaction.type,
amount: String(transaction.amount),
description: transaction.description,
});
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
const payload = {
date: form.date,
account: form.account,
type: form.type,
amount: parseFloat(form.amount),
description: form.description,
};
try {
if (editingTransaction) {
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await fetch(`/api/companies/${companyId}/money`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
setDialogOpen(false);
revalidate();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Transaktion wirklich löschen?")) return;
setDeleting(id);
await fetch(`/api/companies/${companyId}/money?transactionId=${id}`, { method: "DELETE" });
setDeleting(null);
revalidate();
}
const sortedTransactions = [...initialTransactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const kasseBalance = initialTransactions
.filter((t) => t.account === 'kasse')
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
const bankBalance = initialTransactions
.filter((t) => t.account === 'bank')
.reduce((sum, t) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
const formValid = form.date && form.amount && parseFloat(form.amount) > 0;
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Kasse und Bank</h1>
<p className="text-gray-500 mt-1">
{companyName}
</p>
</div>
<div className="flex items-center gap-3">
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Neue Transaktion
</Button>
</div>
</div>
{/* Zusammenfassung */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Kasse (Saldo)</p>
<p className="text-xl font-bold text-indigo-700">{formatCurrency(kasseBalance)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Bank (Saldo)</p>
<p className="text-xl font-bold text-teal-700">{formatCurrency(bankBalance)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 mb-1">Gesamter Kontostand</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(balance)}</p>
</CardContent>
</Card>
</div>
{/* Tabelle */}
{sortedTransactions.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-gray-400">
<p className="text-sm">Noch keine Transaktionen erfasst.</p>
<Button variant="outline" className="mt-4" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erste Transaktion hinzufügen
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Datum
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Konto
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Typ
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Beschreibung
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Betrag
</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedTransactions.map((transaction) => (
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.date}
</td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
</td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
</Badge>
</td>
<td className="px-3 py-2.5 text-slate-700">
{transaction.description}
</td>
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
</span>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(transaction)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(transaction.id)}
disabled={deleting === transaction.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === transaction.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Dialog: Anlegen / Bearbeiten */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editingTransaction ? "Transaktion bearbeiten" : "Neue Transaktion"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konto <span className="text-red-500">*</span>
</label>
<select
value={form.account}
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="kasse">Kasse</option>
<option value="bank">Bank</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ <span className="text-red-500">*</span>
</label>
<select
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="einlage">Einlage</option>
<option value="entnahme">Entnahme</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betrag () <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.amount}
onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))}
placeholder="0,00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="z.B. Barentnahme, Gehalt"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleSave} disabled={saving || !formValid}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingTransaction ? "Speichern" : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+21 -1
View File
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax"; import { 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;
+37
View File
@@ -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())
+53
View File
@@ -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());
+82
View File
@@ -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());