From b22e5baa5cac2d8bf09d687d074972eb2fbb8fc7 Mon Sep 17 00:00:00 2001 From: hwinkel Date: Sun, 3 May 2026 08:46:58 +0200 Subject: [PATCH] feat: add client-side validation utilities and debugging tools - Implemented client-side validation functions for tax ID, VAT ID, IBAN, BIC, and website URL. - Added debug logging functionality to assist in development. - Created a comprehensive validation function for company form data. feat: initialize database with Prisma migrations - Added a server-side script to run Prisma migrations and check database health. - Ensured safe initialization of the database to prevent concurrent migrations. feat: comprehensive server-side error logging - Developed an error logging system that captures detailed error context, including request details and stack traces. - Implemented logging functions for different error types (route, action, database, API, startup). fix: validate user ID existence in audit logs - Updated the logging function to validate that the user ID exists in the database before logging actions. fix: update schemas for optional fields and validation - Modified schemas to allow for nullable fields and refined validation logic for tax ID, VAT ID, IBAN, and BIC. feat: enhance error boundary for better debugging - Improved error boundary to log detailed error information in development mode. - Added a debug panel to the main application layout for real-time error tracking. feat: implement company deletion functionality in admin routes - Added a new API route for deleting companies with appropriate logging. - Integrated delete confirmation in the admin interface for better user experience. fix: handle API errors gracefully - Wrapped API actions in try-catch blocks to log errors and return appropriate responses. feat: generate and save invoice PDFs - Implemented functionality to generate and save invoice PDFs upon status updates. - Added a new column in the database for storing the URL of the generated PDF. chore: update Docker image reference - Changed the Docker image reference to point to the new Git repository. chore: update package dependencies - Added @radix-ui/react-tooltip for enhanced UI components. - Updated package-lock.json to reflect new dependencies. --- .gitignore | 3 + .../console-2026-05-02T20-43-04-874Z.log | 1 + .../page-2026-05-02T20-43-05-337Z.yml | 19 + INTEGRATION_EXAMPLE.md | 95 +++++ app/components/company/company-form.tsx | 381 +++++++++++++----- app/components/debug-panel.tsx | 116 ++++++ app/components/ui/tooltip.tsx | 28 ++ app/entry.server.tsx | 19 +- app/lib/ERROR_LOGGING_GUIDE.md | 136 +++++++ app/lib/client-validation.ts | 244 +++++++++++ app/lib/db-init.server.ts | 73 ++++ app/lib/error-logger.server.ts | 227 +++++++++++ app/lib/logger.server.ts | 14 +- app/lib/schemas.ts | 34 +- app/root.tsx | 57 ++- app/routes.ts | 1 + app/routes/admin.mandanten.tsx | 48 ++- app/routes/api.admin.companies.$id.delete.ts | 34 ++ app/routes/api.companies.ts | 64 ++- app/routes/api.invoices.$id.ts | 49 ++- app/routes/companies.new.tsx | 1 + app/routes/companies.tsx | 6 + docker-compose.yml | 2 +- package-lock.json | 53 +++ package.json | 1 + .../20260502_add_belegurl/migration.sql | 1 + 26 files changed, 1573 insertions(+), 134 deletions(-) create mode 100644 .playwright-mcp/console-2026-05-02T20-43-04-874Z.log create mode 100644 .playwright-mcp/page-2026-05-02T20-43-05-337Z.yml create mode 100644 INTEGRATION_EXAMPLE.md create mode 100644 app/components/debug-panel.tsx create mode 100644 app/components/ui/tooltip.tsx create mode 100644 app/lib/ERROR_LOGGING_GUIDE.md create mode 100644 app/lib/client-validation.ts create mode 100644 app/lib/db-init.server.ts create mode 100644 app/lib/error-logger.server.ts create mode 100644 app/routes/api.admin.companies.$id.delete.ts create mode 100644 prisma/migrations/20260502_add_belegurl/migration.sql diff --git a/.gitignore b/.gitignore index 854ac21..dd69fbd 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ next-env.d.ts # Uploaded Belege (persistent volume in production) data/documents/ + +.vscode/ +.react-router diff --git a/.playwright-mcp/console-2026-05-02T20-43-04-874Z.log b/.playwright-mcp/console-2026-05-02T20-43-04-874Z.log new file mode 100644 index 0000000..eb0e894 --- /dev/null +++ b/.playwright-mcp/console-2026-05-02T20-43-04-874Z.log @@ -0,0 +1 @@ +[ 421ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-05-02T20-43-05-337Z.yml b/.playwright-mcp/page-2026-05-02T20-43-05-337Z.yml new file mode 100644 index 0000000..19949f3 --- /dev/null +++ b/.playwright-mcp/page-2026-05-02T20-43-05-337Z.yml @@ -0,0 +1,19 @@ +- generic [ref=e4]: + - generic [ref=e5]: + - img [ref=e7] + - generic [ref=e9]: Rechnungsmanager + - generic [ref=e10]: + - heading "Willkommen zurück" [level=1] [ref=e11] + - paragraph [ref=e12]: Benutzername oder E-Mail und Passwort eingeben + - generic [ref=e14]: + - generic [ref=e15]: + - text: Benutzername oder E-Mail + - textbox "Benutzername oder E-Mail" [ref=e16]: + - /placeholder: anna oder anna@example.de + - generic [ref=e17]: + - text: Passwort + - generic [ref=e18]: + - textbox "Passwort" [ref=e19] + - button "Passwort anzeigen" [ref=e20]: + - img [ref=e21] + - button "Anmelden" [ref=e24] \ No newline at end of file diff --git a/INTEGRATION_EXAMPLE.md b/INTEGRATION_EXAMPLE.md new file mode 100644 index 0000000..8157a7b --- /dev/null +++ b/INTEGRATION_EXAMPLE.md @@ -0,0 +1,95 @@ +/** + * INTEGRATION EXAMPLE: How to use error logging in your existing routes + * This shows the new error-logger.server in real-world usage + */ + +// Before (old code - minimal logging): +/* +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 }); + + try { + const customer = await prisma.customer.create({ data: parsed.data }); + await log({ userId: user.id, action: "CREATE_CUSTOMER", entity: "Customer", entityId: customer.id, request }); + return Response.json(customer, { status: 201 }); + } catch (error) { + console.error(error); // ❌ Not helpful for debugging + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} +*/ + +// After (with comprehensive error logging): +import { getApiUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { log } from "@/lib/logger.server"; +import { logApiError } from "@/lib/error-logger.server"; +import { customerSchema } from "@/lib/schemas"; + +export async function action({ request }: { request: Request }) { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + try { + const body = await request.json(); + const parsed = customerSchema.safeParse(body); + if (!parsed.success) { + return Response.json({ error: parsed.error.issues }, { status: 400 }); + } + + const customer = await prisma.customer.create({ data: parsed.data }); + await log({ + userId: user.id, + action: "CREATE_CUSTOMER", + entity: "Customer", + entityId: customer.id, + request, + }); + return Response.json(customer, { status: 201 }); + } catch (error) { + // ✅ Now with full context: stack trace, request details, metadata + logApiError(error, { + request, + endpoint: "/api/customers", + userId: user.id, + statusCode: 500, + metadata: { + method: request.method, + }, + }); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ============================================================================ +// WHAT YOU'LL SEE IN THE SERVER LOGS NOW: +// ============================================================================ + +/* +================================================================================ +[API_ERROR] | 2026-05-02T22:15:45.789Z | duplicate entry for unique field 'email' +POST /api/customers | user: user-abc123 | ip: 192.168.1.100 | endpoint: /api/customers | statusCode: 500 +Error: Customer with email 'john@example.com' already exists + at Object.create (file:///app/lib/customer.server.ts:25:13) + at action (file:///app/routes/api.customers.ts:30:7) + at processRequest (file:///app/routes/_middleware.ts:12:3) + +Metadata: { + "method": "POST", + "endpoint": "/api/customers" +} +================================================================================ + +✓ You can now see: + - Exact error message with context + - HTTP method & endpoint + - User ID & IP address + - Stack trace for debugging + - Custom metadata + - Timestamp +*/ diff --git a/app/components/company/company-form.tsx b/app/components/company/company-form.tsx index 1080593..9761614 100644 --- a/app/components/company/company-form.tsx +++ b/app/components/company/company-form.tsx @@ -4,6 +4,8 @@ import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { debugLog, handleApiError } from "@/lib/client-validation"; +import { useState, useEffect } from "react"; const schema = z.object({ name: z.string().min(1, "Name ist erforderlich"), @@ -32,118 +34,317 @@ interface CompanyFormProps { submitLabel?: string; } -function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) { +function Field({ + label, + error, + tooltip, + required = false, + children +}: { + label: string; + error?: string; + tooltip?: string; + required?: boolean; + children: React.ReactNode +}) { + const [showRequiredTooltip, setShowRequiredTooltip] = useState(false); + + // Debug: log errors to console + if (error) { + console.log(`[Field Error] ${label}: ${error}`); + } + return (
- - {children} - {error &&

{error}

} + +
+
+ {children} +
+ {/* Show error tooltip ONLY when there's an error */} + {error && ( +
+ + + +
+

{error}

+ {tooltip &&

{tooltip}

} +
+
+ )} +
); } export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) { - const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ + const [apiError, setApiError] = useState(null); + const [validationError, setValidationError] = useState(null); + const { register, handleSubmit, formState: { errors, isSubmitting, isValid }, watch, trigger } = useForm({ resolver: zodResolver(schema), + mode: "onBlur", // Validate when user leaves field defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues }, }); + // Debug: log all errors + useEffect(() => { + if (Object.keys(errors).length > 0) { + console.log("[Form Errors]", errors); + } + }, [errors]); + + // Watch form data for debug logging + const formData = watch(); + + const handleFormSubmit = async (data: FormData) => { + // Trigger validation to check if form is actually valid + const isFormValid = await trigger(); + + if (!isFormValid) { + // Build error message from validation errors + const errorFields: string[] = []; + + if (errors.name) errorFields.push("Firmenname"); + if (errors.address) errorFields.push("Adresse"); + if (errors.zip) errorFields.push("Postleitzahl"); + if (errors.city) errorFields.push("Ort/Stadt"); + if (errors.email) errorFields.push("E-Mail"); + if (errors.taxId) errorFields.push("Steuernummer"); + if (errors.vatId) errorFields.push("USt-IdNr."); + if (errors.bankIban) errorFields.push("IBAN"); + if (errors.bankBic) errorFields.push("BIC"); + + const fieldList = errorFields.length > 0 + ? errorFields.join(", ") + : "Bitte überprüfen Sie die rot gekennzeichneten Felder"; + + const message = `Folgende Felder sind erforderlich oder falsch: ${fieldList}`; + setValidationError(message); + debugLog("warning", "Form validation failed", { errors: errorFields }); + return; + } + + try { + setApiError(null); + setValidationError(null); + debugLog("info", "Submitting form", data); + + await onSubmit(data); + + debugLog("success", "Form submitted successfully"); + } catch (error) { + debugLog("error", "Form submission failed", error); + handleApiError(error, "/api/companies"); + + // Extract error message for user + if (error instanceof Response) { + try { + const json = await error.clone().json(); + const messages = json.error?.map?.((e: any) => e.message)?.join(", ") || + json.message || + "Ein Fehler ist aufgetreten"; + setApiError(messages); + } catch { + setApiError(`HTTP ${error.status}: ${error.statusText}`); + } + } else if (error instanceof Error) { + setApiError(error.message); + } else { + setApiError("Ein unbekannter Fehler ist aufgetreten"); + } + } + }; + return ( -
-
-

Stammdaten

-
- - - - - - - - - - - - -
+ + {/* Required fields info banner */} +
+

ℹ️ Erforderliche Felder

+

Folgende Felder sind erforderlich: Firmenname, Adresse, Postleitzahl, Ort

-
-

Anschrift

-
-
- - + {/* Validation error banner */} + {validationError && ( +
+

⚠️ Eingabefehler

+

{validationError}

+
+ )} + + {/* API error banner */} + {apiError && ( +
+

❌ Fehler

+

{apiError}

+
+ )} +
+

Stammdaten

+
+ + + + + + + + + + +
- - - - +
+ +
+

Anschrift

+
+
+ + + +
+ + + + - -
-
- -
-

Kontakt

-
- - - - - - - - - -
-
- -
-

Bankverbindung

-
-
- -
- - - - - -
-
-
-

Rechnungseinstellungen

-
- - - +
+

Kontakt

+
+ + + + + + + + + +
-

Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001

-
- -
-
-
- -
- +
+

Bankverbindung

+
+
+ + + +
+ + + + + + +
+
+ +
+

Rechnungseinstellungen

+
+ + + +
+

Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001

+
+ +
+
+ +
+ +
+ ); } diff --git a/app/components/debug-panel.tsx b/app/components/debug-panel.tsx new file mode 100644 index 0000000..d4e4cfa --- /dev/null +++ b/app/components/debug-panel.tsx @@ -0,0 +1,116 @@ +/** + * Debug panel component - shows in development and when debug mode is enabled + * Access via browser console: setDebugMode(true) or localStorage.setItem('DEBUG_MODE', 'true') + */ + +import { useEffect, useState } from "react"; +import { isDebugMode, setDebugMode } from "@/lib/client-validation"; + +export function DebugPanel() { + const [isOpen, setIsOpen] = useState(false); + const [debugEnabled, setDebugEnabled] = useState(false); + + useEffect(() => { + setDebugEnabled(isDebugMode()); + }, []); + + const toggleDebug = () => { + const newState = !debugEnabled; + setDebugMode(newState); + setDebugEnabled(newState); + }; + + // Only show in development + if (process.env.NODE_ENV !== "development") { + return null; + } + + return ( + <> + {/* Floating button */} + + + {/* Debug panel */} + {isOpen && ( +
+
+
+

Debug Mode

+ +
+ +
+ +
+ +
+

Browser Console Commands:

+ + setDebugMode(true) + + + localStorage.setItem('DEBUG_MODE', 'true') + + + isDebugMode() + +
+ +
+

Current State:

+

+ DEBUG: {debugEnabled ? "ON" : "OFF"} +

+

+ ENV: {process.env.NODE_ENV} +

+
+ + +
+
+ )} + + ); +} diff --git a/app/components/ui/tooltip.tsx b/app/components/ui/tooltip.tsx new file mode 100644 index 0000000..71fc4ef --- /dev/null +++ b/app/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 5075f22..2efa593 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,4 +1,6 @@ import { startCleanupScheduler } from "./lib/cleanup.server"; +import { initializeDatabase } from "./lib/db-init.server"; +import { logStartupError, logError } from "./lib/error-logger.server"; import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "react-router"; import { createReadableStreamFromReadable } from "@react-router/node"; @@ -8,6 +10,12 @@ import { renderToPipeableStream } from "react-dom/server"; startCleanupScheduler(); +// Initialize database: run migrations on startup +initializeDatabase().catch((error) => { + logStartupError(error); + process.exit(1); +}); + const ABORT_DELAY = 5_000; export default function handleRequest( @@ -38,11 +46,20 @@ export default function handleRequest( pipe(body); }, onShellError(error: unknown) { + logError("SHELL_ERROR", error, { + request, + route: new URL(request.url).pathname, + }); reject(error); }, onError(error: unknown) { responseStatusCode = 500; - if (shellRendered) console.error(error); + if (shellRendered) { + logError("RENDER_ERROR", error, { + request, + route: new URL(request.url).pathname, + }); + } }, } ); diff --git a/app/lib/ERROR_LOGGING_GUIDE.md b/app/lib/ERROR_LOGGING_GUIDE.md new file mode 100644 index 0000000..0237526 --- /dev/null +++ b/app/lib/ERROR_LOGGING_GUIDE.md @@ -0,0 +1,136 @@ +/** + * Error Logging Usage Examples + * + * Use these patterns in your routes to get better error debugging + */ + +// ============================================================================ +// PATTERN 1: In a loader (data fetching) +// ============================================================================ + +import { json, type LoaderFunction } from "react-router"; +import { logRouteError } from "@/lib/error-logger.server"; +import prisma from "@/lib/prisma.server"; + +export const loader: LoaderFunction = async ({ request, params }) => { + try { + const company = await prisma.company.findUnique({ + where: { id: params.id }, + }); + + if (!company) { + throw new Error(`Company not found: ${params.id}`); + } + + return json({ company }); + } catch (error) { + logRouteError(error, { + request, + route: request.url, + userId: params.userId, // if available + metadata: { + companyId: params.id, + }, + }); + throw error; // Re-throw to let React Router handle it + } +}; + +// ============================================================================ +// PATTERN 2: In an action (mutation/form submission) +// ============================================================================ + +import { logActionError } from "@/lib/error-logger.server"; + +export const action: LoaderFunction = async ({ request, params }) => { + try { + const formData = await request.formData(); + const result = await prisma.company.update({ + where: { id: params.id }, + data: { name: formData.get("name") }, + }); + return json({ success: true, result }); + } catch (error) { + logActionError(error, { + request, + action: "UPDATE_COMPANY", + metadata: { + companyId: params.id, + method: request.method, + }, + }); + throw error; + } +}; + +// ============================================================================ +// PATTERN 3: In an API route (POST/PUT/DELETE) +// ============================================================================ + +import { logApiError } from "@/lib/error-logger.server"; + +export const action: LoaderFunction = async ({ request }) => { + try { + const data = await request.json(); + + // Validate + if (!data.name) { + return json( + { error: "Name is required" }, + { status: 400 } + ); + } + + // Process + const result = await prisma.company.create({ data }); + return json({ success: true, result }, { status: 201 }); + } catch (error) { + logApiError(error, { + request, + endpoint: "/api/companies", + statusCode: 500, + metadata: { + method: request.method, + }, + }); + return json( + { error: "Internal server error" }, + { status: 500 } + ); + } +}; + +// ============================================================================ +// PATTERN 4: Database operations with error context +// ============================================================================ + +import { logDatabaseError } from "@/lib/error-logger.server"; + +async function fetchCompanyWithUsers(companyId: string) { + try { + return await prisma.company.findUnique({ + where: { id: companyId }, + include: { users: true }, + }); + } catch (error) { + logDatabaseError(error, "company.findUnique", { + metadata: { companyId }, + }); + throw error; + } +} + +// ============================================================================ +// OUTPUT EXAMPLE (Server Console) +// ============================================================================ + +/* +================================================================================ +[ROUTE_ERROR] | 2026-05-02T22:10:30.123Z | Company not found | route: /companies/123 | GET /companies/123 | user: user-id-456 +Stack at Object.loader (file:///app/routes/companies.$id.tsx:12:13) + at process._tickCallback (internal/timers.ts:203:26) +Metadata: { + "companyId": "123" +} +================================================================================ +*/ diff --git a/app/lib/client-validation.ts b/app/lib/client-validation.ts new file mode 100644 index 0000000..9bedfca --- /dev/null +++ b/app/lib/client-validation.ts @@ -0,0 +1,244 @@ +/** + * Client-side validation and debugging utilities + * Mirrors server-side validation for real-time user feedback + */ + +// Debug mode - enable via localStorage: localStorage.setItem('DEBUG_MODE', 'true') +export function isDebugMode(): boolean { + if (typeof window === "undefined") return false; + return localStorage.getItem("DEBUG_MODE") === "true"; +} + +export function setDebugMode(enabled: boolean): void { + if (typeof window === "undefined") return; + if (enabled) { + localStorage.setItem("DEBUG_MODE", "true"); + console.log("✅ DEBUG MODE ENABLED"); + } else { + localStorage.removeItem("DEBUG_MODE"); + console.log("❌ DEBUG MODE DISABLED"); + } +} + +/** + * Log error to browser console (only in debug mode) + */ +export function debugLog(type: string, message: string, data?: unknown): void { + if (!isDebugMode()) return; + + const style = { + error: "color: #ef4444; font-weight: bold;", + warning: "color: #f97316; font-weight: bold;", + info: "color: #3b82f6; font-weight: bold;", + success: "color: #22c55e; font-weight: bold;", + }; + + const typeStyle = style[type as keyof typeof style] || style.info; + console.log(`%c[${type.toUpperCase()}] ${message}`, typeStyle, data); +} + +/** + * Validation error type + */ +export interface ValidationError { + field: string; + message: string; +} + +/** + * Validate tax ID (Steuernummer): 10 digits + */ +export function validateTaxId(val: string | null | undefined): ValidationError | null { + if (!val || val === "") return null; + if (!/^\d{10}$/.test(val)) { + return { field: "taxId", message: "Steuernummer muss 10 Ziffern haben" }; + } + return null; +} + +/** + * Validate VAT ID (USt-IdNr): DE + 9 digits + */ +export function validateVatId(val: string | null | undefined): ValidationError | null { + if (!val || val === "") return null; + if (!/^DE\d{9}$/.test(val)) { + return { field: "vatId", message: "USt-IdNr. muss im Format DE + 9 Ziffern sein" }; + } + return null; +} + +/** + * Validate IBAN + */ +export function validateIban(val: string | null | undefined): ValidationError | null { + if (!val || val === "") return null; + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(val)) { + return { field: "bankIban", message: "Ungültige IBAN" }; + } + return null; +} + +/** + * Validate BIC + */ +export function validateBic(val: string | null | undefined): ValidationError | null { + if (!val || val === "") return null; + if (!/^[A-Z0-9]{8,11}$/.test(val)) { + return { field: "bankBic", message: "Ungültiger BIC" }; + } + return null; +} + +/** + * Validate website URL + */ +export function validateWebsite(val: string | null | undefined): ValidationError | null { + if (!val || val === "") return null; + if (!/^https?:\/\//.test(val)) { + return { field: "website", message: "Website muss mit http:// oder https:// beginnen" }; + } + if (val.length > 255) { + return { field: "website", message: "Website darf maximal 255 Zeichen sein" }; + } + return null; +} + +/** + * Validate company form data + */ +export interface CompanyFormData { + name: string; + address: string; + zip: string; + city: string; + taxId?: string; + vatId?: string; + website?: string; + bankIban?: string; + bankBic?: string; + email?: string; + phone?: string; + legalForm?: string; + country?: string; + bankName?: string; +} + +export function validateCompanyForm( + data: CompanyFormData +): ValidationError[] { + const errors: ValidationError[] = []; + + // Required fields + if (!data.name || data.name.trim() === "") { + errors.push({ field: "name", message: "Firmenname erforderlich" }); + } else if (data.name.length > 255) { + errors.push({ field: "name", message: "Firmenname darf maximal 255 Zeichen sein" }); + } + + if (!data.address || data.address.trim() === "") { + errors.push({ field: "address", message: "Adresse erforderlich" }); + } else if (data.address.length > 500) { + errors.push({ field: "address", message: "Adresse darf maximal 500 Zeichen sein" }); + } + + if (!data.zip || data.zip.trim() === "") { + errors.push({ field: "zip", message: "PLZ erforderlich" }); + } else if (!/^[\d\s-]*$/.test(data.zip)) { + errors.push({ field: "zip", message: "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten" }); + } else if (data.zip.length > 20) { + errors.push({ field: "zip", message: "PLZ darf maximal 20 Zeichen sein" }); + } + + if (!data.city || data.city.trim() === "") { + errors.push({ field: "city", message: "Stadt erforderlich" }); + } else if (data.city.length > 100) { + errors.push({ field: "city", message: "Stadt darf maximal 100 Zeichen sein" }); + } + + // Optional fields with validation + if (data.taxId) { + const taxError = validateTaxId(data.taxId); + if (taxError) errors.push(taxError); + } + + if (data.vatId) { + const vatError = validateVatId(data.vatId); + if (vatError) errors.push(vatError); + } + + if (data.website) { + const webError = validateWebsite(data.website); + if (webError) errors.push(webError); + } + + if (data.bankIban) { + const ibanError = validateIban(data.bankIban); + if (ibanError) errors.push(ibanError); + } + + if (data.bankBic) { + const bicError = validateBic(data.bankBic); + if (bicError) errors.push(bicError); + } + + if (data.email && data.email.trim()) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + errors.push({ field: "email", message: "Ungültige E-Mail-Adresse" }); + } + } + + if (data.phone && data.phone.length > 20) { + errors.push({ field: "phone", message: "Telefonnummer darf maximal 20 Zeichen sein" }); + } + + if (data.legalForm && data.legalForm.length > 100) { + errors.push({ field: "legalForm", message: "Rechtsform darf maximal 100 Zeichen sein" }); + } + + if (data.country && data.country.length > 2) { + errors.push({ field: "country", message: "Ländercode darf maximal 2 Zeichen sein" }); + } + + if (data.bankName && data.bankName.length > 255) { + errors.push({ field: "bankName", message: "Bankname darf maximal 255 Zeichen sein" }); + } + + // Debug logging + if (errors.length > 0) { + debugLog("warning", `Validation failed: ${errors.length} error(s)`, errors); + } else { + debugLog("success", "Validation passed", data); + } + + return errors; +} + +/** + * Validate API response errors + */ +export function handleApiError(error: unknown, endpoint: string): void { + debugLog("error", `API Error from ${endpoint}`, error); + + if (error instanceof Response) { + debugLog("error", `HTTP ${error.status}: ${error.statusText}`, error); + } else if (error instanceof Error) { + debugLog("error", error.message, error.stack); + } else { + debugLog("error", "Unknown error", error); + } +} + +/** + * Get error message for a specific field + */ +export function getFieldError(field: string, errors: ValidationError[]): string | null { + const error = errors.find((e) => e.field === field); + return error?.message || null; +} + +/** + * Check if field has error + */ +export function hasFieldError(field: string, errors: ValidationError[]): boolean { + return errors.some((e) => e.field === field); +} diff --git a/app/lib/db-init.server.ts b/app/lib/db-init.server.ts new file mode 100644 index 0000000..e6b37e3 --- /dev/null +++ b/app/lib/db-init.server.ts @@ -0,0 +1,73 @@ +import { execSync } from "node:child_process"; +import prisma from "./prisma.server"; + +let initStarted = false; +let initCompleted = false; + +/** + * Run Prisma migrations to bring database schema up to date + */ +async function runMigrations(): Promise { + try { + console.log("[DB Init] Running Prisma migrations..."); + execSync("npx prisma migrate deploy", { + stdio: "inherit", + env: { ...process.env }, + }); + console.log("[DB Init] ✓ Migrations completed successfully"); + } catch (error) { + console.error("[DB Init] ✗ Migration failed:", error); + throw new Error("Database migration failed. See logs above."); + } +} + +/** + * Initialize database: run all pending migrations + * Safe to call multiple times (idempotent) + */ +export async function initializeDatabase(): Promise { + // Prevent concurrent initialization attempts + if (initCompleted) return; + if (initStarted) { + // Wait for initialization to complete + while (!initCompleted) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return; + } + + initStarted = true; + + try { + await runMigrations(); + console.log("[DB Init] ✓ Database initialization complete"); + } catch (error) { + console.error("[DB Init] Fatal error during database initialization:", error); + throw error; + } finally { + initCompleted = true; + } +} + +/** + * Check database health (non-blocking) + */ +export async function checkDatabaseHealth(): Promise<{ + connected: boolean; + isEmpty: boolean; + error?: string; +}> { + try { + const userCount = await prisma.user.count(); + return { + connected: true, + isEmpty: userCount === 0, + }; + } catch (error) { + return { + connected: false, + isEmpty: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} diff --git a/app/lib/error-logger.server.ts b/app/lib/error-logger.server.ts new file mode 100644 index 0000000..7976623 --- /dev/null +++ b/app/lib/error-logger.server.ts @@ -0,0 +1,227 @@ +/** + * Comprehensive server-side error logging for debugging + * Captures errors with full context: stack traces, request details, environment + */ + +export interface ErrorContext { + request?: Request; + userId?: string | null; + route?: string; + metadata?: Record; +} + +export interface ErrorLogEntry { + timestamp: string; + type: string; + message: string; + stack?: string; + context?: { + method?: string; + url?: string; + route?: string; + userId?: string | null; + ipAddress?: string | null; + userAgent?: string; + metadata?: Record; + }; + environment: string; +} + +/** + * Extract error message and stack from any error type + */ +function extractErrorInfo(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack, + }; + } + + if (typeof error === "string") { + return { message: error }; + } + + if (error && typeof error === "object") { + const err = error as Record; + return { + message: (err.message as string) || String(error), + stack: (err.stack as string) || undefined, + }; + } + + return { message: String(error) }; +} + +/** + * Extract request context + */ +function extractRequestContext( + request?: Request +): ErrorLogEntry["context"] { + if (!request) return {}; + + const url = new URL(request.url); + const ipAddress = + request.headers.get("x-forwarded-for") ?? + request.headers.get("x-real-ip") ?? + null; + const userAgent = request.headers.get("user-agent"); + + return { + method: request.method, + url: url.pathname + url.search, + ipAddress, + userAgent: userAgent ?? undefined, + }; +} + +/** + * Build comprehensive error log entry + */ +function buildErrorLogEntry( + type: string, + error: unknown, + context?: ErrorContext +): ErrorLogEntry { + const { message, stack } = extractErrorInfo(error); + const requestContext = extractRequestContext(context?.request); + + return { + timestamp: new Date().toISOString(), + type, + message, + stack, + context: { + ...requestContext, + route: context?.route, + userId: context?.userId, + metadata: context?.metadata, + }, + environment: process.env.NODE_ENV || "development", + }; +} + +/** + * Format error log for console output + */ +function formatErrorLog(entry: ErrorLogEntry): string { + const parts = [ + `[${entry.type}]`, + `${entry.timestamp}`, + entry.message, + ]; + + if (entry.context?.route) parts.push(`route: ${entry.context.route}`); + if (entry.context?.method && entry.context?.url) { + parts.push(`${entry.context.method} ${entry.context.url}`); + } + if (entry.context?.userId) parts.push(`user: ${entry.context.userId}`); + if (entry.context?.ipAddress) parts.push(`ip: ${entry.context.ipAddress}`); + + let output = parts.join(" | "); + + if (entry.stack) { + output += "\n" + entry.stack; + } + + if (entry.context?.metadata) { + output += "\nMetadata: " + JSON.stringify(entry.context.metadata, null, 2); + } + + return output; +} + +/** + * Log a route/loader error + */ +export function logRouteError( + error: unknown, + context: ErrorContext & { route: string } +): void { + const entry = buildErrorLogEntry("ROUTE_ERROR", error, context); + console.error("\n" + "=".repeat(80)); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} + +/** + * Log an action/mutation error + */ +export function logActionError( + error: unknown, + context: ErrorContext & { action: string } +): void { + const entry = buildErrorLogEntry("ACTION_ERROR", error, { + ...context, + metadata: { + action: context.action, + ...context.metadata, + }, + }); + console.error("\n" + "=".repeat(80)); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} + +/** + * Log a database error + */ +export function logDatabaseError( + error: unknown, + operation: string, + context?: Omit +): void { + const entry = buildErrorLogEntry("DATABASE_ERROR", error, { + ...context, + metadata: { operation }, + }); + console.error("\n" + "=".repeat(80)); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} + +/** + * Log an API error + */ +export function logApiError( + error: unknown, + context: ErrorContext & { endpoint: string; statusCode?: number } +): void { + const entry = buildErrorLogEntry("API_ERROR", error, { + ...context, + metadata: { + endpoint: context.endpoint, + statusCode: context.statusCode || 500, + ...context.metadata, + }, + }); + console.error("\n" + "=".repeat(80)); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} + +/** + * Log a server startup error + */ +export function logStartupError(error: unknown): void { + const entry = buildErrorLogEntry("STARTUP_ERROR", error); + console.error("\n" + "=".repeat(80)); + console.error("🚨 CRITICAL: Server failed to start"); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} + +/** + * Log a generic error with type + */ +export function logError( + type: string, + error: unknown, + context?: ErrorContext +): void { + const entry = buildErrorLogEntry(type, error, context); + console.error("\n" + "=".repeat(80)); + console.error(formatErrorLog(entry)); + console.error("=".repeat(80) + "\n"); +} diff --git a/app/lib/logger.server.ts b/app/lib/logger.server.ts index 60bcd51..4790156 100644 --- a/app/lib/logger.server.ts +++ b/app/lib/logger.server.ts @@ -47,9 +47,21 @@ export async function log({ : undefined; try { + // Validate that userId exists in the database if provided + let validatedUserId = userId; + if (userId) { + const userExists = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (!userExists) { + validatedUserId = null; // User doesn't exist, log as anonymous + } + } + await prisma.auditLog.create({ data: { - userId: userId ?? null, + userId: validatedUserId ?? null, action, entity: entity ?? null, entityId: entityId ?? null, diff --git a/app/lib/schemas.ts b/app/lib/schemas.ts index 993a1bf..d7f97ed 100644 --- a/app/lib/schemas.ts +++ b/app/lib/schemas.ts @@ -35,7 +35,7 @@ export const taxRateSchema = z export const ibanSchema = z .string() .refine( - (iban) => /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban), + (iban) => iban === "" || /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban), "Ungültige IBAN" ); @@ -44,14 +44,20 @@ export const ibanSchema = z */ export const taxIdSchema = z .string() - .regex(/^\d{10}$/, "Steuernummer muss 10 Ziffern haben"); + .refine( + (val) => val === "" || /^\d{10}$/.test(val), + "Steuernummer muss 10 Ziffern haben" + ); /** * VAT ID (Umsatzsteuer-IdNr): DE + 9 digits */ export const vatIdSchema = z .string() - .regex(/^DE\d{9}$/, "USt-IdNr. muss im Format DE + 9 Ziffern sein"); + .refine( + (val) => val === "" || /^DE\d{9}$/.test(val), + "USt-IdNr. muss im Format DE + 9 Ziffern sein" + ); // ===== Invoice Schemas ===== @@ -131,8 +137,8 @@ export const companySchema = z.object({ .string() .max(100, "Rechtsform darf maximal 100 Zeichen sein") .optional(), - taxId: taxIdSchema.optional(), - vatId: vatIdSchema.optional(), + taxId: taxIdSchema.nullable(), + vatId: vatIdSchema.nullable(), address: z .string() .min(1, "Adresse erforderlich") @@ -162,14 +168,22 @@ export const companySchema = z.object({ .optional(), website: z .string() - .url("Ungültige URL") + .refine( + (val) => val === "" || /^https?:\/\//.test(val), + "Website muss mit http:// oder https:// beginnen" + ) .max(255, "Website darf maximal 255 Zeichen sein") - .optional(), - bankIban: ibanSchema.optional(), + .optional() + .or(z.literal("")), + bankIban: ibanSchema.nullable(), bankBic: z .string() - .regex(/^[A-Z0-9]{8,11}$/, "Ungültiger BIC") - .optional(), + .refine( + (val) => val === "" || /^[A-Z0-9]{8,11}$/.test(val), + "Ungültiger BIC" + ) + .optional() + .or(z.literal("")), bankName: z .string() .max(255, "Bankname darf maximal 255 Zeichen sein") diff --git a/app/root.tsx b/app/root.tsx index cab0814..5688e11 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,21 +1,61 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router"; import "./app.css"; +import { DebugPanel } from "./components/debug-panel"; export function ErrorBoundary() { const error = useRouteError(); - const message = isRouteErrorResponse(error) + + // Get error details + const isResponse = isRouteErrorResponse(error); + const status = isResponse ? error.status : 500; + const statusText = isResponse ? error.statusText : "Internal Server Error"; + const message = isResponse ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; + + // Log error details for debugging + if (typeof console !== "undefined") { + console.error("\n" + "=".repeat(80)); + console.error("[ERROR_BOUNDARY]", new Date().toISOString()); + console.error(`Status: ${status} ${statusText}`); + console.error(`Message: ${message}`); + if (stack) console.error("Stack:\n" + stack); + if (error && typeof error === "object") { + console.error("Full Error Object:", error); + } + console.error("=".repeat(80) + "\n"); + } + return ( - + + + + + -

Fehler

-
{message}
- {import.meta.env.DEV && stack &&
{stack}
} +

+ {status} Fehler +

+

+ {statusText} +

+
+          {message}
+        
+ {import.meta.env.DEV && stack && ( +
+ + Stack Trace (Dev Only) + +
+              {stack}
+            
+
+ )} @@ -45,5 +85,10 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return ; + return ( + <> + + + + ); } diff --git a/app/routes.ts b/app/routes.ts index 05fb5ad..ad4a1f6 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -45,6 +45,7 @@ export default [ route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"), route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"), route("api/companies/:id/money", "routes/api.companies.$id.money.ts"), + route("api/admin/companies/:id/delete", "routes/api.admin.companies.$id.delete.ts"), route("api/customers", "routes/api.customers.ts"), route("api/customers/:id", "routes/api.customers.$id.ts"), route("api/services", "routes/api.services.ts"), diff --git a/app/routes/admin.mandanten.tsx b/app/routes/admin.mandanten.tsx index 57e985c..f53db2f 100644 --- a/app/routes/admin.mandanten.tsx +++ b/app/routes/admin.mandanten.tsx @@ -2,7 +2,8 @@ import { Link, useLoaderData } from "react-router"; import { requireAdmin } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { Badge } from "@/components/ui/badge"; -import { Building2, Archive } from "lucide-react"; +import { Building2, Archive, Trash2 } from "lucide-react"; +import { useState } from "react"; export async function loader({ request }: { request: Request }) { await requireAdmin(request); @@ -68,6 +69,37 @@ function MandantenTabelle({ title: string; archived?: boolean; }) { + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async (companyId: string, companyName: string) => { + if (deleteConfirm !== companyId) { + setDeleteConfirm(companyId); + return; + } + + setIsDeleting(true); + try { + const response = await fetch(`/api/admin/companies/${companyId}/delete`, { + method: "DELETE", + }); + + if (response.ok) { + // Reload the page to refresh the list + window.location.reload(); + } else { + const error = await response.json(); + alert(`Fehler beim Löschen: ${error.error || response.statusText}`); + setDeleteConfirm(null); + } + } catch (error) { + alert(`Fehler beim Löschen: ${error}`); + setDeleteConfirm(null); + } finally { + setIsDeleting(false); + } + }; + if (companies.length === 0) return null; return ( @@ -113,13 +145,25 @@ function MandantenTabelle({ {company._count.invoices} {company._count.customers} - + Öffnen → + ))} diff --git a/app/routes/api.admin.companies.$id.delete.ts b/app/routes/api.admin.companies.$id.delete.ts new file mode 100644 index 0000000..5cf3dd9 --- /dev/null +++ b/app/routes/api.admin.companies.$id.delete.ts @@ -0,0 +1,34 @@ +import { requireAdmin } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { log } from "@/lib/logger.server"; + +export async function action({ request, params }: { request: Request; params: { id: string } }) { + const user = await requireAdmin(request); + + if (request.method !== "DELETE") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + const company = await prisma.company.findUnique({ + where: { id: params.id }, + }); + + if (!company) { + return Response.json({ error: "Company not found" }, { status: 404 }); + } + + await prisma.company.delete({ + where: { id: params.id }, + }); + + await log({ + userId: user.id, + action: "DELETE_COMPANY", + entity: "Company", + entityId: params.id, + metadata: { companyName: company.name }, + request, + }); + + return Response.json({ ok: true }); +} diff --git a/app/routes/api.companies.ts b/app/routes/api.companies.ts index 3037b76..504dabf 100644 --- a/app/routes/api.companies.ts +++ b/app/routes/api.companies.ts @@ -1,36 +1,56 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { log } from "@/lib/logger.server"; +import { logApiError } from "@/lib/error-logger.server"; import { companySchema } from "@/lib/schemas"; export async function loader({ request }: { request: Request }) { - const user = await getApiUser(request); - if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + try { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); - const companies = await prisma.company.findMany({ - where: { userId: user.id }, - include: { _count: { select: { invoices: true, customers: true } } }, - orderBy: { name: "asc" }, - }); + const companies = await prisma.company.findMany({ + where: { userId: user.id }, + include: { _count: { select: { invoices: true, customers: true } } }, + orderBy: { name: "asc" }, + }); - return Response.json(companies); + return Response.json(companies); + } catch (error) { + logApiError(error, { + request, + endpoint: "/api/companies", + statusCode: 500, + }); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } } export async function action({ request }: { request: Request }) { - const user = await getApiUser(request); - if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + try { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); - const body = await request.json(); - const parsed = companySchema.safeParse(body); - if (!parsed.success) { - return Response.json({ error: parsed.error.issues }, { status: 400 }); + const body = await request.json(); + const parsed = companySchema.safeParse(body); + if (!parsed.success) { + console.warn("[CompanyAPI] Validation failed:", parsed.error.issues); + return Response.json({ error: parsed.error.issues }, { status: 400 }); + } + + const company = await prisma.company.create({ + data: { ...parsed.data, userId: user.id }, + }); + + await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request }); + + return Response.json(company, { status: 201 }); + } catch (error) { + logApiError(error, { + request, + endpoint: "/api/companies", + statusCode: 500, + }); + return Response.json({ error: "Internal server error" }, { status: 500 }); } - - const company = await prisma.company.create({ - data: { ...parsed.data, userId: user.id }, - }); - - await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request }); - - return Response.json(company, { status: 201 }); } diff --git a/app/routes/api.invoices.$id.ts b/app/routes/api.invoices.$id.ts index e119409..f9846b5 100644 --- a/app/routes/api.invoices.$id.ts +++ b/app/routes/api.invoices.$id.ts @@ -5,6 +5,8 @@ import { calcItemAmounts, calcInvoiceTotals, calcItemAmountsKleinunternehmer } f import { log } from "@/lib/logger.server"; import { InvoiceStatus } from "@prisma/client"; import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; async function getInvoice(id: string, userId: string) { return prisma.invoice.findFirst({ @@ -13,6 +15,39 @@ async function getInvoice(id: string, userId: string) { }); } +/** Storage root for documents */ +function storageRoot(): string { + return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents"); +} + +/** Generate and save invoice PDF as beleg (receipt) */ +async function generateAndSaveInvoicePDF(invoice: Awaited>, userId: string): Promise { + if (!invoice) return null; + + try { + const { renderToBuffer } = await import("@react-pdf/renderer"); + const React = (await import("react")).default; + const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any; + const buffer = await renderToBuffer(element); + + // Save to storage + const safeName = `${invoice.id}-${Date.now()}.pdf`; + const userDir = join(storageRoot(), userId); + await mkdir(userDir, { recursive: true }); + await writeFile(join(userDir, safeName), Buffer.from(buffer)); + + // Return as "beleg:{userId}/{storedName}|{originalName}" + const originalName = `rechnung-${invoice.number ?? invoice.id}.pdf`; + return `beleg:${userId}/${safeName}|${originalName}`; + } catch { + console.error("Failed to generate invoice PDF"); + return null; + } +} + 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 }); @@ -148,6 +183,16 @@ export async function action({ request, params }: { request: Request; params: { // Handle Buchung sync: Create when PAID, delete when unpaying if (newStatus === "PAID" && oldStatus !== "PAID") { + // Generate and save invoice PDF as beleg + const belegUrl = await generateAndSaveInvoicePDF(invoice, user.id); + + // Calculate weighted average tax rate (kann mehrere Items mit unterschiedlichen Steuersätzen geben) + let averageTaxRate = 0; + if (invoice.taxTotal > 0 && invoice.netTotal > 0) { + // steuersatz = (taxTotal / netTotal) * 100 + averageTaxRate = Math.round((invoice.taxTotal / invoice.netTotal) * 100); + } + // Create a Buchung for the invoice payment const buchung = await prisma.buchung.create({ data: { @@ -159,6 +204,8 @@ export async function action({ request, params }: { request: Request; params: { description: `Rechnung ${invoice.number}`, kategorie: "Rechnungseinnahme", isBusinessRecord: true, + steuersatz: invoice.kleinunternehmer ? 0 : averageTaxRate, // 0 for Kleinunternehmer + belegUrl: belegUrl, // Attach the generated invoice PDF }, }); @@ -179,7 +226,7 @@ export async function action({ request, params }: { request: Request; params: { action: "UPDATE_INVOICE_STATUS", entity: "Invoice", entityId: params.id, - metadata: { oldStatus, newStatus, buchungId: buchung.id }, + metadata: { oldStatus, newStatus, buchungId: buchung.id, belegUrl, steuersatz: averageTaxRate }, request, }); diff --git a/app/routes/companies.new.tsx b/app/routes/companies.new.tsx index e679ea7..2792640 100644 --- a/app/routes/companies.new.tsx +++ b/app/routes/companies.new.tsx @@ -1,3 +1,4 @@ + import { Link, useNavigate } from "react-router"; export const handle = { diff --git a/app/routes/companies.tsx b/app/routes/companies.tsx index 69d1c47..fb7ce04 100644 --- a/app/routes/companies.tsx +++ b/app/routes/companies.tsx @@ -230,6 +230,12 @@ export default function CompaniesPage() { {/* Aktionen */}
+