From 3a2a94ec1992eb8ec2cd6a4c30089e7193e4d3ce Mon Sep 17 00:00:00 2001 From: hwinkel Date: Fri, 13 Mar 2026 10:58:44 +0100 Subject: [PATCH] ADD: added admin panel and archiv mandates --- .react-router/types/+routes.ts | 51 ++++- .../types/app/routes/+types/admin-layout.ts | 62 +++++ .../types/app/routes/+types/admin.logs.ts | 65 ++++++ .../app/routes/+types/admin.users.$id.ts | 65 ++++++ .../app/routes/+types/admin.users.new.ts | 65 ++++++ .../types/app/routes/+types/admin.users.ts | 65 ++++++ .../types/app/routes/+types/archiv.ts | 65 ++++++ .../invoice/invoice-status-badge.tsx | 3 +- app/components/layout/topbar.tsx | 124 ++++++---- app/lib/logger.ts | 57 +++++ app/routes.ts | 9 + app/routes/admin-layout.tsx | 95 ++++++++ app/routes/admin.logs.tsx | 172 ++++++++++++++ app/routes/admin.users.$id.tsx | 214 ++++++++++++++++++ app/routes/admin.users.new.tsx | 130 +++++++++++ app/routes/admin.users.tsx | 97 ++++++++ app/routes/api.companies.$id.ts | 13 ++ app/routes/api.invoices.$id.ts | 8 + app/routes/archiv.tsx | 138 +++++++++++ .../companies.$id.invoices.$invoiceId.tsx | 74 +++++- app/routes/companies.$id.invoices.tsx | 204 +++++++++++++---- app/routes/companies.$id.tsx | 82 +++++-- app/routes/companies.tsx | 146 ++++++++---- app/routes/dashboard-layout.tsx | 6 +- app/routes/home.tsx | 8 +- app/routes/login.tsx | 22 +- app/session.server.ts | 49 +++- .../migration.sql | 29 +++ .../migration.sql | 2 + .../migration.sql | 7 + prisma/schema.prisma | 35 ++- prisma/seed.ts | 38 +++- 32 files changed, 2023 insertions(+), 177 deletions(-) create mode 100644 .react-router/types/app/routes/+types/admin-layout.ts create mode 100644 .react-router/types/app/routes/+types/admin.logs.ts create mode 100644 .react-router/types/app/routes/+types/admin.users.$id.ts create mode 100644 .react-router/types/app/routes/+types/admin.users.new.ts create mode 100644 .react-router/types/app/routes/+types/admin.users.ts create mode 100644 .react-router/types/app/routes/+types/archiv.ts create mode 100644 app/lib/logger.ts create mode 100644 app/routes/admin-layout.tsx create mode 100644 app/routes/admin.logs.tsx create mode 100644 app/routes/admin.users.$id.tsx create mode 100644 app/routes/admin.users.new.tsx create mode 100644 app/routes/admin.users.tsx create mode 100644 app/routes/archiv.tsx create mode 100644 prisma/migrations/20260313000000_add_username_role_auditlog/migration.sql create mode 100644 prisma/migrations/20260313000001_add_archived_invoice_status/migration.sql create mode 100644 prisma/migrations/20260313000002_company_archived_invoice_deleted/migration.sql diff --git a/.react-router/types/+routes.ts b/.react-router/types/+routes.ts index 1abb066..f8015d5 100644 --- a/.react-router/types/+routes.ts +++ b/.react-router/types/+routes.ts @@ -62,6 +62,23 @@ type Pages = { "id": string; }; }; + "/archiv": { + params: {}; + }; + "/admin/users": { + params: {}; + }; + "/admin/users/new": { + params: {}; + }; + "/admin/users/:id": { + params: { + "id": string; + }; + }; + "/admin/logs": { + params: {}; + }; "/api/companies": { params: {}; }; @@ -109,7 +126,7 @@ type Pages = { type RouteFiles = { "root.tsx": { id: "root"; - page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports"; + page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/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/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports"; }; "routes/login.tsx": { id: "routes/login"; @@ -121,7 +138,7 @@ type RouteFiles = { }; "routes/dashboard-layout.tsx": { id: "routes/dashboard-layout"; - page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports"; + page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv"; }; "routes/home.tsx": { id: "routes/home"; @@ -163,6 +180,30 @@ type RouteFiles = { id: "routes/companies.$id.reports"; page: "/companies/:id/reports"; }; + "routes/archiv.tsx": { + id: "routes/archiv"; + page: "/archiv"; + }; + "routes/admin-layout.tsx": { + id: "routes/admin-layout"; + page: "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs"; + }; + "routes/admin.users.tsx": { + id: "routes/admin.users"; + page: "/admin/users"; + }; + "routes/admin.users.new.tsx": { + id: "routes/admin.users.new"; + page: "/admin/users/new"; + }; + "routes/admin.users.$id.tsx": { + id: "routes/admin.users.$id"; + page: "/admin/users/:id"; + }; + "routes/admin.logs.tsx": { + id: "routes/admin.logs"; + page: "/admin/logs"; + }; "routes/api.companies.ts": { id: "routes/api.companies"; page: "/api/companies"; @@ -220,6 +261,12 @@ type RouteModules = { "routes/companies.$id.invoices.new": typeof import("./app/routes/companies.$id.invoices.new.tsx"); "routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx"); "routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx"); + "routes/archiv": typeof import("./app/routes/archiv.tsx"); + "routes/admin-layout": typeof import("./app/routes/admin-layout.tsx"); + "routes/admin.users": typeof import("./app/routes/admin.users.tsx"); + "routes/admin.users.new": typeof import("./app/routes/admin.users.new.tsx"); + "routes/admin.users.$id": typeof import("./app/routes/admin.users.$id.tsx"); + "routes/admin.logs": typeof import("./app/routes/admin.logs.tsx"); "routes/api.companies": typeof import("./app/routes/api.companies.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"); diff --git a/.react-router/types/app/routes/+types/admin-layout.ts b/.react-router/types/app/routes/+types/admin-layout.ts new file mode 100644 index 0000000..3ce8983 --- /dev/null +++ b/.react-router/types/app/routes/+types/admin-layout.ts @@ -0,0 +1,62 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../admin-layout.js") + +type Info = GetInfo<{ + file: "routes/admin-layout.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/admin-layout"; + module: typeof import("../admin-layout.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/admin.logs.ts b/.react-router/types/app/routes/+types/admin.logs.ts new file mode 100644 index 0000000..b4e991c --- /dev/null +++ b/.react-router/types/app/routes/+types/admin.logs.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../admin.logs.js") + +type Info = GetInfo<{ + file: "routes/admin.logs.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/admin-layout"; + module: typeof import("../admin-layout.js"); +}, { + id: "routes/admin.logs"; + module: typeof import("../admin.logs.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/admin.users.$id.ts b/.react-router/types/app/routes/+types/admin.users.$id.ts new file mode 100644 index 0000000..decbab0 --- /dev/null +++ b/.react-router/types/app/routes/+types/admin.users.$id.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../admin.users.$id.js") + +type Info = GetInfo<{ + file: "routes/admin.users.$id.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/admin-layout"; + module: typeof import("../admin-layout.js"); +}, { + id: "routes/admin.users.$id"; + module: typeof import("../admin.users.$id.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/admin.users.new.ts b/.react-router/types/app/routes/+types/admin.users.new.ts new file mode 100644 index 0000000..b075fcb --- /dev/null +++ b/.react-router/types/app/routes/+types/admin.users.new.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../admin.users.new.js") + +type Info = GetInfo<{ + file: "routes/admin.users.new.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/admin-layout"; + module: typeof import("../admin-layout.js"); +}, { + id: "routes/admin.users.new"; + module: typeof import("../admin.users.new.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/admin.users.ts b/.react-router/types/app/routes/+types/admin.users.ts new file mode 100644 index 0000000..6c1ad7f --- /dev/null +++ b/.react-router/types/app/routes/+types/admin.users.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../admin.users.js") + +type Info = GetInfo<{ + file: "routes/admin.users.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/admin-layout"; + module: typeof import("../admin-layout.js"); +}, { + id: "routes/admin.users"; + module: typeof import("../admin.users.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/archiv.ts b/.react-router/types/app/routes/+types/archiv.ts new file mode 100644 index 0000000..7b0a629 --- /dev/null +++ b/.react-router/types/app/routes/+types/archiv.ts @@ -0,0 +1,65 @@ +// Generated by React Router + +import type { GetInfo, GetAnnotations } from "react-router/internal"; + +type Module = typeof import("../archiv.js") + +type Info = GetInfo<{ + file: "routes/archiv.tsx", + module: Module +}> + +type Matches = [{ + id: "root"; + module: typeof import("../../root.js"); +}, { + id: "routes/dashboard-layout"; + module: typeof import("../dashboard-layout.js"); +}, { + id: "routes/archiv"; + module: typeof import("../archiv.js"); +}]; + +type Annotations = GetAnnotations; + +export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; + + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; + + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; + + // middleware + export type MiddlewareFunction = Annotations["MiddlewareFunction"]; + + // clientMiddleware + export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; + + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; + + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + + // action + export type ActionArgs = Annotations["ActionArgs"]; + + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; + + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + + // Component + export type ComponentProps = Annotations["ComponentProps"]; + + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; +} \ No newline at end of file diff --git a/app/components/invoice/invoice-status-badge.tsx b/app/components/invoice/invoice-status-badge.tsx index 27fb3ec..34abe04 100644 --- a/app/components/invoice/invoice-status-badge.tsx +++ b/app/components/invoice/invoice-status-badge.tsx @@ -1,11 +1,12 @@ import { Badge } from "@/components/ui/badge"; import { InvoiceStatus } from "@prisma/client"; -const statusConfig: Record = { +const statusConfig: Record = { DRAFT: { label: "Entwurf", variant: "secondary" }, SENT: { label: "Versendet", variant: "warning" }, PAID: { label: "Bezahlt", variant: "success" }, CANCELLED: { label: "Storniert", variant: "destructive" }, + DELETED: { label: "Gelöscht", variant: "outline" }, }; export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) { diff --git a/app/components/layout/topbar.tsx b/app/components/layout/topbar.tsx index f3a6b97..c44e2ab 100644 --- a/app/components/layout/topbar.tsx +++ b/app/components/layout/topbar.tsx @@ -1,5 +1,5 @@ import { useMatches, useLocation, Link, Form } from "react-router"; -import { ChevronRight, LayoutDashboard, LogOut } from "lucide-react"; +import { ChevronRight, LayoutDashboard, LogOut, Shield, Archive } from "lucide-react"; interface Breadcrumb { label: string; @@ -24,7 +24,13 @@ function getInitials(name?: string | null): string { return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2); } -export function Topbar({ userName }: { userName?: string | null }) { +export function Topbar({ + userName, + userRole, +}: { + userName?: string | null; + userRole?: string | null; +}) { const matches = useMatches(); const location = useLocation(); const isOnDashboard = location.pathname === "/"; @@ -85,10 +91,31 @@ export function Topbar({ userName }: { userName?: string | null }) { )} - {/* Dashboard Button */} - {!isOnDashboard && ( +
+ {/* Dashboard Button */} + {!isOnDashboard && ( + + + Dashboard + + )} + + {/* Archiv Link */} - - Dashboard + + Archiv - )} - {/* User + Logout */} - {userName && ( -
- {userName} -
- {getInitials(userName)} -
-
- -
-
- )} + {getInitials(userName)} +
+
+ +
+ + )} + ); } diff --git a/app/lib/logger.ts b/app/lib/logger.ts new file mode 100644 index 0000000..b54002e --- /dev/null +++ b/app/lib/logger.ts @@ -0,0 +1,57 @@ +import prisma from "@/lib/prisma"; + +export type LogAction = + | "LOGIN" + | "LOGIN_FAILED" + | "LOGOUT" + | "CREATE_USER" + | "UPDATE_USER" + | "DELETE_USER" + | "CREATE_COMPANY" + | "UPDATE_COMPANY" + | "DELETE_COMPANY" + | "CREATE_INVOICE" + | "UPDATE_INVOICE" + | "DELETE_INVOICE"; + +export async function log({ + userId, + action, + entity, + entityId, + metadata, + request, +}: { + userId?: string | null; + action: LogAction; + entity?: string; + entityId?: string; + metadata?: Record; + request?: Request; +}) { + const ipAddress = request + ? request.headers.get("x-forwarded-for") ?? + request.headers.get("x-real-ip") ?? + undefined + : undefined; + + try { + await prisma.auditLog.create({ + data: { + userId: userId ?? null, + action, + entity: entity ?? null, + entityId: entityId ?? null, + metadata: metadata ?? undefined, + ipAddress: ipAddress ?? null, + }, + }); + } catch (err) { + // Never let logging failures break the app + console.error("[AuditLog] Failed to write log entry:", err); + } + + console.log( + `[AUDIT] ${new Date().toISOString()} | ${action}${entity ? ` | ${entity}` : ""}${entityId ? `:${entityId}` : ""}${userId ? ` | user:${userId}` : ""}${ipAddress ? ` | ip:${ipAddress}` : ""}` + ); +} diff --git a/app/routes.ts b/app/routes.ts index b5455ac..944df82 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -15,6 +15,15 @@ export default [ route("companies/:id/invoices/new", "routes/companies.$id.invoices.new.tsx"), route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"), route("companies/:id/reports", "routes/companies.$id.reports.tsx"), + route("archiv", "routes/archiv.tsx"), + ]), + + // Admin routes + layout("routes/admin-layout.tsx", [ + route("admin/users", "routes/admin.users.tsx"), + route("admin/users/new", "routes/admin.users.new.tsx"), + route("admin/users/:id", "routes/admin.users.$id.tsx"), + route("admin/logs", "routes/admin.logs.tsx"), ]), // API resource routes diff --git a/app/routes/admin-layout.tsx b/app/routes/admin-layout.tsx new file mode 100644 index 0000000..c4647b7 --- /dev/null +++ b/app/routes/admin-layout.tsx @@ -0,0 +1,95 @@ +import { Outlet, useLoaderData, Link, useLocation } from "react-router"; +import { requireAdmin } from "@/session.server"; +import { Shield, Users, ScrollText, LayoutDashboard } from "lucide-react"; + +export async function loader({ request }: { request: Request }) { + const user = await requireAdmin(request); + return { userName: user.name }; +} + +export default function AdminLayout() { + const { userName } = useLoaderData(); + const location = useLocation(); + + const navItems = [ + { to: "/admin/users", label: "Benutzerverwaltung", icon: Users }, + { to: "/admin/logs", label: "Audit-Log", icon: ScrollText }, + ]; + + return ( +
+
+
+ + + Admin + + / + +
+
+ {userName} + + + App + +
+
+
+
+ +
+
+
+ ); +} diff --git a/app/routes/admin.logs.tsx b/app/routes/admin.logs.tsx new file mode 100644 index 0000000..584ee79 --- /dev/null +++ b/app/routes/admin.logs.tsx @@ -0,0 +1,172 @@ +import { useLoaderData } from "react-router"; +import { requireAdmin } from "@/session.server"; +import prisma from "@/lib/prisma"; + +const ACTION_LABELS: Record = { + LOGIN: "Anmeldung", + LOGIN_FAILED: "Anmeldung fehlgeschlagen", + LOGOUT: "Abmeldung", + CREATE_USER: "Benutzer erstellt", + UPDATE_USER: "Benutzer bearbeitet", + DELETE_USER: "Benutzer gelöscht", + CREATE_COMPANY: "Firma erstellt", + UPDATE_COMPANY: "Firma bearbeitet", + DELETE_COMPANY: "Firma gelöscht", + CREATE_INVOICE: "Rechnung erstellt", + UPDATE_INVOICE: "Rechnung bearbeitet", + DELETE_INVOICE: "Rechnung gelöscht", +}; + +const ACTION_COLORS: Record = { + LOGIN: "bg-green-100 text-green-700", + LOGIN_FAILED: "bg-red-100 text-red-700", + LOGOUT: "bg-slate-100 text-slate-600", + CREATE_USER: "bg-blue-100 text-blue-700", + UPDATE_USER: "bg-amber-100 text-amber-700", + DELETE_USER: "bg-red-100 text-red-700", + CREATE_COMPANY: "bg-blue-100 text-blue-700", + UPDATE_COMPANY: "bg-amber-100 text-amber-700", + DELETE_COMPANY: "bg-red-100 text-red-700", + CREATE_INVOICE: "bg-blue-100 text-blue-700", + UPDATE_INVOICE: "bg-amber-100 text-amber-700", + DELETE_INVOICE: "bg-red-100 text-red-700", +}; + +export async function loader({ request }: { request: Request }) { + await requireAdmin(request); + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1")); + const pageSize = 50; + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { user: { select: { name: true, username: true } } }, + }), + prisma.auditLog.count(), + ]); + + return { + logs: logs.map((l) => ({ + id: l.id, + action: l.action, + entity: l.entity, + entityId: l.entityId, + metadata: l.metadata, + ipAddress: l.ipAddress, + createdAt: l.createdAt.toISOString(), + userName: l.user?.name ?? null, + userUsername: l.user?.username ?? null, + })), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; +} + +export default function AdminLogsPage() { + const { logs, total, page, totalPages } = useLoaderData(); + + return ( +
+
+

Audit-Log

+

{total} Einträge insgesamt

+
+ +
+ + + + + + + + + + + + {logs.length === 0 && ( + + + + )} + {logs.map((log, i) => ( + + + + + + + + ))} + +
ZeitpunktAktionBenutzerObjektIP
+ Noch keine Einträge vorhanden. +
+ {new Date(log.createdAt).toLocaleString("de-DE")} + + + {ACTION_LABELS[log.action] ?? log.action} + + + {log.userName ? ( + + {log.userName}{" "} + @{log.userUsername} + + ) : ( + + )} + + {log.entity ? ( + + {log.entity} + {log.entityId && ( + #{log.entityId.slice(0, 8)} + )} + + ) : ( + + )} + + {log.ipAddress ?? "—"} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ {page > 1 && ( + + Zurück + + )} + + Seite {page} / {totalPages} + + {page < totalPages && ( + + Weiter + + )} +
+ )} +
+ ); +} diff --git a/app/routes/admin.users.$id.tsx b/app/routes/admin.users.$id.tsx new file mode 100644 index 0000000..17b47b4 --- /dev/null +++ b/app/routes/admin.users.$id.tsx @@ -0,0 +1,214 @@ +import { + Form, + useActionData, + useLoaderData, + useNavigation, + redirect, + Link, +} from "react-router"; +import { requireAdmin } from "@/session.server"; +import { log } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { AlertCircle, ArrowLeft, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export async function loader({ + request, + params, +}: { + request: Request; + params: { id: string }; +}) { + await requireAdmin(request); + const user = await prisma.user.findUnique({ + where: { id: params.id }, + select: { id: true, name: true, username: true, email: true, role: true }, + }); + if (!user) throw new Response("Nicht gefunden", { status: 404 }); + return { user }; +} + +export async function action({ + request, + params, +}: { + request: Request; + params: { id: string }; +}) { + const admin = await requireAdmin(request); + const formData = await request.formData(); + const intent = formData.get("intent") as string; + + if (intent === "delete") { + if (params.id === admin.id) { + return { error: "Sie können Ihr eigenes Konto nicht löschen." }; + } + await prisma.user.delete({ where: { id: params.id } }); + await log({ + userId: admin.id, + action: "DELETE_USER", + entity: "User", + entityId: params.id, + request, + }); + return redirect("/admin/users"); + } + + // intent === "update" + const name = (formData.get("name") as string).trim(); + const username = (formData.get("username") as string).trim().toLowerCase(); + const email = (formData.get("email") as string).trim().toLowerCase(); + const role = formData.get("role") as "USER" | "ADMIN"; + const password = (formData.get("password") as string).trim(); + + if (!name || !username || !email) { + return { error: "Name, Benutzername und E-Mail sind Pflichtfelder." }; + } + + const conflict = await prisma.user.findFirst({ + where: { + AND: [ + { id: { not: params.id } }, + { OR: [{ email }, { username }] }, + ], + }, + }); + if (conflict) { + return { error: "E-Mail oder Benutzername bereits von einem anderen Nutzer vergeben." }; + } + + const updateData: Record = { + name, + username, + email, + role: role === "ADMIN" ? "ADMIN" : "USER", + }; + + if (password) { + if (password.length < 8) { + return { error: "Das Passwort muss mindestens 8 Zeichen lang sein." }; + } + updateData.passwordHash = await bcrypt.hash(password, 12); + } + + await prisma.user.update({ where: { id: params.id }, data: updateData }); + await log({ + userId: admin.id, + action: "UPDATE_USER", + entity: "User", + entityId: params.id, + metadata: { name, username, email, role, passwordChanged: !!password }, + request, + }); + + return redirect("/admin/users"); +} + +export default function AdminUserEditPage() { + const { user } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const loading = navigation.state === "submitting"; + + return ( +
+
+ + + +
+

Benutzer bearbeiten

+

{user.name}

+
+
+ +
+
+ + + {actionData?.error && ( +
+ + {actionData.error} +
+ )} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+
+ + {/* Delete zone */} +
+

Benutzer löschen

+

+ Löscht den Benutzer und alle zugehörigen Firmen, Kunden und Rechnungen unwiderruflich. +

+
+ + +
+
+
+ ); +} diff --git a/app/routes/admin.users.new.tsx b/app/routes/admin.users.new.tsx new file mode 100644 index 0000000..f582e50 --- /dev/null +++ b/app/routes/admin.users.new.tsx @@ -0,0 +1,130 @@ +import { Form, useActionData, useNavigation, redirect, Link } from "react-router"; +import { requireAdmin } from "@/session.server"; +import { log } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { AlertCircle, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export async function loader({ request }: { request: Request }) { + await requireAdmin(request); + return null; +} + +export async function action({ request }: { request: Request }) { + const admin = await requireAdmin(request); + const formData = await request.formData(); + + const name = (formData.get("name") as string).trim(); + const username = (formData.get("username") as string).trim().toLowerCase(); + const email = (formData.get("email") as string).trim().toLowerCase(); + const password = formData.get("password") as string; + const role = formData.get("role") as "USER" | "ADMIN"; + + if (!name || !username || !email || !password) { + return { error: "Alle Felder sind Pflichtfelder." }; + } + if (password.length < 8) { + return { error: "Das Passwort muss mindestens 8 Zeichen lang sein." }; + } + + const existing = await prisma.user.findFirst({ + where: { OR: [{ email }, { username }] }, + }); + if (existing) { + return { error: "E-Mail oder Benutzername bereits vergeben." }; + } + + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { name, username, email, passwordHash, role: role === "ADMIN" ? "ADMIN" : "USER" }, + }); + + await log({ + userId: admin.id, + action: "CREATE_USER", + entity: "User", + entityId: user.id, + metadata: { name, username, email, role }, + request, + }); + + return redirect("/admin/users"); +} + +export default function AdminUsersNewPage() { + const actionData = useActionData(); + const navigation = useNavigation(); + const loading = navigation.state === "submitting"; + + return ( +
+
+ + + +
+

Neuer Benutzer

+

Zugangsdaten und Rolle festlegen

+
+
+ +
+
+ {actionData?.error && ( +
+ + {actionData.error} +
+ )} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

Mindestens 8 Zeichen

+
+ +
+ + +
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/app/routes/admin.users.tsx b/app/routes/admin.users.tsx new file mode 100644 index 0000000..8c59f5f --- /dev/null +++ b/app/routes/admin.users.tsx @@ -0,0 +1,97 @@ +import { Link, useLoaderData } from "react-router"; +import { requireAdmin } from "@/session.server"; +import prisma from "@/lib/prisma"; +import { UserPlus, Shield, User } from "lucide-react"; + +export async function loader({ request }: { request: Request }) { + await requireAdmin(request); + const users = await prisma.user.findMany({ + orderBy: { createdAt: "asc" }, + select: { + id: true, + email: true, + username: true, + name: true, + role: true, + createdAt: true, + _count: { select: { companies: true } }, + }, + }); + return { + users: users.map((u) => ({ ...u, createdAt: u.createdAt.toISOString() })), + }; +} + +export default function AdminUsersPage() { + const { users } = useLoaderData(); + + return ( +
+
+
+

Benutzerverwaltung

+

{users.length} Benutzer registriert

+
+ + + Neuer Benutzer + +
+ +
+ + + + + + + + + + + + + {users.map((user, i) => ( + + + + + + + + + + ))} + +
NameBenutzernameE-MailRolleFirmenErstellt +
{user.name}{user.username}{user.email} + {user.role === "ADMIN" ? ( + + Admin + + ) : ( + + Benutzer + + )} + {user._count.companies} + {new Date(user.createdAt).toLocaleDateString("de-DE")} + + + Bearbeiten + +
+
+
+ ); +} diff --git a/app/routes/api.companies.$id.ts b/app/routes/api.companies.$id.ts index 56ff1b7..3b051d1 100644 --- a/app/routes/api.companies.$id.ts +++ b/app/routes/api.companies.$id.ts @@ -43,6 +43,19 @@ export async function action({ request, params }: { request: Request; params: { return Response.json({ ok: true }); } + if (request.method === "PATCH") { + const body = await request.json(); + const archive = body.archived === true; + await prisma.company.update({ + where: { id: params.id }, + data: { + archived: archive, + archivedAt: archive ? new Date() : null, + }, + }); + return Response.json({ ok: true }); + } + // PUT const body = await request.json(); const parsed = companySchema.safeParse(body); diff --git a/app/routes/api.invoices.$id.ts b/app/routes/api.invoices.$id.ts index bb1df42..7dbb951 100644 --- a/app/routes/api.invoices.$id.ts +++ b/app/routes/api.invoices.$id.ts @@ -30,6 +30,14 @@ export async function action({ request, params }: { request: Request; params: { if (!invoice) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { + const isAdmin = user.role === "ADMIN"; + const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED]; + if (!isAdmin && !deletableStatuses.includes(invoice.status)) { + return Response.json( + { error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." }, + { status: 403 } + ); + } await prisma.invoice.delete({ where: { id: params.id } }); return Response.json({ ok: true }); } diff --git a/app/routes/archiv.tsx b/app/routes/archiv.tsx new file mode 100644 index 0000000..20cc639 --- /dev/null +++ b/app/routes/archiv.tsx @@ -0,0 +1,138 @@ +import { Link, useLoaderData } from "react-router"; +import { requireUser } from "@/session.server"; +import prisma from "@/lib/prisma"; +import { formatCurrency } from "@/lib/tax"; +import { Archive, Building2, FileText, Users, ArchiveRestore } from "lucide-react"; +import { useRevalidator } from "react-router"; + +export const handle = { + breadcrumbs: () => [{ label: "Archiv" }], +}; + +export async function loader({ request }: { request: Request }) { + const user = await requireUser(request); + + const companies = await prisma.company.findMany({ + where: { userId: user.id, archived: true }, + include: { + _count: { select: { invoices: true, customers: true } }, + invoices: { + where: { status: "PAID" }, + select: { grossTotal: true }, + }, + }, + orderBy: { archivedAt: "desc" }, + }); + + return { + isAdmin: user.role === "ADMIN", + companies: companies.map((c) => ({ + ...c, + archivedAt: c.archivedAt?.toISOString() ?? null, + revenue: c.invoices.reduce((s, inv) => s + Number(inv.grossTotal), 0), + invoices: undefined, + })), + }; +} + +export default function ArchivPage() { + const { companies, isAdmin } = useLoaderData(); + const { revalidate } = useRevalidator(); + + async function restore(id: string) { + if (!confirm("Archivierung aufheben?")) return; + await fetch(`/api/companies/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived: false }), + }); + revalidate(); + } + + return ( +
+
+
+ +

Archiv

+
+

+ {companies.length === 0 + ? "Keine archivierten Mandanten" + : `${companies.length} archivierte ${companies.length === 1 ? "Mandant" : "Mandanten"}`} +

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

Archiv ist leer

+

Archivierte Mandanten erscheinen hier.

+
+ ) : ( +
+ {companies.map((company) => ( +
+
+ +
+ +
+
+ + {company.name} + + {company.legalForm && ( + {company.legalForm} + )} +
+
+ + + {company._count.invoices} Rechnungen + + + + {company._count.customers} Kunden + + Umsatz (bezahlt): {formatCurrency(company.revenue)} + {company.archivedAt && ( + + Archiviert: {new Date(company.archivedAt).toLocaleDateString("de-DE")} + + )} +
+
+ +
+ + Öffnen + + {isAdmin && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/routes/companies.$id.invoices.$invoiceId.tsx b/app/routes/companies.$id.invoices.$invoiceId.tsx index c46c0dc..797772c 100644 --- a/app/routes/companies.$id.invoices.$invoiceId.tsx +++ b/app/routes/companies.$id.invoices.$invoiceId.tsx @@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { formatCurrency, formatDate } from "@/lib/tax"; -import { ChevronLeft, Download, CheckCircle, Send, Trash2 } from "lucide-react"; +import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw } from "lucide-react"; import { InvoiceStatus } from "@prisma/client"; export async function loader({ @@ -40,6 +40,7 @@ export async function loader({ if (!invoice) throw new Response("Not Found", { status: 404 }); return { + isAdmin: user.role === "ADMIN", invoice: { ...invoice, netTotal: Number(invoice.netTotal), @@ -63,7 +64,7 @@ export async function loader({ } export default function InvoiceDetailPage() { - const { invoice } = useLoaderData(); + const { invoice, isAdmin } = useLoaderData(); const navigate = useNavigate(); const { revalidate } = useRevalidator(); const [loading, setLoading] = useState(false); @@ -91,8 +92,31 @@ export default function InvoiceDetailPage() { revalidate(); } - async function handleDelete() { - if (!confirm("Rechnung wirklich löschen?")) return; + async function handleSoftDelete() { + if (!confirm("Rechnung in den Papierkorb verschieben?")) return; + setLoading(true); + await fetch(`/api/invoices/${invoice.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "DELETED" }), + }); + setLoading(false); + revalidate(); + } + + async function handleRestore() { + setLoading(true); + await fetch(`/api/invoices/${invoice.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "CANCELLED" }), + }); + setLoading(false); + revalidate(); + } + + async function handleHardDelete() { + if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return; await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" }); navigate(`/companies/${id}/invoices`); } @@ -131,9 +155,11 @@ export default function InvoiceDetailPage() { {/* Invoice Actions */}
- + {invoice.status !== "DELETED" && ( + + )} {invoice.status === "DRAFT" && ( )} - {(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && ( + {/* Soft-Delete für alle nicht-gelöschten Status */} + {invoice.status !== "DELETED" && ( )} + {/* Gelöschte Rechnung: Wiederherstellen + endgültig löschen (Admin) */} + {invoice.status === "DELETED" && ( + <> + + {isAdmin && ( + + )} + + )}
diff --git a/app/routes/companies.$id.invoices.tsx b/app/routes/companies.$id.invoices.tsx index 121cfcc..84b2849 100644 --- a/app/routes/companies.$id.invoices.tsx +++ b/app/routes/companies.$id.invoices.tsx @@ -10,10 +10,11 @@ export const handle = { import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { formatCurrency, formatDate } from "@/lib/tax"; -import { Plus, FileText, ChevronLeft } from "lucide-react"; +import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2 } from "lucide-react"; +import { useState } from "react"; export async function loader({ request, params }: { request: Request; params: { id: string } }) { const user = await requireUser(request); @@ -41,23 +42,160 @@ export async function loader({ request, params }: { request: Request; params: { }; } +type InvoiceRow = ReturnType>["invoices"][number]; + +function groupByYear(invoices: InvoiceRow[]): Map { + const map = new Map(); + for (const inv of invoices) { + const year = new Date(inv.issueDate).getFullYear(); + if (!map.has(year)) map.set(year, []); + map.get(year)!.push(inv); + } + return map; +} + +function InvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: string }) { + return ( + +
+
+ +
+
+

{invoice.number}

+

{invoice.customer.name}

+
+
+
+

{formatDate(invoice.issueDate)}

+ +

+ {formatCurrency(invoice.grossTotal)} +

+
+ + ); +} + +function YearPanel({ + year, + invoices, + companyId, + defaultOpen, +}: { + year: number; + invoices: InvoiceRow[]; + companyId: string; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const totalGross = invoices.reduce((s, i) => s + i.grossTotal, 0); + + return ( +
+ + + {open && ( +
+ {invoices.map((invoice) => ( + + ))} +
+ )} +
+ ); +} + +function DeletedPanel({ + invoices, + companyId, +}: { + invoices: InvoiceRow[]; + companyId: string; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ {invoices.map((invoice) => ( + + ))} +
+ )} +
+ ); +} + export default function InvoicesPage() { const { company, invoices } = useLoaderData(); const id = company.id; + const currentYear = new Date().getFullYear(); + + const activeInvoices = invoices.filter((i) => i.status !== "DELETED"); + const deletedInvoices = invoices.filter((i) => i.status === "DELETED"); + + const byYear = groupByYear(activeInvoices); + const years = Array.from(byYear.keys()).sort((a, b) => b - a); return (
{company.name} -
+
-

Rechnungen

-

{invoices.length} Rechnungen für {company.name}

+

Rechnungen

+

+ {activeInvoices.length} Rechnungen für {company.name} + {deletedInvoices.length > 0 && ( + · {deletedInvoices.length} im Papierkorb + )} +

- {invoices.length === 0 ? ( + {activeInvoices.length === 0 && deletedInvoices.length === 0 ? ( - - -

Noch keine Rechnungen

-

Erstellen Sie die erste Rechnung für diesen Mandanten.

+
+ +

Noch keine Rechnungen

+

Erstellen Sie die erste Rechnung für diesen Mandanten.

- - - ) : ( - -
- {invoices.map((invoice) => ( - -
-
- -
-
-

{invoice.number}

-

{invoice.customer.name}

-
-
-
-

{formatDate(invoice.issueDate)}

- -

- {formatCurrency(invoice.grossTotal)} -

-
- - ))}
+ ) : ( +
+ {years.map((year) => ( + + ))} + {deletedInvoices.length > 0 && ( + + )} +
)}
); diff --git a/app/routes/companies.$id.tsx b/app/routes/companies.$id.tsx index f64e387..77b6775 100644 --- a/app/routes/companies.$id.tsx +++ b/app/routes/companies.$id.tsx @@ -1,4 +1,4 @@ -import { Link, useLoaderData } from "react-router"; +import { Link, useLoaderData, useRevalidator } from "react-router"; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma"; import { Button } from "@/components/ui/button"; @@ -7,9 +7,10 @@ import { Badge } from "@/components/ui/badge"; import { formatCurrency, formatDate } from "@/lib/tax"; import { FileText, Users, BarChart3, Plus, Edit, Building2, - Mail, Phone, CreditCard, Receipt + Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle } from "lucide-react"; import { InvoiceStatus } from "@prisma/client"; +import { useState } from "react"; export const handle = { breadcrumbs: (data: { company: { id: string; name: string } }) => [ @@ -23,13 +24,15 @@ const statusLabels: Record = { SENT: "Versendet", PAID: "Bezahlt", CANCELLED: "Storniert", + DELETED: "Gelöscht", }; -const statusVariants: Record = { +const statusVariants: Record = { DRAFT: "secondary", SENT: "warning", PAID: "success", CANCELLED: "destructive", + DELETED: "outline", }; export async function loader({ request, params }: { request: Request; params: { id: string } }) { @@ -56,8 +59,10 @@ export async function loader({ request, params }: { request: Request; params: { }); return { + isAdmin: user.role === "ADMIN", company: { ...company, + archivedAt: company.archivedAt?.toISOString() ?? null, invoices: company.invoices.map((inv) => ({ ...inv, grossTotal: Number(inv.grossTotal), @@ -70,30 +75,81 @@ export async function loader({ request, params }: { request: Request; params: { } export default function CompanyPage() { - const { company, revenue } = useLoaderData(); + const { company, revenue, isAdmin } = useLoaderData(); + const { revalidate } = useRevalidator(); + const [archiving, setArchiving] = useState(false); const id = company.id; + async function toggleArchive() { + const action = company.archived ? "Archivierung aufheben?" : "Mandanten archivieren?"; + if (!confirm(action)) return; + setArchiving(true); + await fetch(`/api/companies/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived: !company.archived }), + }); + setArchiving(false); + revalidate(); + } + return (
+ {/* Archiv-Banner */} + {company.archived && ( +
+ + + Dieser Mandant ist archiviert + {company.archivedAt && ` seit ${new Date(company.archivedAt).toLocaleDateString("de-DE")}`}. + + {isAdmin && ( + + )} +
+ )} +
-
- +
+
-

{company.name}

+

+ {company.name} +

{company.legalForm && `${company.legalForm} · `} {company.zip} {company.city}

- +
+ {isAdmin && !company.archived && ( + + )} + {isAdmin && company.archived && ( + + )} + +
diff --git a/app/routes/companies.tsx b/app/routes/companies.tsx index 5f293cc..9cd9cb0 100644 --- a/app/routes/companies.tsx +++ b/app/routes/companies.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { formatCurrency, formatDate } from "@/lib/tax"; import { Building2, Plus, FileText, Users, X, Edit, Receipt, - Mail, Phone, CreditCard, ChevronRight, + Mail, Phone, CreditCard, ChevronRight, ChevronDown, Archive, } from "lucide-react"; import { InvoiceStatus } from "@prisma/client"; @@ -20,16 +20,18 @@ const statusLabels: Record = { SENT: "Versendet", PAID: "Bezahlt", CANCELLED: "Storniert", + DELETED: "Gelöscht", }; const statusVariants: Record< InvoiceStatus, - "secondary" | "default" | "success" | "destructive" | "warning" + "secondary" | "default" | "success" | "destructive" | "warning" | "outline" > = { DRAFT: "secondary", SENT: "warning", PAID: "success", CANCELLED: "destructive", + DELETED: "outline", }; export async function loader({ request }: { request: Request }) { @@ -58,11 +60,69 @@ export async function loader({ request }: { request: Request }) { }; } +type Company = ReturnType>["companies"][number]; + +function CompanyCard({ + company, + isActive, + onSelect, +}: { + company: Company; + isActive: boolean; + onSelect: () => void; +}) { + const archived = company.archived; + return ( + + ); +} + export default function CompaniesPage() { const { companies } = useLoaderData(); const [selectedId, setSelectedId] = useState(null); + const [archivedOpen, setArchivedOpen] = useState(false); const selected = companies.find((c) => c.id === selectedId) ?? null; + const active = companies.filter((c) => !c.archived); + const archived = companies.filter((c) => c.archived); + return (
{/* Kacheln */} @@ -70,7 +130,10 @@ export default function CompaniesPage() {

Mandanten

-

{companies.length} Mandanten verwaltet

+

+ {active.length} aktive Mandanten + {archived.length > 0 && · {archived.length} archiviert} +

- {companies.length === 0 ? ( + {active.length === 0 && archived.length === 0 ? (
@@ -95,47 +158,46 @@ export default function CompaniesPage() {
) : ( -
- {companies.map((company) => { - const isActive = selectedId === company.id; - return ( +
+ {/* Aktive Mandanten */} + {active.length > 0 && ( +
+ {active.map((company) => ( + setSelectedId(selectedId === company.id ? null : company.id)} + /> + ))} +
+ )} + + {/* Archivierte Mandanten */} + {archived.length > 0 && ( +
- ); - })} + {archivedOpen && ( +
+ {archived.map((company) => ( + setSelectedId(selectedId === company.id ? null : company.id)} + /> + ))} +
+ )} +
+ )}
)}
diff --git a/app/routes/dashboard-layout.tsx b/app/routes/dashboard-layout.tsx index a25d2b8..cb375e5 100644 --- a/app/routes/dashboard-layout.tsx +++ b/app/routes/dashboard-layout.tsx @@ -4,15 +4,15 @@ import { Topbar } from "@/components/layout/topbar"; export async function loader({ request }: { request: Request }) { const user = await requireUser(request); - return { userName: user.name }; + return { userName: user.name, userRole: user.role }; } export default function DashboardLayout() { - const { userName } = useLoaderData(); + const { userName, userRole } = useLoaderData(); return (
- +
diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 8fe92f0..3df1c25 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -16,21 +16,21 @@ export async function loader({ request }: { request: Request }) { const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([ prisma.company.findMany({ - where: { userId }, + where: { userId, archived: false }, include: { _count: { select: { invoices: true, customers: true } } }, orderBy: { name: "asc" }, }), prisma.invoice.aggregate({ - where: { company: { userId } }, + where: { company: { userId, archived: false } }, _count: true, _sum: { grossTotal: true }, }), prisma.invoice.aggregate({ - where: { company: { userId }, status: InvoiceStatus.PAID }, + where: { company: { userId, archived: false }, status: InvoiceStatus.PAID }, _sum: { grossTotal: true }, }), prisma.invoice.count({ - where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } }, + where: { company: { userId, archived: false }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } }, }), ]); diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 9fa3431..b26837d 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -13,13 +13,13 @@ export async function loader({ request }: { request: Request }) { export async function action({ request }: { request: Request }) { const formData = await request.formData(); - const email = formData.get("email") as string; + const identifier = formData.get("identifier") as string; const password = formData.get("password") as string; - const user = await login(email, password); - if (!user) return { error: "E-Mail oder Passwort falsch." }; + const user = await login(identifier, password, request); + if (!user) return { error: "Benutzername/E-Mail oder Passwort falsch." }; - return createUserSession(user.id, user.name, "/"); + return createUserSession(user.id, user.name, user.role, "/"); } export default function LoginPage() { @@ -83,7 +83,7 @@ export default function LoginPage() {

Willkommen zurück

-

Melden Sie sich mit Ihren Zugangsdaten an

+

Benutzername oder E-Mail und Passwort eingeben

@@ -96,14 +96,14 @@ export default function LoginPage() { )}
- +
diff --git a/app/session.server.ts b/app/session.server.ts index d8962fa..8bc1b52 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -1,6 +1,7 @@ import { createCookieSessionStorage, redirect } from "react-router"; import bcrypt from "bcryptjs"; import prisma from "@/lib/prisma"; +import { log } from "@/lib/logger"; const sessionStorage = createCookieSessionStorage({ cookie: { @@ -14,22 +15,43 @@ const sessionStorage = createCookieSessionStorage({ }, }); -export async function login(email: string, password: string) { - const user = await prisma.user.findUnique({ where: { email } }); - if (!user) return null; +export async function login( + identifier: string, + password: string, + request?: Request +) { + // Allow login via email or username + const user = await prisma.user.findFirst({ + where: { + OR: [{ email: identifier }, { username: identifier }], + }, + }); + + if (!user) { + await log({ action: "LOGIN_FAILED", metadata: { identifier }, request }); + return null; + } + const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) return null; - return { id: user.id, email: user.email, name: user.name }; + if (!valid) { + await log({ action: "LOGIN_FAILED", metadata: { identifier }, request }); + return null; + } + + await log({ userId: user.id, action: "LOGIN", entity: "User", entityId: user.id, request }); + return { id: user.id, email: user.email, name: user.name, role: user.role }; } export async function createUserSession( userId: string, userName: string, + userRole: string, redirectTo: string ) { const session = await sessionStorage.getSession(); session.set("userId", userId); session.set("userName", userName); + session.set("userRole", userRole); return redirect(redirectTo, { headers: { "Set-Cookie": await sessionStorage.commitSession(session) }, }); @@ -42,24 +64,33 @@ export async function getUserSession(request: Request) { return { userId: session.get("userId") as string | undefined, userName: session.get("userName") as string | undefined, + userRole: session.get("userRole") as string | undefined, }; } export async function requireUser(request: Request) { - const { userId, userName } = await getUserSession(request); + const { userId, userName, userRole } = await getUserSession(request); if (!userId) throw redirect("/login"); - return { id: userId, name: userName as string | undefined }; + return { id: userId, name: userName as string | undefined, role: userRole as string | undefined }; +} + +export async function requireAdmin(request: Request) { + const user = await requireUser(request); + if (user.role !== "ADMIN") throw redirect("/"); + return user; } export async function getApiUser(request: Request) { - const { userId } = await getUserSession(request); - return userId ? { id: userId } : null; + const { userId, userRole } = await getUserSession(request); + return userId ? { id: userId, role: userRole } : null; } export async function logout(request: Request) { const session = await sessionStorage.getSession( request.headers.get("Cookie") ); + const userId = session.get("userId") as string | undefined; + await log({ userId, action: "LOGOUT", entity: "User", entityId: userId, request }); return redirect("/login", { headers: { "Set-Cookie": await sessionStorage.destroySession(session) }, }); diff --git a/prisma/migrations/20260313000000_add_username_role_auditlog/migration.sql b/prisma/migrations/20260313000000_add_username_role_auditlog/migration.sql new file mode 100644 index 0000000..363e1d2 --- /dev/null +++ b/prisma/migrations/20260313000000_add_username_role_auditlog/migration.sql @@ -0,0 +1,29 @@ +-- Add UserRole enum (MySQL ENUM) +ALTER TABLE `users` ADD COLUMN `username` VARCHAR(191) NULL; +ALTER TABLE `users` ADD COLUMN `role` ENUM('USER', 'ADMIN') NOT NULL DEFAULT 'USER'; + +-- Backfill username from email (use part before @, ensure uniqueness) +UPDATE `users` SET `username` = LOWER(SUBSTRING_INDEX(`email`, '@', 1)) WHERE `username` IS NULL; + +-- Make username NOT NULL and add unique constraint +ALTER TABLE `users` MODIFY COLUMN `username` VARCHAR(191) NOT NULL; +ALTER TABLE `users` ADD UNIQUE INDEX `users_username_key`(`username`); + +-- Create AuditLog table +CREATE TABLE `audit_logs` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NULL, + `action` VARCHAR(191) NOT NULL, + `entity` VARCHAR(191) NULL, + `entityId` VARCHAR(191) NULL, + `metadata` JSON NULL, + `ipAddress` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `audit_logs_userId_idx`(`userId`), + INDEX `audit_logs_createdAt_idx`(`createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Add foreign key from audit_logs to users +ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260313000001_add_archived_invoice_status/migration.sql b/prisma/migrations/20260313000001_add_archived_invoice_status/migration.sql new file mode 100644 index 0000000..6dbeecc --- /dev/null +++ b/prisma/migrations/20260313000001_add_archived_invoice_status/migration.sql @@ -0,0 +1,2 @@ +-- Extend InvoiceStatus enum with ARCHIVED +ALTER TABLE `invoices` MODIFY COLUMN `status` ENUM('DRAFT', 'SENT', 'PAID', 'CANCELLED', 'ARCHIVED') NOT NULL DEFAULT 'DRAFT'; diff --git a/prisma/migrations/20260313000002_company_archived_invoice_deleted/migration.sql b/prisma/migrations/20260313000002_company_archived_invoice_deleted/migration.sql new file mode 100644 index 0000000..353595c --- /dev/null +++ b/prisma/migrations/20260313000002_company_archived_invoice_deleted/migration.sql @@ -0,0 +1,7 @@ +-- Rename ARCHIVED -> DELETED in InvoiceStatus enum +ALTER TABLE `invoices` MODIFY COLUMN `status` ENUM('DRAFT', 'SENT', 'PAID', 'CANCELLED', 'DELETED') NOT NULL DEFAULT 'DRAFT'; + +-- Add archived fields to companies +ALTER TABLE `companies` + ADD COLUMN `archived` TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN `archivedAt` DATETIME(3) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 417f245..921fe78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,18 +8,42 @@ datasource db { shadowDatabaseUrl = env("SHADOW_DATABASE_URL") } +enum UserRole { + USER + ADMIN +} + model User { - id String @id @default(cuid()) - email String @unique + id String @id @default(cuid()) + email String @unique + username String @unique passwordHash String name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + role UserRole @default(USER) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt companies Company[] + auditLogs AuditLog[] @@map("users") } +model AuditLog { + id String @id @default(cuid()) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + action String + entity String? + entityId String? + metadata Json? + ipAddress String? + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([createdAt]) + @@map("audit_logs") +} + model Company { id String @id @default(cuid()) name String @@ -39,6 +63,8 @@ model Company { invoicePrefix String @default("RE") invoiceSequence Int @default(0) kleinunternehmer Boolean @default(false) + archived Boolean @default(false) + archivedAt DateTime? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) customers Customer[] @@ -97,6 +123,7 @@ enum InvoiceStatus { SENT PAID CANCELLED + DELETED } model InvoiceItem { diff --git a/prisma/seed.ts b/prisma/seed.ts index 5a54cec..95eb26d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,11 +13,13 @@ async function main() { update: {}, create: { email: "anna@example.de", + username: "anna", passwordHash, name: "Anna Musterfrau", + role: "ADMIN", }, }); - console.log(`✓ User created: ${user.email}`); + console.log(`✓ User created: ${user.email} (username: ${user.username}, role: ${user.role})`); // Create demo company const company = await prisma.company.upsert({ @@ -94,6 +96,40 @@ async function main() { }); console.log(`✓ Invoice created: ${invoice.number}`); + const invoice2 = await prisma.invoice.upsert({ + where: { id: "demo-invoice-2" }, + update: {}, + create: { + id: "demo-invoice-2", + number: "RE-2024-002", + companyId: company.id, + customerId: customer.id, + issueDate: new Date("2026-02-15"), + deliveryDate: new Date("2026-02-15"), + dueDate: new Date("2026-03-14"), + status: "PAID", + netTotal: 2000.0, + taxTotal: 380.0, + grossTotal: 2380.0, + items: { + create: [ + { + position: 1, + description: "Buchhaltungsleistungen Februar 2024", + quantity: 10, + unit: "h", + unitPrice: 200.0, + taxRate: 19.0, + netAmount: 2000.0, + taxAmount: 380.0, + grossAmount: 2380.0, + }, + ], + }, + }, + }); + console.log(`✓ Invoice created: ${invoice2.number}`); + // Update company sequence await prisma.company.update({ where: { id: company.id },