feat: add client-side validation utilities and debugging tools
Build and Push Docker Image / build (push) Successful in 1m23s

- 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.
This commit is contained in:
hwinkel
2026-05-03 08:46:58 +02:00
parent c3e7a97c8a
commit b22e5baa5c
26 changed files with 1573 additions and 134 deletions
+3
View File
@@ -50,3 +50,6 @@ next-env.d.ts
# Uploaded Belege (persistent volume in production) # Uploaded Belege (persistent volume in production)
data/documents/ data/documents/
.vscode/
.react-router
@@ -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
@@ -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]
+95
View File
@@ -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
*/
+222 -21
View File
@@ -4,6 +4,8 @@ import { z } from "zod";
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 { debugLog, handleApiError } from "@/lib/client-validation";
import { useState, useEffect } from "react";
const schema = z.object({ const schema = z.object({
name: z.string().min(1, "Name ist erforderlich"), name: z.string().min(1, "Name ist erforderlich"),
@@ -32,37 +34,193 @@ interface CompanyFormProps {
submitLabel?: string; 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 ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>{label}</Label> <Label className="flex items-center gap-1">
{label}
{required && (
<span className="inline-flex items-center group relative">
<span className="text-red-500 font-bold text-lg leading-none">*</span>
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-red-600 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
Erforderliches Feld
</div>
</span>
)}
</Label>
<div className="relative">
<div className={required && !error ? "relative" : ""}>
{children} {children}
{error && <p className="text-xs text-red-600">{error}</p>} </div>
{/* Show error tooltip ONLY when there's an error */}
{error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700 flex gap-2">
<svg className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<div>
<p className="font-medium">{error}</p>
{tooltip && <p className="text-red-600 mt-0.5">{tooltip}</p>}
</div>
</div>
)}
</div>
</div> </div>
); );
} }
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) { export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({ const [apiError, setApiError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const { register, handleSubmit, formState: { errors, isSubmitting, isValid }, watch, trigger } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
mode: "onBlur", // Validate when user leaves field
defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues }, 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 ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Required fields info banner */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm font-medium text-blue-900"> Erforderliche Felder</p>
<p className="text-sm text-blue-800 mt-1">Folgende Felder sind erforderlich: <strong>Firmenname, Adresse, Postleitzahl, Ort</strong></p>
</div>
{/* Validation error banner */}
{validationError && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm font-medium text-yellow-900"> Eingabefehler</p>
<p className="text-sm text-yellow-800 mt-1">{validationError}</p>
</div>
)}
{/* API error banner */}
{apiError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-800"> Fehler</p>
<p className="text-sm text-red-700 mt-1">{apiError}</p>
</div>
)}
<div> <div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Firmenname *" error={errors.name?.message}> <Field
label="Firmenname"
required={true}
error={errors.name?.message}
tooltip="Name des Unternehmens oder der Geschäftseinheit"
>
<Input {...register("name")} placeholder="Muster GmbH" /> <Input {...register("name")} placeholder="Muster GmbH" />
</Field> </Field>
<Field label="Rechtsform" error={errors.legalForm?.message}> <Field
label="Rechtsform"
error={errors.legalForm?.message}
tooltip="z.B. GmbH, AG, UG, Einzelunternehmen"
>
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." /> <Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
</Field> </Field>
<Field label="Steuernummer" error={errors.taxId?.message}> <Field
label="Steuernummer"
error={errors.taxId?.message}
tooltip="10-stellige deutsche Steuernummer (z.B. 123/456/78901)"
>
<Input {...register("taxId")} placeholder="123/456/78901" /> <Input {...register("taxId")} placeholder="123/456/78901" />
</Field> </Field>
<Field label="USt-IdNr." error={errors.vatId?.message}> <Field
label="USt-IdNr."
error={errors.vatId?.message}
tooltip="Umsatzsteuer-Identifikationsnummer (z.B. DE123456789)"
>
<Input {...register("vatId")} placeholder="DE123456789" /> <Input {...register("vatId")} placeholder="DE123456789" />
</Field> </Field>
</div> </div>
@@ -72,14 +230,29 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
<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="md:col-span-2"> <div className="md:col-span-2">
<Field label="Straße & Hausnummer *" error={errors.address?.message}> <Field
label="Straße & Hausnummer"
required={true}
error={errors.address?.message}
tooltip="Vollständige Adresse mit Straße und Hausnummer"
>
<Input {...register("address")} placeholder="Musterstraße 1" /> <Input {...register("address")} placeholder="Musterstraße 1" />
</Field> </Field>
</div> </div>
<Field label="PLZ *" error={errors.zip?.message}> <Field
label="PLZ"
required={true}
error={errors.zip?.message}
tooltip="Deutsche Postleitzahl (5-stellig)"
>
<Input {...register("zip")} placeholder="10115" /> <Input {...register("zip")} placeholder="10115" />
</Field> </Field>
<Field label="Ort *" error={errors.city?.message}> <Field
label="Ort"
required={true}
error={errors.city?.message}
tooltip="Stadt oder Gemeinde"
>
<Input {...register("city")} placeholder="Berlin" /> <Input {...register("city")} placeholder="Berlin" />
</Field> </Field>
</div> </div>
@@ -88,13 +261,25 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<div> <div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="E-Mail" error={errors.email?.message}> <Field
label="E-Mail"
error={errors.email?.message}
tooltip="Kontakt-E-Mail-Adresse (optional)"
>
<Input {...register("email")} type="email" placeholder="info@firma.de" /> <Input {...register("email")} type="email" placeholder="info@firma.de" />
</Field> </Field>
<Field label="Telefon" error={errors.phone?.message}> <Field
label="Telefon"
error={errors.phone?.message}
tooltip="Telefonnummer mit Landesvorwahl (optional)"
>
<Input {...register("phone")} placeholder="+49 30 12345678" /> <Input {...register("phone")} placeholder="+49 30 12345678" />
</Field> </Field>
<Field label="Website" error={errors.website?.message}> <Field
label="Website"
error={errors.website?.message}
tooltip="URL der Unternehmenswebseite (optional)"
>
<Input {...register("website")} placeholder="https://firma.de" /> <Input {...register("website")} placeholder="https://firma.de" />
</Field> </Field>
</div> </div>
@@ -104,14 +289,26 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
<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="md:col-span-2"> <div className="md:col-span-2">
<Field label="IBAN" error={errors.bankIban?.message}> <Field
label="IBAN"
error={errors.bankIban?.message}
tooltip="Internationale Kontonummer (z.B. DE89 3704 0044...) (optional)"
>
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" /> <Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
</Field> </Field>
</div> </div>
<Field label="BIC" error={errors.bankBic?.message}> <Field
label="BIC"
error={errors.bankBic?.message}
tooltip="Bank Identifier Code (z.B. COBADEFFXXX) (optional)"
>
<Input {...register("bankBic")} placeholder="COBADEFFXXX" /> <Input {...register("bankBic")} placeholder="COBADEFFXXX" />
</Field> </Field>
<Field label="Kreditinstitut" error={errors.bankName?.message}> <Field
label="Kreditinstitut"
error={errors.bankName?.message}
tooltip="Name der Bank (optional)"
>
<Input {...register("bankName")} placeholder="Commerzbank" /> <Input {...register("bankName")} placeholder="Commerzbank" />
</Field> </Field>
</div> </div>
@@ -120,7 +317,11 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<div> <div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}> <Field
label="Rechnungsnummern-Präfix"
error={errors.invoicePrefix?.message}
tooltip="Präfix für Rechnungsnummern (z.B. RE oder INV)"
>
<Input {...register("invoicePrefix")} placeholder="RE" /> <Input {...register("invoicePrefix")} placeholder="RE" />
</Field> </Field>
</div> </div>
@@ -139,8 +340,8 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
</div> </div>
</div> </div>
<div className="flex justify-end pt-2"> <div className="flex justify-end gap-3 pt-2">
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting || !isValid}>
{isSubmitting ? "Speichern..." : submitLabel} {isSubmitting ? "Speichern..." : submitLabel}
</Button> </Button>
</div> </div>
+116
View File
@@ -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 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full font-bold text-white text-lg transition-all ${
debugEnabled
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 hover:bg-gray-500"
}`}
title={debugEnabled ? "Debug Mode: ON" : "Debug Mode: OFF"}
>
🐛
</button>
{/* Debug panel */}
{isOpen && (
<div className="fixed bottom-20 right-4 z-40 bg-gray-900 text-white rounded-lg shadow-2xl p-4 w-80 max-h-96 overflow-auto">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg">Debug Mode</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white"
>
</button>
</div>
<div className="border-t border-gray-700 pt-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={debugEnabled}
onChange={toggleDebug}
className="w-4 h-4"
/>
<span className="text-sm">
{debugEnabled ? "✅ Enabled" : "⭕ Disabled"}
</span>
</label>
</div>
<div className="bg-gray-800 p-3 rounded text-xs space-y-1">
<p className="text-gray-400">Browser Console Commands:</p>
<code className="block text-blue-400">
setDebugMode(true)
</code>
<code className="block text-blue-400">
localStorage.setItem('DEBUG_MODE', 'true')
</code>
<code className="block text-green-400">
isDebugMode()
</code>
</div>
<div className="bg-gray-800 p-3 rounded text-xs">
<p className="text-gray-400 mb-1">Current State:</p>
<p className="text-yellow-300">
DEBUG: <strong>{debugEnabled ? "ON" : "OFF"}</strong>
</p>
<p className="text-yellow-300">
ENV: <strong>{process.env.NODE_ENV}</strong>
</p>
</div>
<button
onClick={() => {
if (debugEnabled) {
console.log("🔍 Debug Info:");
console.log({
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
});
} else {
alert("Enable Debug Mode first!");
}
}}
className="w-full bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded text-sm font-medium"
>
Log Debug Info
</button>
</div>
</div>
)}
</>
);
}
+28
View File
@@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+18 -1
View File
@@ -1,4 +1,6 @@
import { startCleanupScheduler } from "./lib/cleanup.server"; 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 { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router"; import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node"; import { createReadableStreamFromReadable } from "@react-router/node";
@@ -8,6 +10,12 @@ import { renderToPipeableStream } from "react-dom/server";
startCleanupScheduler(); startCleanupScheduler();
// Initialize database: run migrations on startup
initializeDatabase().catch((error) => {
logStartupError(error);
process.exit(1);
});
const ABORT_DELAY = 5_000; const ABORT_DELAY = 5_000;
export default function handleRequest( export default function handleRequest(
@@ -38,11 +46,20 @@ export default function handleRequest(
pipe(body); pipe(body);
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
logError("SHELL_ERROR", error, {
request,
route: new URL(request.url).pathname,
});
reject(error); reject(error);
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500;
if (shellRendered) console.error(error); if (shellRendered) {
logError("RENDER_ERROR", error, {
request,
route: new URL(request.url).pathname,
});
}
}, },
} }
); );
+136
View File
@@ -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"
}
================================================================================
*/
+244
View File
@@ -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);
}
+73
View File
@@ -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<void> {
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<void> {
// 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",
};
}
}
+227
View File
@@ -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<string, unknown>;
}
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<string, unknown>;
};
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<string, unknown>;
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<ErrorContext, "metadata">
): 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");
}
+13 -1
View File
@@ -47,9 +47,21 @@ export async function log({
: undefined; : undefined;
try { 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({ await prisma.auditLog.create({
data: { data: {
userId: userId ?? null, userId: validatedUserId ?? null,
action, action,
entity: entity ?? null, entity: entity ?? null,
entityId: entityId ?? null, entityId: entityId ?? null,
+24 -10
View File
@@ -35,7 +35,7 @@ export const taxRateSchema = z
export const ibanSchema = z export const ibanSchema = z
.string() .string()
.refine( .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" "Ungültige IBAN"
); );
@@ -44,14 +44,20 @@ export const ibanSchema = z
*/ */
export const taxIdSchema = z export const taxIdSchema = z
.string() .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 * VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
*/ */
export const vatIdSchema = z export const vatIdSchema = z
.string() .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 ===== // ===== Invoice Schemas =====
@@ -131,8 +137,8 @@ export const companySchema = z.object({
.string() .string()
.max(100, "Rechtsform darf maximal 100 Zeichen sein") .max(100, "Rechtsform darf maximal 100 Zeichen sein")
.optional(), .optional(),
taxId: taxIdSchema.optional(), taxId: taxIdSchema.nullable(),
vatId: vatIdSchema.optional(), vatId: vatIdSchema.nullable(),
address: z address: z
.string() .string()
.min(1, "Adresse erforderlich") .min(1, "Adresse erforderlich")
@@ -162,14 +168,22 @@ export const companySchema = z.object({
.optional(), .optional(),
website: z website: z
.string() .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") .max(255, "Website darf maximal 255 Zeichen sein")
.optional(), .optional()
bankIban: ibanSchema.optional(), .or(z.literal("")),
bankIban: ibanSchema.nullable(),
bankBic: z bankBic: z
.string() .string()
.regex(/^[A-Z0-9]{8,11}$/, "Ungültiger BIC") .refine(
.optional(), (val) => val === "" || /^[A-Z0-9]{8,11}$/.test(val),
"Ungültiger BIC"
)
.optional()
.or(z.literal("")),
bankName: z bankName: z
.string() .string()
.max(255, "Bankname darf maximal 255 Zeichen sein") .max(255, "Bankname darf maximal 255 Zeichen sein")
+51 -6
View File
@@ -1,21 +1,61 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
import "./app.css"; import "./app.css";
import { DebugPanel } from "./components/debug-panel";
export function ErrorBoundary() { export function ErrorBoundary() {
const error = useRouteError(); 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.status} ${error.statusText}`
: error instanceof Error : error instanceof Error
? error.message ? error.message
: String(error); : String(error);
const stack = error instanceof Error ? error.stack : undefined; 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 ( return (
<html lang="de"> <html lang="de">
<head><meta charSet="utf-8" /><Meta /><Links /></head> <head>
<meta charSet="utf-8" />
<Meta />
<Links />
</head>
<body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}> <body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>Fehler</h1> <h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>{message}</pre> {status} Fehler
{import.meta.env.DEV && stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>} </h1>
<p style={{ marginBottom: "1rem", fontSize: "0.9rem" }}>
{statusText}
</p>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>
{message}
</pre>
{import.meta.env.DEV && stack && (
<details style={{ marginTop: "1rem" }}>
<summary style={{ cursor: "pointer", fontWeight: 600, color: "#64748b" }}>
Stack Trace (Dev Only)
</summary>
<pre style={{ marginTop: "0.5rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap", background: "#f1f5f9", padding: "1rem", borderRadius: "0.5rem" }}>
{stack}
</pre>
</details>
)}
<Scripts /> <Scripts />
</body> </body>
</html> </html>
@@ -45,5 +85,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
return <Outlet />; return (
<>
<Outlet />
<DebugPanel />
</>
);
} }
+1
View File
@@ -45,6 +45,7 @@ export default [
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"), route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"), route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"), route("api/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", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.ts"), route("api/customers/:id", "routes/api.customers.$id.ts"),
route("api/services", "routes/api.services.ts"), route("api/services", "routes/api.services.ts"),
+46 -2
View File
@@ -2,7 +2,8 @@ import { Link, useLoaderData } from "react-router";
import { requireAdmin } from "@/session.server"; import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { Badge } from "@/components/ui/badge"; 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 }) { export async function loader({ request }: { request: Request }) {
await requireAdmin(request); await requireAdmin(request);
@@ -68,6 +69,37 @@ function MandantenTabelle({
title: string; title: string;
archived?: boolean; archived?: boolean;
}) { }) {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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; if (companies.length === 0) return null;
return ( return (
@@ -113,13 +145,25 @@ function MandantenTabelle({
</td> </td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td> <td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td> <td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right space-x-2 flex justify-end">
<Link <Link
to={`/companies/${company.id}`} to={`/companies/${company.id}`}
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs" className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
> >
Öffnen Öffnen
</Link> </Link>
<button
onClick={() => handleDelete(company.id, company.name)}
disabled={isDeleting}
className={`text-xs font-medium flex items-center gap-1 px-2 py-1 rounded transition-colors ${
deleteConfirm === company.id
? "bg-red-100 text-red-700 hover:bg-red-200"
: "text-slate-500 hover:text-red-600"
} ${isDeleting ? "opacity-50 cursor-not-allowed" : ""}`}
>
<Trash2 className="w-3.5 h-3.5" />
{deleteConfirm === company.id ? "Bestätigen?" : "Löschen"}
</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -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 });
}
+20
View File
@@ -1,9 +1,11 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { log } from "@/lib/logger.server"; import { log } from "@/lib/logger.server";
import { logApiError } from "@/lib/error-logger.server";
import { companySchema } from "@/lib/schemas"; import { companySchema } from "@/lib/schemas";
export async function loader({ request }: { request: Request }) { export async function loader({ request }: { request: Request }) {
try {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
@@ -14,15 +16,25 @@ export async function loader({ request }: { request: Request }) {
}); });
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 }) { export async function action({ request }: { request: Request }) {
try {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json(); const body = await request.json();
const parsed = companySchema.safeParse(body); const parsed = companySchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
return Response.json({ error: parsed.error.issues }, { status: 400 }); return Response.json({ error: parsed.error.issues }, { status: 400 });
} }
@@ -33,4 +45,12 @@ export async function action({ request }: { request: Request }) {
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request }); await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
return Response.json(company, { status: 201 }); 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 });
}
} }
+48 -1
View File
@@ -5,6 +5,8 @@ import { calcItemAmounts, calcInvoiceTotals, calcItemAmountsKleinunternehmer } f
import { log } from "@/lib/logger.server"; import { log } from "@/lib/logger.server";
import { InvoiceStatus } from "@prisma/client"; import { InvoiceStatus } from "@prisma/client";
import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas"; 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) { async function getInvoice(id: string, userId: string) {
return prisma.invoice.findFirst({ 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<ReturnType<typeof getInvoice>>, userId: string): Promise<string | null> {
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 } }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); 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 // Handle Buchung sync: Create when PAID, delete when unpaying
if (newStatus === "PAID" && oldStatus !== "PAID") { 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 // Create a Buchung for the invoice payment
const buchung = await prisma.buchung.create({ const buchung = await prisma.buchung.create({
data: { data: {
@@ -159,6 +204,8 @@ export async function action({ request, params }: { request: Request; params: {
description: `Rechnung ${invoice.number}`, description: `Rechnung ${invoice.number}`,
kategorie: "Rechnungseinnahme", kategorie: "Rechnungseinnahme",
isBusinessRecord: true, 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", action: "UPDATE_INVOICE_STATUS",
entity: "Invoice", entity: "Invoice",
entityId: params.id, entityId: params.id,
metadata: { oldStatus, newStatus, buchungId: buchung.id }, metadata: { oldStatus, newStatus, buchungId: buchung.id, belegUrl, steuersatz: averageTaxRate },
request, request,
}); });
+1
View File
@@ -1,3 +1,4 @@
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
export const handle = { export const handle = {
+6
View File
@@ -230,6 +230,12 @@ export default function CompaniesPage() {
{/* Aktionen */} {/* Aktionen */}
<div className="flex gap-2 p-4 border-b border-slate-100"> <div className="flex gap-2 p-4 border-b border-slate-100">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link to={`/companies/${selected.id}`}>
<ChevronRight className="h-3.5 w-3.5" />
Dashboard
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="flex-1"> <Button variant="outline" size="sm" asChild className="flex-1">
<Link to={`/companies/${selected.id}/edit`}> <Link to={`/companies/${selected.id}/edit`}>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
+1 -1
View File
@@ -21,7 +21,7 @@ services:
app: app:
# registry.henryathome.home64.de/henry/annasrechnungsmanager:latest # registry.henryathome.home64.de/henry/annasrechnungsmanager:latest
image: annasrechnungsmanager:latest image: git.henryathome.home64.de/henry/annasrechnungsmanager:latest
container_name: annas_app container_name: annas_app
restart: unless-stopped restart: unless-stopped
ports: ports:
+53
View File
@@ -17,6 +17,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@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",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@react-router/node": "^7.13.1", "@react-router/node": "^7.13.1",
"@react-router/serve": "^7", "@react-router/serve": "^7",
@@ -2075,6 +2076,58 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@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",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@react-router/node": "^7.13.1", "@react-router/node": "^7.13.1",
"@react-router/serve": "^7", "@react-router/serve": "^7",
@@ -0,0 +1 @@
ALTER TABLE `buchungen` ADD COLUMN `belegUrl` LONGTEXT;