ADD: changed to rect router

This commit is contained in:
hwinkel
2026-03-10 21:49:01 +01:00
parent 44e79e657f
commit 4bc57b2c4e
102 changed files with 5067 additions and 4824 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npx react-router typegen)",
"Bash(npx react-router build)"
],
"additionalDirectories": [
"/home/henry/.claude/projects/-home-henry-code-AnnasRechnungsManager"
]
}
}
+9
View File
@@ -0,0 +1,9 @@
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
v8_middleware: false
}
}
+233
View File
@@ -0,0 +1,233 @@
// Generated by React Router
import "react-router"
declare module "react-router" {
interface Register {
pages: Pages
routeFiles: RouteFiles
routeModules: RouteModules
}
}
type Pages = {
"/": {
params: {};
};
"/login": {
params: {};
};
"/logout": {
params: {};
};
"/companies": {
params: {};
};
"/companies/new": {
params: {};
};
"/companies/:id": {
params: {
"id": string;
};
};
"/companies/:id/edit": {
params: {
"id": string;
};
};
"/companies/:id/customers": {
params: {
"id": string;
};
};
"/companies/:id/invoices": {
params: {
"id": string;
};
};
"/companies/:id/invoices/new": {
params: {
"id": string;
};
};
"/companies/:id/invoices/:invoiceId": {
params: {
"id": string;
"invoiceId": string;
};
};
"/companies/:id/reports": {
params: {
"id": string;
};
};
"/api/companies": {
params: {};
};
"/api/companies/:id": {
params: {
"id": string;
};
};
"/api/companies/:id/customers": {
params: {
"id": string;
};
};
"/api/companies/:id/invoices": {
params: {
"id": string;
};
};
"/api/customers": {
params: {};
};
"/api/customers/:id": {
params: {
"id": string;
};
};
"/api/invoices": {
params: {};
};
"/api/invoices/:id": {
params: {
"id": string;
};
};
"/api/invoices/:id/pdf": {
params: {
"id": string;
};
};
"/api/reports": {
params: {};
};
};
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
};
"routes/login.tsx": {
id: "routes/login";
page: "/login";
};
"routes/logout.ts": {
id: "routes/logout";
page: "/logout";
};
"routes/dashboard-layout.tsx": {
id: "routes/dashboard-layout";
page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports";
};
"routes/home.tsx": {
id: "routes/home";
page: "/";
};
"routes/companies.tsx": {
id: "routes/companies";
page: "/companies";
};
"routes/companies.new.tsx": {
id: "routes/companies.new";
page: "/companies/new";
};
"routes/companies.$id.tsx": {
id: "routes/companies.$id";
page: "/companies/:id";
};
"routes/companies.$id.edit.tsx": {
id: "routes/companies.$id.edit";
page: "/companies/:id/edit";
};
"routes/companies.$id.customers.tsx": {
id: "routes/companies.$id.customers";
page: "/companies/:id/customers";
};
"routes/companies.$id.invoices.tsx": {
id: "routes/companies.$id.invoices";
page: "/companies/:id/invoices";
};
"routes/companies.$id.invoices.new.tsx": {
id: "routes/companies.$id.invoices.new";
page: "/companies/:id/invoices/new";
};
"routes/companies.$id.invoices.$invoiceId.tsx": {
id: "routes/companies.$id.invoices.$invoiceId";
page: "/companies/:id/invoices/:invoiceId";
};
"routes/companies.$id.reports.tsx": {
id: "routes/companies.$id.reports";
page: "/companies/:id/reports";
};
"routes/api.companies.ts": {
id: "routes/api.companies";
page: "/api/companies";
};
"routes/api.companies.$id.ts": {
id: "routes/api.companies.$id";
page: "/api/companies/:id";
};
"routes/api.companies.$id.customers.ts": {
id: "routes/api.companies.$id.customers";
page: "/api/companies/:id/customers";
};
"routes/api.companies.$id.invoices.ts": {
id: "routes/api.companies.$id.invoices";
page: "/api/companies/:id/invoices";
};
"routes/api.customers.ts": {
id: "routes/api.customers";
page: "/api/customers";
};
"routes/api.customers.$id.ts": {
id: "routes/api.customers.$id";
page: "/api/customers/:id";
};
"routes/api.invoices.ts": {
id: "routes/api.invoices";
page: "/api/invoices";
};
"routes/api.invoices.$id.ts": {
id: "routes/api.invoices.$id";
page: "/api/invoices/:id";
};
"routes/api.invoices.$id.pdf.ts": {
id: "routes/api.invoices.$id.pdf";
page: "/api/invoices/:id/pdf";
};
"routes/api.reports.ts": {
id: "routes/api.reports";
page: "/api/reports";
};
};
type RouteModules = {
"root": typeof import("./app/root.tsx");
"routes/login": typeof import("./app/routes/login.tsx");
"routes/logout": typeof import("./app/routes/logout.ts");
"routes/dashboard-layout": typeof import("./app/routes/dashboard-layout.tsx");
"routes/home": typeof import("./app/routes/home.tsx");
"routes/companies": typeof import("./app/routes/companies.tsx");
"routes/companies.new": typeof import("./app/routes/companies.new.tsx");
"routes/companies.$id": typeof import("./app/routes/companies.$id.tsx");
"routes/companies.$id.edit": typeof import("./app/routes/companies.$id.edit.tsx");
"routes/companies.$id.customers": typeof import("./app/routes/companies.$id.customers.tsx");
"routes/companies.$id.invoices": typeof import("./app/routes/companies.$id.invoices.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.reports": typeof import("./app/routes/companies.$id.reports.tsx");
"routes/api.companies": typeof import("./app/routes/api.companies.ts");
"routes/api.companies.$id": typeof import("./app/routes/api.companies.$id.ts");
"routes/api.companies.$id.customers": typeof import("./app/routes/api.companies.$id.customers.ts");
"routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts");
"routes/api.customers": typeof import("./app/routes/api.customers.ts");
"routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts");
"routes/api.invoices": typeof import("./app/routes/api.invoices.ts");
"routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts");
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
"routes/api.reports": typeof import("./app/routes/api.reports.ts");
};
+18
View File
@@ -0,0 +1,18 @@
// Generated by React Router
declare module "virtual:react-router/server-build" {
import { ServerBuild } from "react-router";
export const assets: ServerBuild["assets"];
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
export const basename: ServerBuild["basename"];
export const entry: ServerBuild["entry"];
export const future: ServerBuild["future"];
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
}
+59
View File
@@ -0,0 +1,59 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../root.js")
type Info = GetInfo<{
file: "root.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../root.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.customers.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.customers.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id.customers";
module: typeof import("../api.companies.$id.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.invoices.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.invoices.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id.invoices";
module: typeof import("../api.companies.$id.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id";
module: typeof import("../api.companies.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.js")
type Info = GetInfo<{
file: "routes/api.companies.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies";
module: typeof import("../api.companies.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.customers.$id.js")
type Info = GetInfo<{
file: "routes/api.customers.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.customers.$id";
module: typeof import("../api.customers.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.customers.js")
type Info = GetInfo<{
file: "routes/api.customers.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.customers";
module: typeof import("../api.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.$id.pdf.js")
type Info = GetInfo<{
file: "routes/api.invoices.$id.pdf.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices.$id.pdf";
module: typeof import("../api.invoices.$id.pdf.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.$id.js")
type Info = GetInfo<{
file: "routes/api.invoices.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices.$id";
module: typeof import("../api.invoices.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.js")
type Info = GetInfo<{
file: "routes/api.invoices.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices";
module: typeof import("../api.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.reports.js")
type Info = GetInfo<{
file: "routes/api.reports.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.reports";
module: typeof import("../api.reports.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.customers.js")
type Info = GetInfo<{
file: "routes/companies.$id.customers.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.customers";
module: typeof import("../companies.$id.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.edit.js")
type Info = GetInfo<{
file: "routes/companies.$id.edit.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.edit";
module: typeof import("../companies.$id.edit.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.$invoiceId.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.$invoiceId.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.$invoiceId";
module: typeof import("../companies.$id.invoices.$invoiceId.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.new.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.new.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.new";
module: typeof import("../companies.$id.invoices.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("../companies.$id.invoices.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices";
module: typeof import("../companies.$id.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.reports.js")
type Info = GetInfo<{
file: "routes/companies.$id.reports.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.reports";
module: typeof import("../companies.$id.reports.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.js")
type Info = GetInfo<{
file: "routes/companies.$id.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id";
module: typeof import("../companies.$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("../companies.new.js")
type Info = GetInfo<{
file: "routes/companies.new.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.new";
module: typeof import("../companies.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("../companies.js")
type Info = GetInfo<{
file: "routes/companies.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies";
module: typeof import("../companies.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../dashboard-layout.js")
type Info = GetInfo<{
file: "routes/dashboard-layout.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-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("../home.js")
type Info = GetInfo<{
file: "routes/home.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/home";
module: typeof import("../home.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../login.js")
type Info = GetInfo<{
file: "routes/login.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/login";
module: typeof import("../login.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../logout.js")
type Info = GetInfo<{
file: "routes/logout.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/logout";
module: typeof import("../logout.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"];
}
+12 -9
View File
@@ -11,18 +11,19 @@ Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
## Tech Stack ## Tech Stack
- **Next.js 14** (App Router) + TypeScript - **React Router v7** (Framework Mode, SSR) + TypeScript
- **MySQL / MariaDB** via Prisma ORM - **MariaDB / MySQL** via Prisma ORM
- **NextAuth.js v5** (Email/Passwort-Login) - **Cookie-Session-Auth** (bcryptjs, kein NextAuth)
- **Tailwind CSS** + shadcn/ui - **Tailwind CSS v4** + shadcn/ui
- **@react-pdf/renderer** für PDF-Generierung - **@react-pdf/renderer** für PDF-Generierung
- **Docker** für die Datenbank
## Setup ## Setup
### 1. Voraussetzungen ### 1. Voraussetzungen
- Node.js 18+ - Node.js 20+
- MySQL oder MariaDB - Docker (für MariaDB)
### 2. Installation ### 2. Installation
@@ -30,11 +31,11 @@ Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
npm install npm install
``` ```
### 3. Datenbank konfigurieren ### 3. Umgebungsvariablen konfigurieren
```bash ```bash
cp .env.example .env cp .env.example .env
# DATABASE_URL in .env anpassen # DATABASE_URL und AUTH_SECRET in .env anpassen
``` ```
### 4. Datenbank einrichten ### 4. Datenbank einrichten
@@ -52,7 +53,9 @@ npx prisma db seed
npm run dev npm run dev
``` ```
Öffne [http://localhost:3000](http://localhost:3000) Startet Docker (PostgreSQL) und den Vite-Dev-Server.
Öffne [http://localhost:5173](http://localhost:5173)
## Datenbank-Kommandos ## Datenbank-Kommandos
View File
@@ -1,5 +1,3 @@
"use client";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -1,5 +1,3 @@
"use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -128,7 +126,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Header info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Kunde *</Label> <Label>Kunde *</Label>
@@ -161,7 +158,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
</div> </div>
</div> </div>
{/* Items */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rechnungspositionen</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rechnungspositionen</h3>
@@ -176,7 +172,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
</div> </div>
<div className="border border-gray-200 rounded-xl overflow-hidden"> <div className="border border-gray-200 rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600"> <div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600">
<div className="col-span-4">Beschreibung</div> <div className="col-span-4">Beschreibung</div>
<div className="col-span-1">Menge</div> <div className="col-span-1">Menge</div>
@@ -194,9 +189,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
{...register(`items.${index}.description`, { required: true })} {...register(`items.${index}.description`, { required: true })}
placeholder="Leistungsbeschreibung" placeholder="Leistungsbeschreibung"
className="text-sm" className="text-sm"
onChange={(e) => {
register(`items.${index}.description`).onChange(e);
}}
/> />
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
@@ -260,7 +252,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
))} ))}
</div> </div>
{/* Totals */}
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<div className="w-64 space-y-1.5"> <div className="w-64 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
@@ -279,7 +270,6 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
</div> </div>
</div> </div>
{/* Notes */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Anmerkungen</Label> <Label>Anmerkungen</Label>
<Textarea {...register("notes")} placeholder="Zahlungsbedingungen, Hinweise..." rows={3} /> <Textarea {...register("notes")} placeholder="Zahlungsbedingungen, Hinweise..." rows={3} />
@@ -246,7 +246,6 @@ interface InvoicePDFProps {
export function InvoicePDFDocument({ invoice }: InvoicePDFProps) { export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
const n = (v: unknown) => Number(v); const n = (v: unknown) => Number(v);
// Group items by tax rate
const taxGroups = invoice.items.reduce( const taxGroups = invoice.items.reduce(
(acc, item) => { (acc, item) => {
const rate = n(item.taxRate); const rate = n(item.taxRate);
@@ -261,7 +260,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View> <View>
<Text style={styles.companyName}>{invoice.company.name}</Text> <Text style={styles.companyName}>{invoice.company.name}</Text>
@@ -287,10 +285,8 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
</View> </View>
{/* Invoice Title */}
<Text style={styles.invoiceTitle}>Rechnung</Text> <Text style={styles.invoiceTitle}>Rechnung</Text>
{/* Address Section */}
<View style={styles.addressSection}> <View style={styles.addressSection}>
<View style={styles.addressBlock}> <View style={styles.addressBlock}>
<Text style={styles.addressLabel}>Rechnungsempfänger</Text> <Text style={styles.addressLabel}>Rechnungsempfänger</Text>
@@ -308,7 +304,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
</View> </View>
{/* Meta */}
<View style={styles.metaGrid}> <View style={styles.metaGrid}>
<View style={styles.metaItem}> <View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rechnungsnummer</Text> <Text style={styles.metaLabel}>Rechnungsnummer</Text>
@@ -330,7 +325,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
</View> </View>
{/* Items Table */}
<View style={styles.tableHeader}> <View style={styles.tableHeader}>
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text> <Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text> <Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
@@ -355,7 +349,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
))} ))}
{/* Totals */}
<View style={styles.totalsSection}> <View style={styles.totalsSection}>
<View style={styles.totalsTable}> <View style={styles.totalsTable}>
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
@@ -375,7 +368,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
</View> </View>
{/* Notes */}
{invoice.notes && ( {invoice.notes && (
<View style={styles.notes}> <View style={styles.notes}>
<Text style={styles.notesLabel}>Hinweise</Text> <Text style={styles.notesLabel}>Hinweise</Text>
@@ -383,7 +375,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
</View> </View>
)} )}
{/* Footer */}
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text style={styles.footerText}> <Text style={styles.footerText}>
{invoice.company.name} {invoice.company.name}
@@ -1,12 +1,7 @@
"use client"; import { Link, Form, useLocation } from "react-router";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react";
import { import {
Calculator, Calculator,
Building2, Building2,
FileText,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
ChevronRight, ChevronRight,
@@ -26,11 +21,11 @@ const navItems: NavItem[] = [
]; ];
export function Sidebar({ userName }: { userName?: string | null }) { export function Sidebar({ userName }: { userName?: string | null }) {
const pathname = usePathname(); const location = useLocation();
const pathname = location.pathname;
return ( return (
<aside className="w-60 shrink-0 flex flex-col bg-white border-r border-gray-200 min-h-screen"> <aside className="w-60 shrink-0 flex flex-col bg-white border-r border-gray-200 min-h-screen">
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-5 border-b border-gray-200"> <div className="flex items-center gap-3 px-4 py-5 border-b border-gray-200">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600">
<Calculator className="w-4 h-4 text-white" /> <Calculator className="w-4 h-4 text-white" />
@@ -40,14 +35,13 @@ export function Sidebar({ userName }: { userName?: string | null }) {
</span> </span>
</div> </div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-0.5"> <nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => { {navItems.map((item) => {
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href)); const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} to={item.href}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -63,20 +57,21 @@ export function Sidebar({ userName }: { userName?: string | null }) {
})} })}
</nav> </nav>
{/* User */}
<div className="px-3 py-4 border-t border-gray-200"> <div className="px-3 py-4 border-t border-gray-200">
{userName && ( {userName && (
<p className="text-xs text-gray-500 px-3 mb-2 truncate">{userName}</p> <p className="text-xs text-gray-500 px-3 mb-2 truncate">{userName}</p>
)} )}
<Form method="post" action="/logout">
<Button <Button
type="submit"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50" className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
onClick={() => signOut({ callbackUrl: "/login" })}
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Abmelden Abmelden
</Button> </Button>
</Form>
</div> </div>
</aside> </aside>
); );
@@ -1,5 +1,3 @@
"use client";
import * as React from "react"; import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import * as React from "react"; import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
@@ -3,7 +3,6 @@ import prisma from "./prisma";
export async function generateInvoiceNumber(companyId: string): Promise<string> { export async function generateInvoiceNumber(companyId: string): Promise<string> {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Atomically increment the sequence number
const company = await prisma.company.update({ const company = await prisma.company.update({
where: { id: companyId }, where: { id: companyId },
data: { invoiceSequence: { increment: 1 } }, data: { invoiceSequence: { increment: 1 } },
View File
+25
View File
@@ -0,0 +1,25 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "./app.css";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Annas Rechnungsmanager</title>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
+31
View File
@@ -0,0 +1,31 @@
import { type RouteConfig, index, route, layout } from "@react-router/dev/routes";
export default [
route("login", "routes/login.tsx"),
route("logout", "routes/logout.ts"),
layout("routes/dashboard-layout.tsx", [
index("routes/home.tsx"),
route("companies", "routes/companies.tsx"),
route("companies/new", "routes/companies.new.tsx"),
route("companies/:id", "routes/companies.$id.tsx"),
route("companies/:id/edit", "routes/companies.$id.edit.tsx"),
route("companies/:id/customers", "routes/companies.$id.customers.tsx"),
route("companies/:id/invoices", "routes/companies.$id.invoices.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/reports", "routes/companies.$id.reports.tsx"),
]),
// API resource routes
route("api/companies", "routes/api.companies.ts"),
route("api/companies/:id", "routes/api.companies.$id.ts"),
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/customers", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.ts"),
route("api/invoices", "routes/api.invoices.ts"),
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
route("api/reports", "routes/api.reports.ts"),
] satisfies RouteConfig;
+17
View File
@@ -0,0 +1,17 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: params.id },
orderBy: { name: "asc" },
});
return Response.json(customers);
}
+18
View File
@@ -0,0 +1,18 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const invoices = await prisma.invoice.findMany({
where: { companyId: params.id },
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
});
return Response.json(invoices);
}
+52
View File
@@ -0,0 +1,52 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
website: z.string().optional(),
bankIban: z.string().optional(),
bankBic: z.string().optional(),
bankName: z.string().optional(),
invoicePrefix: z.string().optional(),
});
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(company);
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.company.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = companySchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.company.update({ where: { id: params.id }, data: parsed.data });
return Response.json(updated);
}
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { getApiUser } from "@/session.server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
@@ -21,32 +20,32 @@ const companySchema = z.object({
invoicePrefix: z.string().optional().default("RE"), invoicePrefix: z.string().optional().default("RE"),
}); });
export async function GET() { export async function loader({ request }: { request: Request }) {
const session = await auth(); const user = await getApiUser(request);
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const companies = await prisma.company.findMany({ const companies = await prisma.company.findMany({
where: { userId: session.user.id }, where: { userId: user.id },
include: { _count: { select: { invoices: true, customers: true } } }, include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
return NextResponse.json(companies); return Response.json(companies);
} }
export async function POST(req: NextRequest) { export async function action({ request }: { request: Request }) {
const session = await auth(); const user = await getApiUser(request);
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await request.json();
const parsed = companySchema.safeParse(body); const parsed = companySchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues }, { status: 400 }); return Response.json({ error: parsed.error.issues }, { status: 400 });
} }
const company = await prisma.company.create({ const company = await prisma.company.create({
data: { ...parsed.data, userId: session.user.id }, data: { ...parsed.data, userId: user.id },
}); });
return NextResponse.json(company, { status: 201 }); return Response.json(company, { status: 201 });
} }
+50
View File
@@ -0,0 +1,50 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const customer = await prisma.customer.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(customer);
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const customer = await prisma.customer.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.customer.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.customer.update({ where: { id: params.id }, data: parsed.data });
return Response.json(updated);
}
+33
View File
@@ -0,0 +1,33 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
companyId: z.string().min(1),
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const customer = await prisma.customer.create({ data: parsed.data });
return Response.json(customer, { status: 201 });
}
@@ -1,15 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { getApiUser } from "@/session.server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const session = await auth(); const user = await getApiUser(request);
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const invoice = await prisma.invoice.findFirst({ const invoice = await prisma.invoice.findFirst({
where: { id, company: { userId: session.user.id } }, where: { id: params.id, company: { userId: user.id } },
include: { include: {
items: { orderBy: { position: "asc" } }, items: { orderBy: { position: "asc" } },
customer: true, customer: true,
@@ -17,9 +14,8 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
}, },
}); });
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
// Dynamic import to avoid SSR bundling issues with @react-pdf/renderer
const { renderToBuffer } = await import("@react-pdf/renderer"); const { renderToBuffer } = await import("@react-pdf/renderer");
const React = (await import("react")).default; const React = (await import("react")).default;
const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf"); const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf");
@@ -28,7 +24,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any; const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any;
const buffer = await renderToBuffer(element); const buffer = await renderToBuffer(element);
return new NextResponse(new Uint8Array(buffer), { return new Response(new Uint8Array(buffer), {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
+47
View File
@@ -0,0 +1,47 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { InvoiceStatus } from "@prisma/client";
import { z } from "zod";
async function getInvoice(id: string, userId: string) {
return prisma.invoice.findFirst({
where: { id, company: { userId } },
include: { items: { orderBy: { position: "asc" } }, customer: true, company: true },
});
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(invoice);
}
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.invoice.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PATCH
const body = await request.json();
const parsed = statusSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.invoice.update({
where: { id: params.id },
data: { status: parsed.data.status },
});
return Response.json(updated);
}
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { getApiUser } from "@/session.server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { generateInvoiceNumber } from "@/lib/invoice-number"; import { generateInvoiceNumber } from "@/lib/invoice-number";
import { z } from "zod"; import { z } from "zod";
@@ -29,21 +28,18 @@ const invoiceSchema = z.object({
grossTotal: z.number(), grossTotal: z.number(),
}); });
export async function POST(req: NextRequest) { export async function action({ request }: { request: Request }) {
const session = await auth(); const user = await getApiUser(request);
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await request.json();
const parsed = invoiceSchema.safeParse(body); const parsed = invoiceSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }); if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const { items, companyId, ...invoiceData } = parsed.data; const { items, companyId, ...invoiceData } = parsed.data;
// Verify company belongs to user const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
const company = await prisma.company.findFirst({ if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
where: { id: companyId, userId: session.user.id },
});
if (!company) return NextResponse.json({ error: "Company not found" }, { status: 404 });
const number = await generateInvoiceNumber(companyId); const number = await generateInvoiceNumber(companyId);
@@ -55,20 +51,10 @@ export async function POST(req: NextRequest) {
issueDate: new Date(invoiceData.issueDate), issueDate: new Date(invoiceData.issueDate),
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null, deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
dueDate: new Date(invoiceData.dueDate), dueDate: new Date(invoiceData.dueDate),
items: { items: { create: items },
create: items.map((item) => ({
...item,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
netAmount: item.netAmount,
taxAmount: item.taxAmount,
grossAmount: item.grossAmount,
})),
},
}, },
include: { items: true, customer: true }, include: { items: true, customer: true },
}); });
return NextResponse.json(invoice, { status: 201 }); return Response.json(invoice, { status: 201 });
} }
@@ -1,24 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { getApiUser } from "@/session.server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { InvoiceStatus } from "@prisma/client"; import { InvoiceStatus } from "@prisma/client";
export async function GET(req: NextRequest) { export async function loader({ request }: { request: Request }) {
const session = await auth(); const user = await getApiUser(request);
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId"); const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear())); const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return NextResponse.json({ error: "companyId required" }, { status: 400 }); if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
where: { id: companyId, userId: session.user.id }, if (!company) return Response.json({ error: "Not found" }, { status: 404 });
});
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Get all paid/sent invoices for the year
const invoices = await prisma.invoice.findMany({ const invoices = await prisma.invoice.findMany({
where: { where: {
companyId, companyId,
@@ -28,14 +24,10 @@ export async function GET(req: NextRequest) {
lt: new Date(`${year + 1}-01-01`), lt: new Date(`${year + 1}-01-01`),
}, },
}, },
include: { include: { items: true, customer: { select: { name: true } } },
items: true,
customer: { select: { name: true } },
},
orderBy: { issueDate: "asc" }, orderBy: { issueDate: "asc" },
}); });
// Build monthly summary
const monthly: Record<number, { const monthly: Record<number, {
month: number; month: number;
invoiceCount: number; invoiceCount: number;
@@ -65,7 +57,6 @@ export async function GET(req: NextRequest) {
} }
} }
// Quarterly
const quarterly = [1, 2, 3, 4].map((q) => { const quarterly = [1, 2, 3, 4].map((q) => {
const months = [q * 3 - 2, q * 3 - 1, q * 3]; const months = [q * 3 - 2, q * 3 - 1, q * 3];
const data = months.map((m) => monthly[m]); const data = months.map((m) => monthly[m]);
@@ -97,5 +88,5 @@ export async function GET(req: NextRequest) {
grossTotal: invoices.reduce((s, i) => s + Number(i.grossTotal), 0), grossTotal: invoices.reduce((s, i) => s + Number(i.grossTotal), 0),
}; };
return NextResponse.json({ year, monthly: Object.values(monthly), quarterly, yearTotal, invoices }); return Response.json({ year, monthly: Object.values(monthly), quarterly, yearTotal, invoices });
} }
@@ -1,8 +1,7 @@
"use client"; import { useState } from "react";
import { Link, useLoaderData, useParams, useRevalidator } from "react-router";
import { useEffect, useState } from "react"; import { requireUser } from "@/session.server";
import { useParams } from "next/navigation"; import prisma from "@/lib/prisma";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -28,23 +27,27 @@ type FormData = z.infer<typeof schema>;
interface Customer { interface Customer {
id: string; id: string;
name: string; name: string;
vatId?: string | null | undefined; vatId?: string | null;
address: string; address: string;
zip: string; zip: string;
city: string; city: string;
email?: string | null | undefined; email?: string | null;
phone?: string | null | undefined; phone?: string | null;
} }
interface CustomerForEdit { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
id: string; const user = await requireUser(request);
name: string; const company = await prisma.company.findFirst({
vatId?: string | undefined; where: { id: params.id, userId: user.id },
address: string; });
zip: string; if (!company) throw new Response("Not Found", { status: 404 });
city: string;
email?: string | undefined; const customers = await prisma.customer.findMany({
phone?: string | undefined; where: { companyId: params.id },
orderBy: { name: "asc" },
});
return { customers, companyId: params.id };
} }
function CustomerForm({ function CustomerForm({
@@ -103,17 +106,10 @@ function CustomerForm({
} }
export default function CustomersPage() { export default function CustomersPage() {
const { id: companyId } = useParams<{ id: string }>(); const { customers, companyId } = useLoaderData<typeof loader>();
const [customers, setCustomers] = useState<Customer[]>([]); const { revalidate } = useRevalidator();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editCustomer, setEditCustomer] = useState<CustomerForEdit | null>(null); const [editCustomer, setEditCustomer] = useState<Customer | null>(null);
async function load() {
const res = await fetch(`/api/companies/${companyId}/customers`);
if (res.ok) setCustomers(await res.json());
}
useEffect(() => { load(); }, [companyId]);
async function handleCreate(data: FormData) { async function handleCreate(data: FormData) {
await fetch("/api/customers", { await fetch("/api/customers", {
@@ -122,7 +118,7 @@ export default function CustomersPage() {
body: JSON.stringify({ ...data, companyId }), body: JSON.stringify({ ...data, companyId }),
}); });
setOpen(false); setOpen(false);
load(); revalidate();
} }
async function handleEdit(data: FormData) { async function handleEdit(data: FormData) {
@@ -133,19 +129,19 @@ export default function CustomersPage() {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
setEditCustomer(null); setEditCustomer(null);
load(); revalidate();
} }
async function handleDelete(customerId: string) { async function handleDelete(customerId: string) {
if (!confirm("Kunden wirklich löschen?")) return; if (!confirm("Kunden wirklich löschen?")) return;
await fetch(`/api/customers/${customerId}`, { method: "DELETE" }); await fetch(`/api/customers/${customerId}`, { method: "DELETE" });
load(); revalidate();
} }
return ( return (
<div> <div>
<Link <Link
href={`/companies/${companyId}`} to={`/companies/${companyId}`}
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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten <ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
@@ -169,7 +165,6 @@ export default function CustomersPage() {
</Dialog> </Dialog>
</div> </div>
{/* Edit Dialog */}
<Dialog open={!!editCustomer} onOpenChange={(o) => !o && setEditCustomer(null)}> <Dialog open={!!editCustomer} onOpenChange={(o) => !o && setEditCustomer(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -177,7 +172,15 @@ export default function CustomersPage() {
</DialogHeader> </DialogHeader>
{editCustomer && ( {editCustomer && (
<CustomerForm <CustomerForm
defaultValues={editCustomer} defaultValues={{
name: editCustomer.name,
address: editCustomer.address,
zip: editCustomer.zip,
city: editCustomer.city,
vatId: editCustomer.vatId ?? undefined,
email: editCustomer.email ?? undefined,
phone: editCustomer.phone ?? undefined,
}}
onSubmit={handleEdit} onSubmit={handleEdit}
submitLabel="Speichern" submitLabel="Speichern"
/> />
@@ -219,16 +222,7 @@ export default function CustomersPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditCustomer({ onClick={() => setEditCustomer(customer)}
id: customer.id,
name: customer.name,
address: customer.address,
zip: customer.zip,
city: customer.city,
vatId: customer.vatId ?? undefined,
email: customer.email ?? undefined,
phone: customer.phone ?? undefined,
})}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -1,36 +1,36 @@
"use client"; import { Link, useLoaderData, useNavigate } from "react-router";
import { requireUser } from "@/session.server";
import { useEffect, useState } from "react"; import prisma from "@/lib/prisma";
import { useRouter, useParams } from "next/navigation";
import { CompanyForm } from "@/components/company/company-form"; import { CompanyForm } from "@/components/company/company-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
export default function EditCompanyPage() { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const router = useRouter(); const user = await requireUser(request);
const { id } = useParams<{ id: string }>(); const company = await prisma.company.findFirst({
const [company, setCompany] = useState<Record<string, unknown> | null>(null); where: { id: params.id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { company };
}
useEffect(() => { export default function EditCompanyPage() {
fetch(`/api/companies/${id}`).then((r) => r.json()).then(setCompany); const { company } = useLoaderData<typeof loader>();
}, [id]); const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) { async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch(`/api/companies/${id}`, { const res = await fetch(`/api/companies/${company.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (res.ok) router.push(`/companies/${id}`); if (res.ok) navigate(`/companies/${company.id}`);
} }
if (!company) return <div className="p-8 text-gray-500">Lade...</div>;
return ( return (
<div> <div>
<Link <Link
href={`/companies/${id}`} to={`/companies/${company.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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten <ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
@@ -1,23 +1,26 @@
import { auth } from "@/lib/auth"; import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { notFound } from "next/navigation"; import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { InvoiceActions } from "./invoice-actions";
import { formatCurrency, formatDate } from "@/lib/tax"; import { formatCurrency, formatDate } from "@/lib/tax";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft, Download, CheckCircle, Send, Trash2 } from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
export default async function InvoiceDetailPage({ export async function loader({
request,
params, params,
}: { }: {
params: Promise<{ id: string; invoiceId: string }>; request: Request;
params: { id: string; invoiceId: string };
}) { }) {
const { id, invoiceId } = await params; const user = await requireUser(request);
const session = await auth(); const { id, invoiceId } = params;
const invoice = await prisma.invoice.findFirst({ const invoice = await prisma.invoice.findFirst({
where: { id: invoiceId, companyId: id, company: { userId: session!.user!.id! } }, where: { id: invoiceId, companyId: id, company: { userId: user.id } },
include: { include: {
items: { orderBy: { position: "asc" } }, items: { orderBy: { position: "asc" } },
customer: true, customer: true,
@@ -25,24 +28,81 @@ export default async function InvoiceDetailPage({
}, },
}); });
if (!invoice) notFound(); if (!invoice) throw new Response("Not Found", { status: 404 });
return {
invoice: {
...invoice,
netTotal: Number(invoice.netTotal),
taxTotal: Number(invoice.taxTotal),
grossTotal: Number(invoice.grossTotal),
issueDate: invoice.issueDate.toISOString(),
dueDate: invoice.dueDate.toISOString(),
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
items: invoice.items.map((item) => ({
...item,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
taxRate: Number(item.taxRate),
netAmount: Number(item.netAmount),
taxAmount: Number(item.taxAmount),
grossAmount: Number(item.grossAmount),
})),
},
};
}
export default function InvoiceDetailPage() {
const { invoice } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
const [loading, setLoading] = useState(false);
const id = invoice.companyId;
// Group items by tax rate for totals block
const taxGroups = invoice.items.reduce( const taxGroups = invoice.items.reduce(
(acc, item) => { (acc, item) => {
const rate = Number(item.taxRate); const rate = item.taxRate;
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 }; if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
acc[rate].net += Number(item.netAmount); acc[rate].net += item.netAmount;
acc[rate].tax += Number(item.taxAmount); acc[rate].tax += item.taxAmount;
return acc; return acc;
}, },
{} as Record<number, { net: number; tax: number }> {} as Record<number, { net: number; tax: number }>
); );
async function updateStatus(status: InvoiceStatus) {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
setLoading(false);
revalidate();
}
async function handleDelete() {
if (!confirm("Rechnung wirklich löschen?")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
navigate(`/companies/${id}/invoices`);
}
async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rechnung-${invoice.number}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
return ( return (
<div> <div>
<Link <Link
href={`/companies/${id}/invoices`} to={`/companies/${id}/invoices`}
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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen <ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
@@ -58,15 +118,44 @@ export default async function InvoiceDetailPage({
{invoice.customer.name} · {formatDate(invoice.issueDate)} {invoice.customer.name} · {formatDate(invoice.issueDate)}
</p> </p>
</div> </div>
<InvoiceActions invoice={{ id: invoice.id, status: invoice.status, companyId: id }} />
{/* Invoice Actions */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
{invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren
</Button>
)}
{invoice.status === "SENT" && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => updateStatus(InvoiceStatus.PAID)}
disabled={loading}
>
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
</Button>
)}
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Invoice document preview */}
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
{/* Sender & Recipient */}
<div className="grid grid-cols-2 gap-8 mb-8"> <div className="grid grid-cols-2 gap-8 mb-8">
<div> <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Absender</p> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Absender</p>
@@ -92,7 +181,6 @@ export default async function InvoiceDetailPage({
</div> </div>
</div> </div>
{/* Dates */}
<div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg"> <div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
<div> <div>
<p className="text-xs text-gray-500">Rechnungsnummer</p> <p className="text-xs text-gray-500">Rechnungsnummer</p>
@@ -114,7 +202,6 @@ export default async function InvoiceDetailPage({
</div> </div>
</div> </div>
{/* Items table */}
<div className="border border-gray-200 rounded-lg overflow-hidden mb-6"> <div className="border border-gray-200 rounded-lg overflow-hidden mb-6">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -133,23 +220,22 @@ export default async function InvoiceDetailPage({
<td className="px-4 py-3 text-gray-500">{item.position}</td> <td className="px-4 py-3 text-gray-500">{item.position}</td>
<td className="px-4 py-3 text-gray-900">{item.description}</td> <td className="px-4 py-3 text-gray-900">{item.description}</td>
<td className="px-4 py-3 text-right text-gray-700"> <td className="px-4 py-3 text-right text-gray-700">
{Number(item.quantity)} {item.unit && <span className="text-gray-500">{item.unit}</span>} {item.quantity} {item.unit && <span className="text-gray-500">{item.unit}</span>}
</td> </td>
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(Number(item.unitPrice))}</td> <td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
<td className="px-4 py-3 text-right text-gray-700">{Number(item.taxRate)}%</td> <td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(Number(item.grossAmount))}</td> <td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Totals */}
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-72 space-y-1.5"> <div className="w-72 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
<span>Nettobetrag</span> <span>Nettobetrag</span>
<span>{formatCurrency(Number(invoice.netTotal))}</span> <span>{formatCurrency(invoice.netTotal)}</span>
</div> </div>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => ( {Object.entries(taxGroups).map(([rate, { net, tax }]) => (
<div key={rate} className="flex justify-between text-sm text-gray-600"> <div key={rate} className="flex justify-between text-sm text-gray-600">
@@ -159,7 +245,7 @@ export default async function InvoiceDetailPage({
))} ))}
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2"> <div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
<span>Gesamtbetrag (brutto)</span> <span>Gesamtbetrag (brutto)</span>
<span>{formatCurrency(Number(invoice.grossTotal))}</span> <span>{formatCurrency(invoice.grossTotal)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -171,7 +257,6 @@ export default async function InvoiceDetailPage({
</div> </div>
)} )}
{/* Bank details */}
{invoice.company.bankIban && ( {invoice.company.bankIban && (
<div className="mt-4 pt-4 border-t border-gray-100"> <div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@@ -185,7 +270,6 @@ export default async function InvoiceDetailPage({
</Card> </Card>
</div> </div>
{/* Sidebar */}
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -194,15 +278,15 @@ export default async function InvoiceDetailPage({
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div> <div>
<p className="text-xs text-gray-500">Netto</p> <p className="text-xs text-gray-500">Netto</p>
<p className="font-medium text-gray-900">{formatCurrency(Number(invoice.netTotal))}</p> <p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500">MwSt.</p> <p className="text-xs text-gray-500">MwSt.</p>
<p className="font-medium text-gray-900">{formatCurrency(Number(invoice.taxTotal))}</p> <p className="font-medium text-gray-900">{formatCurrency(invoice.taxTotal)}</p>
</div> </div>
<div className="border-t border-gray-200 pt-2"> <div className="border-t border-gray-200 pt-2">
<p className="text-xs text-gray-500">Brutto</p> <p className="text-xs text-gray-500">Brutto</p>
<p className="text-lg font-bold text-gray-900">{formatCurrency(Number(invoice.grossTotal))}</p> <p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+73
View File
@@ -0,0 +1,73 @@
import { Link, useLoaderData, useNavigate, redirect } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceForm } from "@/components/invoice/invoice-form";
import { ChevronLeft } from "lucide-react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
if (customers.length === 0) {
throw redirect(`/companies/${id}/customers`);
}
return { company, customers };
}
export default function NewInvoicePage() {
const { company, customers } = useLoaderData<typeof loader>();
const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch("/api/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const invoice = await res.json();
navigate(`/companies/${company.id}/invoices/${invoice.id}`);
} else {
alert("Fehler beim Erstellen der Rechnung.");
}
}
return (
<div>
<Link
to={`/companies/${company.id}/invoices`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Neue Rechnung</h1>
<p className="text-gray-500 mt-1">Für Mandant: {company.name}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent>
<InvoiceForm customers={customers} companyId={company.id} onSubmit={handleSubmit} />
</CardContent>
</Card>
</div>
);
}
@@ -1,21 +1,20 @@
import { auth } from "@/lib/auth"; import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } 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 } from "lucide-react";
export default async function InvoicesPage({ params }: { params: Promise<{ id: string }> }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const { id } = await params; const user = await requireUser(request);
const session = await auth(); const { id } = params;
const company = await prisma.company.findFirst({ const company = await prisma.company.findFirst({
where: { id, userId: session!.user!.id! }, where: { id, userId: user.id },
}); });
if (!company) notFound(); if (!company) throw new Response("Not Found", { status: 404 });
const invoices = await prisma.invoice.findMany({ const invoices = await prisma.invoice.findMany({
where: { companyId: id }, where: { companyId: id },
@@ -23,10 +22,25 @@ export default async function InvoicesPage({ params }: { params: Promise<{ id: s
orderBy: { issueDate: "desc" }, orderBy: { issueDate: "desc" },
}); });
return {
company,
invoices: invoices.map((inv) => ({
...inv,
grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.toISOString(),
})),
};
}
export default function InvoicesPage() {
const { company, invoices } = useLoaderData<typeof loader>();
const id = company.id;
return ( return (
<div> <div>
<Link <Link
href={`/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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> {company.name} <ChevronLeft className="h-4 w-4" /> {company.name}
@@ -38,7 +52,7 @@ export default async function InvoicesPage({ params }: { params: Promise<{ id: s
<p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p> <p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p>
</div> </div>
<Button asChild> <Button asChild>
<Link href={`/companies/${id}/invoices/new`}> <Link to={`/companies/${id}/invoices/new`}>
<Plus className="h-4 w-4" /> Neue Rechnung <Plus className="h-4 w-4" /> Neue Rechnung
</Link> </Link>
</Button> </Button>
@@ -51,7 +65,7 @@ export default async function InvoicesPage({ params }: { params: Promise<{ id: s
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Rechnungen</h3> <h3 className="font-semibold text-gray-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-gray-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
<Button asChild> <Button asChild>
<Link href={`/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>
@@ -63,7 +77,7 @@ export default async function InvoicesPage({ params }: { params: Promise<{ id: s
{invoices.map((invoice) => ( {invoices.map((invoice) => (
<Link <Link
key={invoice.id} key={invoice.id}
href={`/companies/${id}/invoices/${invoice.id}`} to={`/companies/${id}/invoices/${invoice.id}`}
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group" className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group"
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -79,7 +93,7 @@ export default async function InvoicesPage({ params }: { params: Promise<{ id: s
<p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p> <p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p>
<InvoiceStatusBadge status={invoice.status} /> <InvoiceStatusBadge status={invoice.status} />
<p className="font-medium text-gray-900 w-28 text-right"> <p className="font-medium text-gray-900 w-28 text-right">
{formatCurrency(Number(invoice.grossTotal))} {formatCurrency(invoice.grossTotal)}
</p> </p>
</div> </div>
</Link> </Link>
@@ -1,8 +1,5 @@
"use client"; import { useState, useEffect } from "react";
import { Link, useParams } from "react-router";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency } from "@/lib/tax"; import { formatCurrency } from "@/lib/tax";
import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react"; import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react";
@@ -62,7 +59,7 @@ export default function ReportsPage() {
return ( return (
<div> <div>
<Link <Link
href={`/companies/${companyId}`} to={`/companies/${companyId}`}
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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten <ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
@@ -86,7 +83,6 @@ export default function ReportsPage() {
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div> <div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
) : data && ( ) : data && (
<div className="space-y-6"> <div className="space-y-6">
{/* Year Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
@@ -114,7 +110,6 @@ export default function ReportsPage() {
</Card> </Card>
</div> </div>
{/* Quarterly UStVA */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -173,7 +168,6 @@ export default function ReportsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Monthly breakdown */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1,7 +1,6 @@
import { auth } from "@/lib/auth"; import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -26,12 +25,12 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
CANCELLED: "destructive", CANCELLED: "destructive",
}; };
export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const { id } = await params; const user = await requireUser(request);
const session = await auth(); const { id } = params;
const company = await prisma.company.findFirst({ const company = await prisma.company.findFirst({
where: { id, userId: session!.user!.id! }, where: { id, userId: user.id },
include: { include: {
invoices: { invoices: {
include: { customer: { select: { name: true } } }, include: { customer: { select: { name: true } } },
@@ -42,16 +41,33 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
}, },
}); });
if (!company) notFound(); if (!company) throw new Response("Not Found", { status: 404 });
const revenue = await prisma.invoice.aggregate({ const revenue = await prisma.invoice.aggregate({
where: { companyId: id, status: InvoiceStatus.PAID }, where: { companyId: id, status: InvoiceStatus.PAID },
_sum: { grossTotal: true }, _sum: { grossTotal: true },
}); });
return {
company: {
...company,
invoices: company.invoices.map((inv) => ({
...inv,
grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.toISOString(),
})),
},
revenue: Number(revenue._sum.grossTotal ?? 0),
};
}
export default function CompanyPage() {
const { company, revenue } = useLoaderData<typeof loader>();
const id = company.id;
return ( return (
<div> <div>
{/* Header */}
<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 bg-indigo-50">
@@ -66,16 +82,15 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</div> </div>
</div> </div>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={`/companies/${id}/edit`}> <Link to={`/companies/${id}/edit`}>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
Bearbeiten Bearbeiten
</Link> </Link>
</Button> </Button>
</div> </div>
{/* Quick Actions */}
<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 href={`/companies/${id}/invoices/new`} className="block"> <Link to={`/companies/${id}/invoices/new`} className="block">
<Card className="hover:border-indigo-200 hover:shadow-sm transition-all cursor-pointer"> <Card className="hover:border-indigo-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3"> <CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-50"> <div className="p-2 rounded-lg bg-indigo-50">
@@ -85,7 +100,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href={`/companies/${id}/invoices`} className="block"> <Link to={`/companies/${id}/invoices`} className="block">
<Card className="hover:border-blue-200 hover:shadow-sm transition-all cursor-pointer"> <Card className="hover:border-blue-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3"> <CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50"> <div className="p-2 rounded-lg bg-blue-50">
@@ -95,7 +110,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href={`/companies/${id}/customers`} className="block"> <Link to={`/companies/${id}/customers`} className="block">
<Card className="hover:border-green-200 hover:shadow-sm transition-all cursor-pointer"> <Card className="hover:border-green-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3"> <CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-50"> <div className="p-2 rounded-lg bg-green-50">
@@ -105,7 +120,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href={`/companies/${id}/reports`} className="block"> <Link to={`/companies/${id}/reports`} className="block">
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer"> <Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3"> <CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-50"> <div className="p-2 rounded-lg bg-purple-50">
@@ -118,11 +133,10 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Invoices */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Letzte Rechnungen</h2> <h2 className="font-semibold text-gray-900">Letzte Rechnungen</h2>
<Link href={`/companies/${id}/invoices`} className="text-sm text-indigo-600 hover:text-indigo-700"> <Link to={`/companies/${id}/invoices`} className="text-sm text-indigo-600 hover:text-indigo-700">
Alle anzeigen Alle anzeigen
</Link> </Link>
</div> </div>
@@ -137,7 +151,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
{company.invoices.map((invoice) => ( {company.invoices.map((invoice) => (
<Link <Link
key={invoice.id} key={invoice.id}
href={`/companies/${id}/invoices/${invoice.id}`} to={`/companies/${id}/invoices/${invoice.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors" className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
> >
<div> <div>
@@ -147,7 +161,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant={statusVariants[invoice.status]}>{statusLabels[invoice.status]}</Badge> <Badge variant={statusVariants[invoice.status]}>{statusLabels[invoice.status]}</Badge>
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900">
{formatCurrency(Number(invoice.grossTotal))} {formatCurrency(invoice.grossTotal)}
</span> </span>
</div> </div>
</Link> </Link>
@@ -157,7 +171,6 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
</Card> </Card>
</div> </div>
{/* Company Info */}
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -166,7 +179,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div> <div>
<p className="text-xs text-gray-500">Bezahlt (gesamt)</p> <p className="text-xs text-gray-500">Bezahlt (gesamt)</p>
<p className="font-semibold text-gray-900">{formatCurrency(Number(revenue._sum.grossTotal ?? 0))}</p> <p className="font-semibold text-gray-900">{formatCurrency(revenue)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500">Rechnungen</p> <p className="text-xs text-gray-500">Rechnungen</p>
@@ -1,13 +1,10 @@
"use client"; import { Link, useNavigate } from "react-router";
import { useRouter } from "next/navigation";
import { CompanyForm } from "@/components/company/company-form"; import { CompanyForm } from "@/components/company/company-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
export default function NewCompanyPage() { export default function NewCompanyPage() {
const router = useRouter(); const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) { async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch("/api/companies", { const res = await fetch("/api/companies", {
@@ -18,14 +15,14 @@ export default function NewCompanyPage() {
if (res.ok) { if (res.ok) {
const company = await res.json(); const company = await res.json();
router.push(`/companies/${company.id}`); navigate(`/companies/${company.id}`);
} }
} }
return ( return (
<div> <div>
<Link <Link
href="/companies" to="/companies"
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-gray-500 hover:text-gray-700 mb-6"
> >
<ChevronLeft className="h-4 w-4" /> Zurück zu Mandanten <ChevronLeft className="h-4 w-4" /> Zurück zu Mandanten
@@ -1,17 +1,22 @@
import { auth } from "@/lib/auth"; import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2, Plus, FileText, Users } from "lucide-react"; import { Building2, Plus, FileText, Users } from "lucide-react";
export default async function CompaniesPage() { export async function loader({ request }: { request: Request }) {
const session = await auth(); const user = await requireUser(request);
const companies = await prisma.company.findMany({ const companies = await prisma.company.findMany({
where: { userId: session!.user!.id! }, where: { userId: user.id },
include: { _count: { select: { invoices: true, customers: true } } }, include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
return { companies };
}
export default function CompaniesPage() {
const { companies } = useLoaderData<typeof loader>();
return ( return (
<div> <div>
@@ -21,7 +26,7 @@ export default async function CompaniesPage() {
<p className="text-gray-500 mt-1">{companies.length} Mandanten verwaltet</p> <p className="text-gray-500 mt-1">{companies.length} Mandanten verwaltet</p>
</div> </div>
<Button asChild> <Button asChild>
<Link href="/companies/new"> <Link to="/companies/new">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Mandant anlegen Mandant anlegen
</Link> </Link>
@@ -35,7 +40,7 @@ export default async function CompaniesPage() {
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Mandanten</h3> <h3 className="font-semibold text-gray-700 mb-2">Noch keine Mandanten</h3>
<p className="text-gray-500 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p> <p className="text-gray-500 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
<Button asChild> <Button asChild>
<Link href="/companies/new"> <Link to="/companies/new">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Ersten Mandanten anlegen Ersten Mandanten anlegen
</Link> </Link>
@@ -45,7 +50,7 @@ export default async function CompaniesPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{companies.map((company) => ( {companies.map((company) => (
<Link key={company.id} href={`/companies/${company.id}`}> <Link key={company.id} to={`/companies/${company.id}`}>
<Card className="hover:shadow-md transition-all hover:border-indigo-200 cursor-pointer h-full"> <Card className="hover:shadow-md transition-all hover:border-indigo-200 cursor-pointer h-full">
<CardHeader> <CardHeader>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
+23
View File
@@ -0,0 +1,23 @@
import { Outlet, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import { Sidebar } from "@/components/layout/sidebar";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
return { userName: user.name };
}
export default function DashboardLayout() {
const { userName } = useLoaderData<typeof loader>();
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar userName={userName} />
<main className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
<Outlet />
</div>
</main>
</div>
);
}
@@ -1,16 +1,16 @@
import { auth } from "@/lib/auth"; import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { formatCurrency } from "@/lib/tax"; import { formatCurrency } from "@/lib/tax";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2, FileText, Euro, TrendingUp } from "lucide-react"; import { Building2, FileText, Euro } from "lucide-react";
import Link from "next/link";
import { InvoiceStatus } from "@prisma/client"; import { InvoiceStatus } from "@prisma/client";
export default async function DashboardPage() { export async function loader({ request }: { request: Request }) {
const session = await auth(); const user = await requireUser(request);
const userId = session!.user!.id!; const userId = user.id;
const [companies, invoiceStats] = await Promise.all([ const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
prisma.company.findMany({ prisma.company.findMany({
where: { userId }, where: { userId },
include: { _count: { select: { invoices: true, customers: true } } }, include: { _count: { select: { invoices: true, customers: true } } },
@@ -21,16 +21,25 @@ export default async function DashboardPage() {
_count: true, _count: true,
_sum: { grossTotal: true }, _sum: { grossTotal: true },
}), }),
]); prisma.invoice.aggregate({
const paidTotal = await prisma.invoice.aggregate({
where: { company: { userId }, status: InvoiceStatus.PAID }, where: { company: { userId }, status: InvoiceStatus.PAID },
_sum: { grossTotal: true }, _sum: { grossTotal: true },
}); }),
prisma.invoice.count({
const openInvoices = await prisma.invoice.count({
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } }, where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
}); }),
]);
return {
companies,
totalInvoices: invoiceStats._count,
paidTotal: Number(paidTotal._sum.grossTotal ?? 0),
openInvoices,
};
}
export default function DashboardPage() {
const { companies, totalInvoices, paidTotal, openInvoices } = useLoaderData<typeof loader>();
return ( return (
<div> <div>
@@ -39,7 +48,6 @@ export default async function DashboardPage() {
<p className="text-gray-500 mt-1">Übersicht aller Mandanten und Rechnungen</p> <p className="text-gray-500 mt-1">Übersicht aller Mandanten und Rechnungen</p>
</div> </div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
@@ -62,7 +70,7 @@ export default async function DashboardPage() {
<FileText className="h-5 w-5 text-blue-600" /> <FileText className="h-5 w-5 text-blue-600" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-gray-900">{invoiceStats._count}</p> <p className="text-2xl font-bold text-gray-900">{totalInvoices}</p>
<p className="text-sm text-gray-500">Rechnungen gesamt</p> <p className="text-sm text-gray-500">Rechnungen gesamt</p>
</div> </div>
</div> </div>
@@ -90,9 +98,7 @@ export default async function DashboardPage() {
<Euro className="h-5 w-5 text-green-600" /> <Euro className="h-5 w-5 text-green-600" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">{formatCurrency(paidTotal)}</p>
{formatCurrency(Number(paidTotal._sum.grossTotal ?? 0))}
</p>
<p className="text-sm text-gray-500">Bezahlt (brutto)</p> <p className="text-sm text-gray-500">Bezahlt (brutto)</p>
</div> </div>
</div> </div>
@@ -100,14 +106,10 @@ export default async function DashboardPage() {
</Card> </Card>
</div> </div>
{/* Companies */}
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Mandanten</h2> <h2 className="text-lg font-semibold text-gray-900">Mandanten</h2>
<Link <Link to="/companies" className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
href="/companies"
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
Alle anzeigen Alle anzeigen
</Link> </Link>
</div> </div>
@@ -118,7 +120,7 @@ export default async function DashboardPage() {
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" /> <Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">Noch keine Mandanten angelegt.</p> <p className="text-gray-500 mb-4">Noch keine Mandanten angelegt.</p>
<Link <Link
href="/companies/new" to="/companies/new"
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors" className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
> >
Mandant anlegen Mandant anlegen
@@ -128,7 +130,7 @@ export default async function DashboardPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{companies.map((company) => ( {companies.map((company) => (
<Link key={company.id} href={`/companies/${company.id}`}> <Link key={company.id} to={`/companies/${company.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer"> <Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader> <CardHeader>
<CardTitle className="text-base">{company.name}</CardTitle> <CardTitle className="text-base">{company.name}</CardTitle>
@@ -1,41 +1,32 @@
"use client"; import { Form, useActionData, useNavigation, redirect } from "react-router";
import { login, createUserSession, getUserSession } from "@/session.server";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Calculator, AlertCircle } from "lucide-react"; import { Calculator, AlertCircle } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const { userId } = await getUserSession(request);
if (userId) throw redirect("/");
return null;
}
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await login(email, password);
if (!user) return { error: "E-Mail oder Passwort falsch." };
return createUserSession(user.id, user.name, "/");
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const actionData = useActionData<typeof action>();
const [email, setEmail] = useState(""); const navigation = useNavigation();
const [password, setPassword] = useState(""); const loading = navigation.state === "submitting";
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
setLoading(false);
if (res?.error) {
setError("E-Mail oder Passwort falsch.");
} else {
router.push("/");
router.refresh();
}
}
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 flex items-center justify-center p-4">
@@ -54,11 +45,11 @@ export default function LoginPage() {
<CardDescription>Geben Sie Ihre Zugangsdaten ein</CardDescription> <CardDescription>Geben Sie Ihre Zugangsdaten ein</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <Form method="post" className="space-y-4">
{error && ( {actionData?.error && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700"> <div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
{error} {actionData.error}
</div> </div>
)} )}
@@ -66,10 +57,9 @@ export default function LoginPage() {
<Label htmlFor="email">E-Mail</Label> <Label htmlFor="email">E-Mail</Label>
<Input <Input
id="email" id="email"
name="email"
type="email" type="email"
placeholder="anna@example.de" placeholder="anna@example.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
/> />
@@ -79,9 +69,8 @@ export default function LoginPage() {
<Label htmlFor="password">Passwort</Label> <Label htmlFor="password">Passwort</Label>
<Input <Input
id="password" id="password"
name="password"
type="password" type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required required
autoComplete="current-password" autoComplete="current-password"
/> />
@@ -90,7 +79,7 @@ export default function LoginPage() {
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "Anmelden..." : "Anmelden"} {loading ? "Anmelden..." : "Anmelden"}
</Button> </Button>
</form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
import { logout } from "@/session.server";
export async function action({ request }: { request: Request }) {
return logout(request);
}
+66
View File
@@ -0,0 +1,66 @@
import { createCookieSessionStorage, redirect } from "react-router";
import bcrypt from "bcryptjs";
import prisma from "@/lib/prisma";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
path: "/",
sameSite: "lax",
secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"],
secure: process.env.NODE_ENV === "production",
},
});
export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
}
export async function createUserSession(
userId: string,
userName: string,
redirectTo: string
) {
const session = await sessionStorage.getSession();
session.set("userId", userId);
session.set("userName", userName);
return redirect(redirectTo, {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
}
export async function getUserSession(request: Request) {
const session = await sessionStorage.getSession(
request.headers.get("Cookie")
);
return {
userId: session.get("userId") as string | undefined,
userName: session.get("userName") as string | undefined,
};
}
export async function requireUser(request: Request) {
const { userId, userName } = await getUserSession(request);
if (!userId) throw redirect("/login");
return { id: userId, name: userName as string | undefined };
}
export async function getApiUser(request: Request) {
const { userId } = await getUserSession(request);
return userId ? { id: userId } : null;
}
export async function logout(request: Request) {
const session = await sessionStorage.getSession(
request.headers.get("Cookie")
);
return redirect("/login", {
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
});
}
+47
View File
@@ -0,0 +1,47 @@
services:
mariadb:
image: mariadb:11
container_name: annas_mariadb
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: annas_rechnungen
MYSQL_USER: annas_user
MYSQL_PASSWORD: annas_password
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
networks:
- db_network
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 5s
timeout: 5s
retries: 5
phpmyadmin:
image: phpmyadmin:latest
container_name: annas_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: mariadb
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: rootpassword
UPLOAD_LIMIT: 100M
ports:
- "8080:80"
depends_on:
mariadb:
condition: service_healthy
networks:
- db_network
volumes:
mariadb_data:
networks:
db_network:
driver: bridge
+12 -17
View File
@@ -1,18 +1,13 @@
import { defineConfig, globalIgnores } from "eslint/config"; import js from "@eslint/js";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ export default [
...nextVitals, js.configs.recommended,
...nextTs, {
// Override default ignores of eslint-config-next. ignores: ["build/**", ".react-router/**", "node_modules/**"],
globalIgnores([ },
// Default ignores of eslint-config-next: {
".next/**", rules: {
"out/**", "no-unused-vars": "off",
"build/**", },
"next-env.d.ts", },
]), ];
]);
export default eslintConfig;
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+2247 -3857
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -2,10 +2,12 @@
"name": "annas-app", "name": "annas-app",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "docker-compose up -d && react-router dev",
"build": "next build", "build": "react-router build",
"start": "next start", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc",
"lint": "eslint", "lint": "eslint",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts", "db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
@@ -15,7 +17,6 @@
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" "seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -26,29 +27,34 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@react-router/node": "^7.13.1",
"@react-router/serve": "^7",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"isbot": "^5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"react": "19.2.3", "react": "^19",
"react-dom": "19.2.3", "react-dom": "^19",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router": "^7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vite": "^6",
"vite-tsconfig-paths": "^5"
} }
} }
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,110 @@
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`passwordHash` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `companies` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`legalForm` VARCHAR(191) NULL,
`taxId` VARCHAR(191) NULL,
`vatId` VARCHAR(191) NULL,
`address` VARCHAR(191) NOT NULL,
`zip` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'DE',
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`website` VARCHAR(191) NULL,
`bankIban` VARCHAR(191) NULL,
`bankBic` VARCHAR(191) NULL,
`bankName` VARCHAR(191) NULL,
`invoicePrefix` VARCHAR(191) NOT NULL DEFAULT 'RE',
`invoiceSequence` INTEGER NOT NULL DEFAULT 0,
`userId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `customers` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`vatId` VARCHAR(191) NULL,
`taxId` VARCHAR(191) NULL,
`address` VARCHAR(191) NOT NULL,
`zip` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'DE',
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `invoices` (
`id` VARCHAR(191) NOT NULL,
`number` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`issueDate` DATETIME(3) NOT NULL,
`deliveryDate` DATETIME(3) NULL,
`dueDate` DATETIME(3) NOT NULL,
`status` ENUM('DRAFT', 'SENT', 'PAID', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`notes` TEXT NULL,
`netTotal` DECIMAL(10, 2) NOT NULL,
`taxTotal` DECIMAL(10, 2) NOT NULL,
`grossTotal` DECIMAL(10, 2) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `invoices_companyId_number_key`(`companyId`, `number`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `invoice_items` (
`id` VARCHAR(191) NOT NULL,
`invoiceId` VARCHAR(191) NOT NULL,
`position` INTEGER NOT NULL,
`description` TEXT NOT NULL,
`quantity` DECIMAL(10, 3) NOT NULL,
`unit` VARCHAR(191) NULL,
`unitPrice` DECIMAL(10, 2) NOT NULL,
`taxRate` DECIMAL(5, 2) NOT NULL,
`netAmount` DECIMAL(10, 2) NOT NULL,
`taxAmount` DECIMAL(10, 2) NOT NULL,
`grossAmount` DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `companies` ADD CONSTRAINT `companies_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `customers` ADD CONSTRAINT `customers_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_invoiceId_fkey` FOREIGN KEY (`invoiceId`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
+1 -1
View File
@@ -7,7 +7,7 @@ async function main() {
console.log("Seeding database..."); console.log("Seeding database...");
// Create demo user // Create demo user
const passwordHash = await bcrypt.hash("demo123", 12); const passwordHash = await bcrypt.hash("annas_password", 12);
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: "anna@example.de" }, where: { email: "anna@example.de" },
update: {}, update: {},
+5
View File
@@ -0,0 +1,5 @@
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;
-3
View File
@@ -1,3 +0,0 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { InvoiceStatus } from "@prisma/client";
import { Download, CheckCircle, Send, Trash2 } from "lucide-react";
interface Props {
invoice: {
id: string;
status: InvoiceStatus;
companyId: string;
};
}
export function InvoiceActions({ invoice }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function updateStatus(status: InvoiceStatus) {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
setLoading(false);
router.refresh();
}
async function handleDelete() {
if (!confirm("Rechnung wirklich löschen?")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
router.push(`/companies/${invoice.companyId}/invoices`);
}
async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rechnung-${invoice.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
{invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren
</Button>
)}
{invoice.status === "SENT" && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => updateStatus(InvoiceStatus.PAID)}
disabled={loading}
>
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
</Button>
)}
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
<Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
);
}
@@ -1,30 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { InvoiceForm } from "@/components/invoice/invoice-form";
interface Props {
customers: { id: string; name: string }[];
companyId: string;
}
export function InvoiceFormClient({ customers, companyId }: Props) {
const router = useRouter();
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch("/api/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const invoice = await res.json();
router.push(`/companies/${companyId}/invoices/${invoice.id}`);
} else {
alert("Fehler beim Erstellen der Rechnung.");
}
}
return <InvoiceForm customers={customers} companyId={companyId} onSubmit={handleSubmit} />;
}
@@ -1,52 +0,0 @@
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceFormClient } from "./invoice-form-client";
import { ChevronLeft } from "lucide-react";
export default async function NewInvoicePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
const company = await prisma.company.findFirst({
where: { id, userId: session!.user!.id! },
});
if (!company) notFound();
const customers = await prisma.customer.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
if (customers.length === 0) {
redirect(`/companies/${id}/customers`);
}
return (
<div>
<Link
href={`/companies/${id}/invoices`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Neue Rechnung</h1>
<p className="text-gray-500 mt-1">Für Mandant: {company.name}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent>
<InvoiceFormClient customers={customers} companyId={id} />
</CardContent>
</Card>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar userName={session.user.name} />
<main className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
);
}
-3
View File
@@ -1,3 +0,0 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
@@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
// Verify company belongs to user
const company = await prisma.company.findFirst({
where: { id, userId: session.user.id },
});
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
});
return NextResponse.json(customers);
}
@@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const company = await prisma.company.findFirst({
where: { id, userId: session.user.id },
});
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
const invoices = await prisma.invoice.findMany({
where: { companyId: id },
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
});
return NextResponse.json(invoices);
}
-70
View File
@@ -1,70 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
website: z.string().optional(),
bankIban: z.string().optional(),
bankBic: z.string().optional(),
bankName: z.string().optional(),
invoicePrefix: z.string().optional(),
});
async function getCompany(id: string, userId: string) {
return prisma.company.findFirst({ where: { id, userId } });
}
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const company = await getCompany(id, session.user.id);
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(company);
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const company = await getCompany(id, session.user.id);
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json();
const parsed = companySchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.company.update({
where: { id },
data: parsed.data,
});
return NextResponse.json(updated);
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const company = await getCompany(id, session.user.id);
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.company.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
-56
View File
@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
async function getCustomer(id: string, userId: string) {
return prisma.customer.findFirst({
where: { id, company: { userId } },
});
}
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const customer = await getCustomer(id, session.user.id);
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(customer);
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const customer = await getCustomer(id, session.user.id);
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.customer.update({ where: { id }, data: parsed.data });
return NextResponse.json(updated);
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const customer = await getCustomer(id, session.user.id);
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.customer.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
-35
View File
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
companyId: z.string().min(1),
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
// Verify company belongs to user
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: session.user.id },
});
if (!company) return NextResponse.json({ error: "Company not found" }, { status: 404 });
const customer = await prisma.customer.create({ data: parsed.data });
return NextResponse.json(customer, { status: 201 });
}
-57
View File
@@ -1,57 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { InvoiceStatus } from "@prisma/client";
import { z } from "zod";
async function getInvoice(id: string, userId: string) {
return prisma.invoice.findFirst({
where: { id, company: { userId } },
include: {
items: { orderBy: { position: "asc" } },
customer: true,
company: true,
},
});
}
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const invoice = await getInvoice(id, session.user.id);
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(invoice);
}
const statusSchema = z.object({
status: z.nativeEnum(InvoiceStatus),
});
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const invoice = await getInvoice(id, session.user.id);
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json();
const parsed = statusSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.invoice.update({
where: { id },
data: { status: parsed.data.status },
});
return NextResponse.json(updated);
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const invoice = await getInvoice(id, session.user.id);
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.invoice.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-19
View File
@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Annas Rechnungsmanager",
description: "Buchhaltung & Rechnungsverwaltung für Mandanten",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}
-56
View File
@@ -1,56 +0,0 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import prisma from "./prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const passwordValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!passwordValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});
-24
View File
@@ -1,24 +0,0 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login");
const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth");
if (isApiAuth) return NextResponse.next();
if (!isLoggedIn && !isAuthPage) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|public).*)"],
};

Some files were not shown because too many files have changed in this diff Show More