ADD: added admin panel and archiv mandates
This commit is contained in:
@@ -62,6 +62,23 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/archiv": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/admin/users": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/admin/users/new": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
|
"/admin/users/:id": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/admin/logs": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
"/api/companies": {
|
"/api/companies": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -109,7 +126,7 @@ type Pages = {
|
|||||||
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/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": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
@@ -121,7 +138,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/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": {
|
"routes/home.tsx": {
|
||||||
id: "routes/home";
|
id: "routes/home";
|
||||||
@@ -163,6 +180,30 @@ type RouteFiles = {
|
|||||||
id: "routes/companies.$id.reports";
|
id: "routes/companies.$id.reports";
|
||||||
page: "/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": {
|
"routes/api.companies.ts": {
|
||||||
id: "routes/api.companies";
|
id: "routes/api.companies";
|
||||||
page: "/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.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.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
|
||||||
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
|
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
|
||||||
|
"routes/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": typeof import("./app/routes/api.companies.ts");
|
||||||
"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");
|
||||||
|
|||||||
@@ -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<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("../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<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("../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<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("../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<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("../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<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("../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<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,11 +1,12 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" }> = {
|
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" | "outline" }> = {
|
||||||
DRAFT: { label: "Entwurf", variant: "secondary" },
|
DRAFT: { label: "Entwurf", variant: "secondary" },
|
||||||
SENT: { label: "Versendet", variant: "warning" },
|
SENT: { label: "Versendet", variant: "warning" },
|
||||||
PAID: { label: "Bezahlt", variant: "success" },
|
PAID: { label: "Bezahlt", variant: "success" },
|
||||||
CANCELLED: { label: "Storniert", variant: "destructive" },
|
CANCELLED: { label: "Storniert", variant: "destructive" },
|
||||||
|
DELETED: { label: "Gelöscht", variant: "outline" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMatches, useLocation, Link, Form } from "react-router";
|
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 {
|
interface Breadcrumb {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,7 +24,13 @@ function getInitials(name?: string | null): string {
|
|||||||
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
|
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 matches = useMatches();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnDashboard = location.pathname === "/";
|
const isOnDashboard = location.pathname === "/";
|
||||||
@@ -85,6 +91,7 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
|
||||||
{/* Dashboard Button */}
|
{/* Dashboard Button */}
|
||||||
{!isOnDashboard && (
|
{!isOnDashboard && (
|
||||||
<Link
|
<Link
|
||||||
@@ -99,7 +106,6 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
|||||||
padding: "0.375rem 0.75rem",
|
padding: "0.375rem 0.75rem",
|
||||||
borderRadius: "0.375rem",
|
borderRadius: "0.375rem",
|
||||||
border: "1px solid #e2e8f0",
|
border: "1px solid #e2e8f0",
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -107,9 +113,50 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Archiv Link */}
|
||||||
|
<Link
|
||||||
|
to="/archiv"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#64748b",
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
Archiv
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Admin Link (only for admins) */}
|
||||||
|
{userRole === "ADMIN" && (
|
||||||
|
<Link
|
||||||
|
to="/admin/users"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#6366f1",
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid #e0e7ff",
|
||||||
|
background: "#eef2ff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User + Logout */}
|
{/* User + Logout */}
|
||||||
{userName && (
|
{userName && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "1rem", flexShrink: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "0.25rem" }}>
|
||||||
<span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span>
|
<span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -146,6 +193,7 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@ export default [
|
|||||||
route("companies/:id/invoices/new", "routes/companies.$id.invoices.new.tsx"),
|
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/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
|
||||||
route("companies/:id/reports", "routes/companies.$id.reports.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
|
// API resource routes
|
||||||
|
|||||||
@@ -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<typeof loader>();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
|
||||||
|
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col">
|
||||||
|
<header
|
||||||
|
className="sticky top-0 z-10 shrink-0"
|
||||||
|
style={{
|
||||||
|
height: "3.5rem",
|
||||||
|
background: "#1e1b4b",
|
||||||
|
borderBottom: "1px solid #312e81",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingLeft: "1.5rem",
|
||||||
|
paddingRight: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
|
<Shield className="w-5 h-5 text-indigo-300" />
|
||||||
|
<span style={{ color: "#c7d2fe", fontWeight: 600, fontSize: "0.9rem" }}>
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#4338ca", marginLeft: "0.25rem" }}>/</span>
|
||||||
|
<nav style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
|
{navItems.map(({ to, label, icon: Icon }) => {
|
||||||
|
const active = location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
padding: "0.25rem 0.625rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
color: active ? "#e0e7ff" : "#818cf8",
|
||||||
|
background: active ? "#312e81" : "transparent",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: active ? 500 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
|
<span style={{ fontSize: "0.8125rem", color: "#818cf8" }}>{userName}</span>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
color: "#818cf8",
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: "0.25rem 0.625rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid #312e81",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-3.5 h-3.5" />
|
||||||
|
App
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useLoaderData } from "react-router";
|
||||||
|
import { requireAdmin } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Audit-Log</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">{total} Einträge insgesamt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid #e2e8f0", background: "#f8fafc" }}>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Zeitpunkt</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Aktion</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Benutzer</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Objekt</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-5 py-8 text-center text-slate-400">
|
||||||
|
Noch keine Einträge vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<tr
|
||||||
|
key={log.id}
|
||||||
|
style={{ borderBottom: i < logs.length - 1 ? "1px solid #f1f5f9" : "none" }}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3 text-slate-400 text-xs whitespace-nowrap">
|
||||||
|
{new Date(log.createdAt).toLocaleString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] ?? "bg-slate-100 text-slate-600"}`}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[log.action] ?? log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-slate-600">
|
||||||
|
{log.userName ? (
|
||||||
|
<span>
|
||||||
|
{log.userName}{" "}
|
||||||
|
<span className="text-slate-400 text-xs">@{log.userUsername}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-slate-500 text-xs">
|
||||||
|
{log.entity ? (
|
||||||
|
<span>
|
||||||
|
{log.entity}
|
||||||
|
{log.entityId && (
|
||||||
|
<span className="text-slate-300"> #{log.entityId.slice(0, 8)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-slate-400 text-xs font-mono">
|
||||||
|
{log.ipAddress ?? "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-6">
|
||||||
|
{page > 1 && (
|
||||||
|
<a
|
||||||
|
href={`?page=${page - 1}`}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="px-3 py-1.5 text-sm text-slate-500">
|
||||||
|
Seite {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages && (
|
||||||
|
<a
|
||||||
|
href={`?page=${page + 1}`}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const loading = navigation.state === "submitting";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link to="/admin/users" className="text-slate-400 hover:text-slate-600">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Benutzer bearbeiten</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-0.5">{user.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<input type="hidden" name="intent" value="update" />
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="flex items-center gap-2.5 rounded-xl bg-red-50 border border-red-100 p-3.5 text-sm text-red-700">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" name="name" type="text" defaultValue={user.name} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">Benutzername</Label>
|
||||||
|
<Input id="username" name="username" type="text" defaultValue={user.username} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" defaultValue={user.email} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Neues Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leer lassen, um Passwort beizubehalten"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="role">Rolle</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
name="role"
|
||||||
|
defaultValue={user.role}
|
||||||
|
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="USER">Benutzer</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
{loading ? "Speichern..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
<Link to="/admin/users">
|
||||||
|
<Button type="button" variant="outline">Abbrechen</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete zone */}
|
||||||
|
<div className="mt-6 bg-white rounded-xl border border-red-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-red-700 mb-1">Benutzer löschen</h2>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
|
Löscht den Benutzer und alle zugehörigen Firmen, Kunden und Rechnungen unwiderruflich.
|
||||||
|
</p>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm(`Benutzer "${user.name}" wirklich löschen?`)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Benutzer löschen
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const loading = navigation.state === "submitting";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link to="/admin/users" className="text-slate-400 hover:text-slate-600">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Neuer Benutzer</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-0.5">Zugangsdaten und Rolle festlegen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="flex items-center gap-2.5 rounded-xl bg-red-50 border border-red-100 p-3.5 text-sm text-red-700">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" name="name" type="text" placeholder="Anna Musterfrau" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">Benutzername</Label>
|
||||||
|
<Input id="username" name="username" type="text" placeholder="anna" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" placeholder="anna@example.de" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input id="password" name="password" type="password" required minLength={8} />
|
||||||
|
<p className="text-xs text-slate-400">Mindestens 8 Zeichen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="role">Rolle</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
name="role"
|
||||||
|
defaultValue="USER"
|
||||||
|
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="USER">Benutzer</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
{loading ? "Erstellen..." : "Benutzer erstellen"}
|
||||||
|
</Button>
|
||||||
|
<Link to="/admin/users">
|
||||||
|
<Button type="button" variant="outline">Abbrechen</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">{users.length} Benutzer registriert</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/admin/users/new"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: "linear-gradient(135deg, #6366f1, #7c3aed)" }}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
Neuer Benutzer
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid #e2e8f0", background: "#f8fafc" }}>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Name</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Benutzername</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">E-Mail</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Rolle</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Firmen</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-slate-500">Erstellt</th>
|
||||||
|
<th className="px-5 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user, i) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
style={{ borderBottom: i < users.length - 1 ? "1px solid #f1f5f9" : "none" }}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3.5 font-medium text-slate-800">{user.name}</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-600 font-mono text-xs">{user.username}</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-600">{user.email}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
{user.role === "ADMIN" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700">
|
||||||
|
<Shield className="w-3 h-3" /> Admin
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600">
|
||||||
|
<User className="w-3 h-3" /> Benutzer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-600">{user._count.companies}</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-400 text-xs">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right">
|
||||||
|
<Link
|
||||||
|
to={`/admin/users/${user.id}`}
|
||||||
|
className="text-indigo-600 hover:text-indigo-800 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,19 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
return Response.json({ ok: true });
|
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
|
// PUT
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = companySchema.safeParse(body);
|
const parsed = companySchema.safeParse(body);
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
if (request.method === "DELETE") {
|
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 } });
|
await prisma.invoice.delete({ where: { id: params.id } });
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof loader>();
|
||||||
|
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 (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<Archive className="h-6 w-6 text-slate-400" />
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Archiv</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">
|
||||||
|
{companies.length === 0
|
||||||
|
? "Keine archivierten Mandanten"
|
||||||
|
: `${companies.length} archivierte ${companies.length === 1 ? "Mandant" : "Mandanten"}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm py-16 text-center">
|
||||||
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-100 mx-auto mb-4">
|
||||||
|
<Archive className="h-7 w-7 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 font-medium mb-1">Archiv ist leer</p>
|
||||||
|
<p className="text-slate-400 text-sm">Archivierte Mandanten erscheinen hier.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{companies.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.id}
|
||||||
|
className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-slate-100 shrink-0">
|
||||||
|
<Building2 className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/companies/${company.id}`}
|
||||||
|
className="font-semibold text-slate-700 hover:text-indigo-600 transition-colors text-sm truncate"
|
||||||
|
>
|
||||||
|
{company.name}
|
||||||
|
</Link>
|
||||||
|
{company.legalForm && (
|
||||||
|
<span className="text-xs text-slate-400">{company.legalForm}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-xs text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
{company._count.invoices} Rechnungen
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{company._count.customers} Kunden
|
||||||
|
</span>
|
||||||
|
<span>Umsatz (bezahlt): {formatCurrency(company.revenue)}</span>
|
||||||
|
{company.archivedAt && (
|
||||||
|
<span>
|
||||||
|
Archiviert: {new Date(company.archivedAt).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Link
|
||||||
|
to={`/companies/${company.id}`}
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-700 px-3 py-1.5 rounded-lg border border-slate-200 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => restore(company.id)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 px-3 py-1.5 rounded-lg border border-indigo-100 hover:bg-indigo-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ArchiveRestore className="h-3.5 w-3.5" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
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";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
@@ -40,6 +40,7 @@ export async function loader({
|
|||||||
if (!invoice) throw new Response("Not Found", { status: 404 });
|
if (!invoice) throw new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isAdmin: user.role === "ADMIN",
|
||||||
invoice: {
|
invoice: {
|
||||||
...invoice,
|
...invoice,
|
||||||
netTotal: Number(invoice.netTotal),
|
netTotal: Number(invoice.netTotal),
|
||||||
@@ -63,7 +64,7 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceDetailPage() {
|
export default function InvoiceDetailPage() {
|
||||||
const { invoice } = useLoaderData<typeof loader>();
|
const { invoice, isAdmin } = useLoaderData<typeof loader>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -91,8 +92,31 @@ export default function InvoiceDetailPage() {
|
|||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleSoftDelete() {
|
||||||
if (!confirm("Rechnung wirklich löschen?")) return;
|
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" });
|
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||||
navigate(`/companies/${id}/invoices`);
|
navigate(`/companies/${id}/invoices`);
|
||||||
}
|
}
|
||||||
@@ -131,9 +155,11 @@ export default function InvoiceDetailPage() {
|
|||||||
|
|
||||||
{/* Invoice Actions */}
|
{/* Invoice Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{invoice.status !== "DELETED" && (
|
||||||
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
||||||
<Download className="h-4 w-4" /> PDF
|
<Download className="h-4 w-4" /> PDF
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{invoice.status === "DRAFT" && (
|
{invoice.status === "DRAFT" && (
|
||||||
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
|
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
|
||||||
<Send className="h-4 w-4" /> Als versendet markieren
|
<Send className="h-4 w-4" /> Als versendet markieren
|
||||||
@@ -149,16 +175,46 @@ export default function InvoiceDetailPage() {
|
|||||||
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
|
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
|
{/* Soft-Delete für alle nicht-gelöschten Status */}
|
||||||
|
{invoice.status !== "DELETED" && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
onClick={handleDelete}
|
onClick={handleSoftDelete}
|
||||||
|
disabled={loading}
|
||||||
|
title="In Papierkorb verschieben"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Gelöschte Rechnung: Wiederherstellen + endgültig löschen (Admin) */}
|
||||||
|
{invoice.status === "DELETED" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-slate-600 hover:text-slate-800"
|
||||||
|
onClick={handleRestore}
|
||||||
|
disabled={loading}
|
||||||
|
title="Als Storniert wiederherstellen"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" /> Wiederherstellen
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={handleHardDelete}
|
||||||
|
disabled={loading}
|
||||||
|
title="Endgültig löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" /> Endgültig löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ export const handle = {
|
|||||||
import { requireUser } from "@/session.server";
|
import { requireUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
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 } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
@@ -41,23 +42,160 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InvoiceRow = ReturnType<typeof useLoaderData<typeof loader>>["invoices"][number];
|
||||||
|
|
||||||
|
function groupByYear(invoices: InvoiceRow[]): Map<number, InvoiceRow[]> {
|
||||||
|
const map = new Map<number, InvoiceRow[]>();
|
||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
to={`/companies/${companyId}/invoices/${invoice.id}`}
|
||||||
|
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-1.5 rounded-lg bg-slate-100 group-hover:bg-indigo-50 transition-colors">
|
||||||
|
<FileText className="h-3.5 w-3.5 text-slate-400 group-hover:text-indigo-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800 text-sm">{invoice.number}</p>
|
||||||
|
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<p className="text-sm text-slate-400 hidden sm:block">{formatDate(invoice.issueDate)}</p>
|
||||||
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
|
<p className="font-medium text-slate-700 w-24 text-right text-sm">
|
||||||
|
{formatCurrency(invoice.grossTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-slate-50 transition-colors"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-slate-800">{year}</span>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{invoices.length} {invoices.length === 1 ? "Rechnung" : "Rechnungen"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-slate-600">{formatCurrency(totalGross)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="divide-y divide-slate-100 border-t border-slate-100">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeletedPanel({
|
||||||
|
invoices,
|
||||||
|
companyId,
|
||||||
|
}: {
|
||||||
|
invoices: InvoiceRow[];
|
||||||
|
companyId: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-red-100 rounded-xl overflow-hidden bg-white shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-red-50/50 transition-colors"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-red-300 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-red-300 shrink-0" />
|
||||||
|
)}
|
||||||
|
<Trash2 className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
<span className="font-medium text-red-700 text-sm">Papierkorb</span>
|
||||||
|
<span className="text-sm text-red-300">
|
||||||
|
{invoices.length} {invoices.length === 1 ? "Rechnung" : "Rechnungen"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="divide-y divide-red-50 border-t border-red-100">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const { company, invoices } = useLoaderData<typeof loader>();
|
const { company, invoices } = useLoaderData<typeof loader>();
|
||||||
const id = company.id;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
to={`/companies/${id}`}
|
to={`/companies/${id}`}
|
||||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 mb-6"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" /> {company.name}
|
<ChevronLeft className="h-4 w-4" /> {company.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Rechnungen</h1>
|
<h1 className="text-2xl font-bold text-slate-900">Rechnungen</h1>
|
||||||
<p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p>
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
|
{activeInvoices.length} Rechnungen für {company.name}
|
||||||
|
{deletedInvoices.length > 0 && (
|
||||||
|
<span className="text-red-400"> · {deletedInvoices.length} im Papierkorb</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/companies/${id}/invoices/new`}>
|
<Link to={`/companies/${id}/invoices/new`}>
|
||||||
@@ -66,48 +204,34 @@ export default function InvoicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{invoices.length === 0 ? (
|
{activeInvoices.length === 0 && deletedInvoices.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
<FileText className="h-12 w-12 text-slate-200 mx-auto mb-4" />
|
||||||
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Rechnungen</h3>
|
<h3 className="font-semibold text-slate-700 mb-2">Noch keine Rechnungen</h3>
|
||||||
<p className="text-gray-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
|
<p className="text-slate-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/companies/${id}/invoices/new`}>
|
<Link to={`/companies/${id}/invoices/new`}>
|
||||||
<Plus className="h-4 w-4" /> Erste Rechnung erstellen
|
<Plus className="h-4 w-4" /> Erste Rechnung erstellen
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<div className="divide-y divide-gray-100">
|
{years.map((year) => (
|
||||||
{invoices.map((invoice) => (
|
<YearPanel
|
||||||
<Link
|
key={year}
|
||||||
key={invoice.id}
|
year={year}
|
||||||
to={`/companies/${id}/invoices/${invoice.id}`}
|
invoices={byYear.get(year)!}
|
||||||
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group"
|
companyId={id}
|
||||||
>
|
defaultOpen={year === currentYear}
|
||||||
<div className="flex items-center gap-4">
|
/>
|
||||||
<div className="p-2 rounded-lg bg-gray-100 group-hover:bg-indigo-50 transition-colors">
|
|
||||||
<FileText className="h-4 w-4 text-gray-500 group-hover:text-indigo-600 transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{invoice.number}</p>
|
|
||||||
<p className="text-sm text-gray-500">{invoice.customer.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p>
|
|
||||||
<InvoiceStatusBadge status={invoice.status} />
|
|
||||||
<p className="font-medium text-gray-900 w-28 text-right">
|
|
||||||
{formatCurrency(invoice.grossTotal)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
|
{deletedInvoices.length > 0 && (
|
||||||
|
<DeletedPanel invoices={deletedInvoices} companyId={id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useLoaderData } 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";
|
import prisma from "@/lib/prisma";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -7,9 +7,10 @@ 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
|
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumbs: (data: { company: { id: string; name: string } }) => [
|
breadcrumbs: (data: { company: { id: string; name: string } }) => [
|
||||||
@@ -23,13 +24,15 @@ const statusLabels: Record<InvoiceStatus, string> = {
|
|||||||
SENT: "Versendet",
|
SENT: "Versendet",
|
||||||
PAID: "Bezahlt",
|
PAID: "Bezahlt",
|
||||||
CANCELLED: "Storniert",
|
CANCELLED: "Storniert",
|
||||||
|
DELETED: "Gelöscht",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning"> = {
|
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning" | "outline"> = {
|
||||||
DRAFT: "secondary",
|
DRAFT: "secondary",
|
||||||
SENT: "warning",
|
SENT: "warning",
|
||||||
PAID: "success",
|
PAID: "success",
|
||||||
CANCELLED: "destructive",
|
CANCELLED: "destructive",
|
||||||
|
DELETED: "outline",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
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 {
|
return {
|
||||||
|
isAdmin: user.role === "ADMIN",
|
||||||
company: {
|
company: {
|
||||||
...company,
|
...company,
|
||||||
|
archivedAt: company.archivedAt?.toISOString() ?? null,
|
||||||
invoices: company.invoices.map((inv) => ({
|
invoices: company.invoices.map((inv) => ({
|
||||||
...inv,
|
...inv,
|
||||||
grossTotal: Number(inv.grossTotal),
|
grossTotal: Number(inv.grossTotal),
|
||||||
@@ -70,24 +75,74 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CompanyPage() {
|
export default function CompanyPage() {
|
||||||
const { company, revenue } = useLoaderData<typeof loader>();
|
const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
const [archiving, setArchiving] = useState(false);
|
||||||
const id = company.id;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Archiv-Banner */}
|
||||||
|
{company.archived && (
|
||||||
|
<div className="flex items-center gap-3 mb-6 px-4 py-3 rounded-xl bg-amber-50 border border-amber-200 text-amber-800 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
|
||||||
|
<span>
|
||||||
|
Dieser Mandant ist archiviert
|
||||||
|
{company.archivedAt && ` seit ${new Date(company.archivedAt).toLocaleDateString("de-DE")}`}.
|
||||||
|
</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={toggleArchive}
|
||||||
|
disabled={archiving}
|
||||||
|
className="ml-auto text-amber-700 underline hover:text-amber-900 text-xs"
|
||||||
|
>
|
||||||
|
Archivierung aufheben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-start justify-between mb-8">
|
<div className="flex items-start justify-between mb-8">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="p-3 rounded-xl bg-indigo-50">
|
<div className={`p-3 rounded-xl ${company.archived ? "bg-slate-100" : "bg-indigo-50"}`}>
|
||||||
<Building2 className="h-6 w-6 text-indigo-600" />
|
<Building2 className={`h-6 w-6 ${company.archived ? "text-slate-400" : "text-indigo-600"}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{company.name}</h1>
|
<h1 className={`text-2xl font-bold ${company.archived ? "text-slate-500" : "text-gray-900"}`}>
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
<p className="text-gray-500 mt-0.5">
|
<p className="text-gray-500 mt-0.5">
|
||||||
{company.legalForm && `${company.legalForm} · `}
|
{company.legalForm && `${company.legalForm} · `}
|
||||||
{company.zip} {company.city}
|
{company.zip} {company.city}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isAdmin && !company.archived && (
|
||||||
|
<Button variant="outline" onClick={toggleArchive} disabled={archiving}>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
Archivieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && company.archived && (
|
||||||
|
<Button variant="outline" onClick={toggleArchive} disabled={archiving}>
|
||||||
|
<ArchiveRestore className="h-4 w-4" />
|
||||||
|
Wiederherstellen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/companies/${id}/edit`}>
|
<Link to={`/companies/${id}/edit`}>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
@@ -95,6 +150,7 @@ export default function CompanyPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
<Link to={`/companies/${id}/invoices/new`} className="block">
|
<Link to={`/companies/${id}/invoices/new`} className="block">
|
||||||
|
|||||||
+113
-51
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||||
import {
|
import {
|
||||||
Building2, Plus, FileText, Users, X, Edit, Receipt,
|
Building2, Plus, FileText, Users, X, Edit, Receipt,
|
||||||
Mail, Phone, CreditCard, ChevronRight,
|
Mail, Phone, CreditCard, ChevronRight, ChevronDown, Archive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
@@ -20,16 +20,18 @@ const statusLabels: Record<InvoiceStatus, string> = {
|
|||||||
SENT: "Versendet",
|
SENT: "Versendet",
|
||||||
PAID: "Bezahlt",
|
PAID: "Bezahlt",
|
||||||
CANCELLED: "Storniert",
|
CANCELLED: "Storniert",
|
||||||
|
DELETED: "Gelöscht",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusVariants: Record<
|
const statusVariants: Record<
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
"secondary" | "default" | "success" | "destructive" | "warning"
|
"secondary" | "default" | "success" | "destructive" | "warning" | "outline"
|
||||||
> = {
|
> = {
|
||||||
DRAFT: "secondary",
|
DRAFT: "secondary",
|
||||||
SENT: "warning",
|
SENT: "warning",
|
||||||
PAID: "success",
|
PAID: "success",
|
||||||
CANCELLED: "destructive",
|
CANCELLED: "destructive",
|
||||||
|
DELETED: "outline",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request }: { request: Request }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
@@ -58,63 +60,38 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompaniesPage() {
|
type Company = ReturnType<typeof useLoaderData<typeof loader>>["companies"][number];
|
||||||
const { companies } = useLoaderData<typeof loader>();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const selected = companies.find((c) => c.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
return (
|
function CompanyCard({
|
||||||
<div className="animate-fade-in flex gap-6">
|
company,
|
||||||
{/* Kacheln */}
|
isActive,
|
||||||
<div className={selected ? "flex-1 min-w-0" : "w-full"}>
|
onSelect,
|
||||||
<div className="flex items-center justify-between mb-8">
|
}: {
|
||||||
<div>
|
company: Company;
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Mandanten</h1>
|
isActive: boolean;
|
||||||
<p className="text-slate-500 mt-1 text-sm">{companies.length} Mandanten verwaltet</p>
|
onSelect: () => void;
|
||||||
</div>
|
}) {
|
||||||
<Button asChild>
|
const archived = company.archived;
|
||||||
<Link to="/companies/new">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Mandant anlegen
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{companies.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm py-16 text-center">
|
|
||||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-100 mx-auto mb-4">
|
|
||||||
<Building2 className="h-7 w-7 text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-slate-700 mb-1">Noch keine Mandanten</h3>
|
|
||||||
<p className="text-slate-400 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/companies/new">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Ersten Mandanten anlegen
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
|
|
||||||
{companies.map((company) => {
|
|
||||||
const isActive = selectedId === company.id;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={company.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedId(isActive ? null : company.id)}
|
onClick={onSelect}
|
||||||
className={`text-left bg-white rounded-2xl border shadow-sm p-5 hover:shadow-md transition-all duration-200 cursor-pointer w-full ${
|
className={`text-left rounded-2xl border shadow-sm p-5 transition-all duration-200 cursor-pointer w-full ${
|
||||||
isActive
|
archived
|
||||||
? "border-indigo-400 ring-2 ring-indigo-100"
|
? "bg-slate-50 border-slate-200 opacity-70 hover:opacity-100"
|
||||||
: "border-slate-200 hover:border-indigo-200"
|
: isActive
|
||||||
|
? "bg-white border-indigo-400 ring-2 ring-indigo-100"
|
||||||
|
: "bg-white border-slate-200 hover:border-indigo-200 hover:shadow-md"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 mb-4">
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-indigo-50 shrink-0">
|
<div className={`flex items-center justify-center w-10 h-10 rounded-xl shrink-0 ${archived ? "bg-slate-100" : "bg-indigo-50"}`}>
|
||||||
<Building2 className="h-5 w-5 text-indigo-600" />
|
{archived
|
||||||
|
? <Archive className="h-5 w-5 text-slate-400" />
|
||||||
|
: <Building2 className="h-5 w-5 text-indigo-600" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-slate-900 text-sm truncate">{company.name}</p>
|
<p className={`font-semibold text-sm truncate ${archived ? "text-slate-500" : "text-slate-900"}`}>{company.name}</p>
|
||||||
{company.legalForm && (
|
{company.legalForm && (
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{company.legalForm}</p>
|
<p className="text-xs text-slate-400 mt-0.5">{company.legalForm}</p>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +112,92 @@ export default function CompaniesPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
export default function CompaniesPage() {
|
||||||
|
const { companies } = useLoaderData<typeof loader>();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||||
|
<div className="animate-fade-in flex gap-6">
|
||||||
|
{/* Kacheln */}
|
||||||
|
<div className={selected ? "flex-1 min-w-0" : "w-full"}>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Mandanten</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
|
{active.length} aktive Mandanten
|
||||||
|
{archived.length > 0 && <span className="text-slate-400"> · {archived.length} archiviert</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/companies/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Mandant anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.length === 0 && archived.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm py-16 text-center">
|
||||||
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-100 mx-auto mb-4">
|
||||||
|
<Building2 className="h-7 w-7 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-700 mb-1">Noch keine Mandanten</h3>
|
||||||
|
<p className="text-slate-400 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/companies/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Ersten Mandanten anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Aktive Mandanten */}
|
||||||
|
{active.length > 0 && (
|
||||||
|
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
|
||||||
|
{active.map((company) => (
|
||||||
|
<CompanyCard
|
||||||
|
key={company.id}
|
||||||
|
company={company}
|
||||||
|
isActive={selectedId === company.id}
|
||||||
|
onSelect={() => setSelectedId(selectedId === company.id ? null : company.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archivierte Mandanten */}
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setArchivedOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700 mb-3 transition-colors"
|
||||||
|
>
|
||||||
|
{archivedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
Archivierte Mandanten ({archived.length})
|
||||||
|
</button>
|
||||||
|
{archivedOpen && (
|
||||||
|
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
|
||||||
|
{archived.map((company) => (
|
||||||
|
<CompanyCard
|
||||||
|
key={company.id}
|
||||||
|
company={company}
|
||||||
|
isActive={selectedId === company.id}
|
||||||
|
onSelect={() => setSelectedId(selectedId === company.id ? null : company.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { Topbar } from "@/components/layout/topbar";
|
|||||||
|
|
||||||
export async function loader({ request }: { request: Request }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
return { userName: user.name };
|
return { userName: user.name, userRole: user.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout() {
|
export default function DashboardLayout() {
|
||||||
const { userName } = useLoaderData<typeof loader>();
|
const { userName, userRole } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex flex-col">
|
<div className="min-h-screen bg-slate-50 flex flex-col">
|
||||||
<Topbar userName={userName} />
|
<Topbar userName={userName} userRole={userRole} />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
+4
-4
@@ -16,21 +16,21 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
|
|
||||||
const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
|
const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
|
||||||
prisma.company.findMany({
|
prisma.company.findMany({
|
||||||
where: { userId },
|
where: { userId, archived: false },
|
||||||
include: { _count: { select: { invoices: true, customers: true } } },
|
include: { _count: { select: { invoices: true, customers: true } } },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
prisma.invoice.aggregate({
|
prisma.invoice.aggregate({
|
||||||
where: { company: { userId } },
|
where: { company: { userId, archived: false } },
|
||||||
_count: true,
|
_count: true,
|
||||||
_sum: { grossTotal: true },
|
_sum: { grossTotal: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoice.aggregate({
|
prisma.invoice.aggregate({
|
||||||
where: { company: { userId }, status: InvoiceStatus.PAID },
|
where: { company: { userId, archived: false }, status: InvoiceStatus.PAID },
|
||||||
_sum: { grossTotal: true },
|
_sum: { grossTotal: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoice.count({
|
prisma.invoice.count({
|
||||||
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
|
where: { company: { userId, archived: false }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -13,13 +13,13 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
|
|
||||||
export async function action({ request }: { request: Request }) {
|
export async function action({ request }: { request: Request }) {
|
||||||
const formData = await request.formData();
|
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 password = formData.get("password") as string;
|
||||||
|
|
||||||
const user = await login(email, password);
|
const user = await login(identifier, password, request);
|
||||||
if (!user) return { error: "E-Mail oder Passwort falsch." };
|
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() {
|
export default function LoginPage() {
|
||||||
@@ -83,7 +83,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Willkommen zurück</h1>
|
<h1 className="text-2xl font-bold text-slate-900">Willkommen zurück</h1>
|
||||||
<p className="text-slate-500 mt-1 text-sm">Melden Sie sich mit Ihren Zugangsdaten an</p>
|
<p className="text-slate-500 mt-1 text-sm">Benutzername oder E-Mail und Passwort eingeben</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-8">
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-8">
|
||||||
@@ -96,14 +96,14 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">E-Mail</Label>
|
<Label htmlFor="identifier">Benutzername oder E-Mail</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="identifier"
|
||||||
name="email"
|
name="identifier"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="anna@example.de"
|
placeholder="anna oder anna@example.de"
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+40
-9
@@ -1,6 +1,7 @@
|
|||||||
import { createCookieSessionStorage, redirect } from "react-router";
|
import { createCookieSessionStorage, redirect } from "react-router";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
const sessionStorage = createCookieSessionStorage({
|
const sessionStorage = createCookieSessionStorage({
|
||||||
cookie: {
|
cookie: {
|
||||||
@@ -14,22 +15,43 @@ const sessionStorage = createCookieSessionStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
identifier: string,
|
||||||
if (!user) return null;
|
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);
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!valid) return null;
|
if (!valid) {
|
||||||
return { id: user.id, email: user.email, name: user.name };
|
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(
|
export async function createUserSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
userName: string,
|
userName: string,
|
||||||
|
userRole: string,
|
||||||
redirectTo: string
|
redirectTo: string
|
||||||
) {
|
) {
|
||||||
const session = await sessionStorage.getSession();
|
const session = await sessionStorage.getSession();
|
||||||
session.set("userId", userId);
|
session.set("userId", userId);
|
||||||
session.set("userName", userName);
|
session.set("userName", userName);
|
||||||
|
session.set("userRole", userRole);
|
||||||
return redirect(redirectTo, {
|
return redirect(redirectTo, {
|
||||||
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
||||||
});
|
});
|
||||||
@@ -42,24 +64,33 @@ export async function getUserSession(request: Request) {
|
|||||||
return {
|
return {
|
||||||
userId: session.get("userId") as string | undefined,
|
userId: session.get("userId") as string | undefined,
|
||||||
userName: session.get("userName") as string | undefined,
|
userName: session.get("userName") as string | undefined,
|
||||||
|
userRole: session.get("userRole") as string | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireUser(request: Request) {
|
export async function requireUser(request: Request) {
|
||||||
const { userId, userName } = await getUserSession(request);
|
const { userId, userName, userRole } = await getUserSession(request);
|
||||||
if (!userId) throw redirect("/login");
|
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) {
|
export async function getApiUser(request: Request) {
|
||||||
const { userId } = await getUserSession(request);
|
const { userId, userRole } = await getUserSession(request);
|
||||||
return userId ? { id: userId } : null;
|
return userId ? { id: userId, role: userRole } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(request: Request) {
|
export async function logout(request: Request) {
|
||||||
const session = await sessionStorage.getSession(
|
const session = await sessionStorage.getSession(
|
||||||
request.headers.get("Cookie")
|
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", {
|
return redirect("/login", {
|
||||||
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
|
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -8,18 +8,42 @@ datasource db {
|
|||||||
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
|
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
|
username String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
name String
|
name String
|
||||||
|
role UserRole @default(USER)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
companies Company[]
|
companies Company[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
@@map("users")
|
@@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 {
|
model Company {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -39,6 +63,8 @@ model Company {
|
|||||||
invoicePrefix String @default("RE")
|
invoicePrefix String @default("RE")
|
||||||
invoiceSequence Int @default(0)
|
invoiceSequence Int @default(0)
|
||||||
kleinunternehmer Boolean @default(false)
|
kleinunternehmer Boolean @default(false)
|
||||||
|
archived Boolean @default(false)
|
||||||
|
archivedAt DateTime?
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
customers Customer[]
|
customers Customer[]
|
||||||
@@ -97,6 +123,7 @@ enum InvoiceStatus {
|
|||||||
SENT
|
SENT
|
||||||
PAID
|
PAID
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
model InvoiceItem {
|
model InvoiceItem {
|
||||||
|
|||||||
+37
-1
@@ -13,11 +13,13 @@ async function main() {
|
|||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
email: "anna@example.de",
|
email: "anna@example.de",
|
||||||
|
username: "anna",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
name: "Anna Musterfrau",
|
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
|
// Create demo company
|
||||||
const company = await prisma.company.upsert({
|
const company = await prisma.company.upsert({
|
||||||
@@ -94,6 +96,40 @@ async function main() {
|
|||||||
});
|
});
|
||||||
console.log(`✓ Invoice created: ${invoice.number}`);
|
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
|
// Update company sequence
|
||||||
await prisma.company.update({
|
await prisma.company.update({
|
||||||
where: { id: company.id },
|
where: { id: company.id },
|
||||||
|
|||||||
Reference in New Issue
Block a user