feat: add client-side validation utilities and debugging tools
Build and Push Docker Image / build (push) Successful in 1m23s
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:
@@ -50,3 +50,6 @@ next-env.d.ts
|
||||
|
||||
# Uploaded Belege (persistent volume in production)
|
||||
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]
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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,37 +34,193 @@ 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 (
|
||||
<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}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
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 (
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
</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..." />
|
||||
</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" />
|
||||
</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" />
|
||||
</Field>
|
||||
</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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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" />
|
||||
</Field>
|
||||
</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" />
|
||||
</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" />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -88,13 +261,25 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
||||
<div>
|
||||
<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">
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</Field>
|
||||
</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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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" />
|
||||
</Field>
|
||||
</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" />
|
||||
</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" />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -120,7 +317,11 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
||||
<div>
|
||||
<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">
|
||||
<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" />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -139,8 +340,8 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="submit" disabled={isSubmitting || !isValid}>
|
||||
{isSubmitting ? "Speichern..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
================================================================================
|
||||
*/
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+24
-10
@@ -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")
|
||||
|
||||
+51
-6
@@ -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 (
|
||||
<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" }}>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>Fehler</h1>
|
||||
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>{message}</pre>
|
||||
{import.meta.env.DEV && stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>}
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>
|
||||
{status} Fehler
|
||||
</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 />
|
||||
</body>
|
||||
</html>
|
||||
@@ -45,5 +85,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<DebugPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
return (
|
||||
@@ -113,13 +145,25 @@ function MandantenTabelle({
|
||||
</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">
|
||||
<td className="px-4 py-3 text-right space-x-2 flex justify-end">
|
||||
<Link
|
||||
to={`/companies/${company.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
|
||||
>
|
||||
Öffnen →
|
||||
</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>
|
||||
</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 });
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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 }) {
|
||||
try {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -14,15 +16,25 @@ export async function loader({ request }: { request: Request }) {
|
||||
});
|
||||
|
||||
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 }) {
|
||||
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) {
|
||||
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
|
||||
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 });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 } }) {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
|
||||
@@ -230,6 +230,12 @@ export default function CompaniesPage() {
|
||||
|
||||
{/* Aktionen */}
|
||||
<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">
|
||||
<Link to={`/companies/${selected.id}/edit`}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
app:
|
||||
# registry.henryathome.home64.de/henry/annasrechnungsmanager:latest
|
||||
image: annasrechnungsmanager:latest
|
||||
image: git.henryathome.home64.de/henry/annasrechnungsmanager:latest
|
||||
container_name: annas_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
Generated
+53
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-router/node": "^7.13.1",
|
||||
"@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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-router/node": "^7.13.1",
|
||||
"@react-router/serve": "^7",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `buchungen` ADD COLUMN `belegUrl` LONGTEXT;
|
||||
Reference in New Issue
Block a user