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)
|
# 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]
|
||||||
@@ -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 { 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,118 +34,317 @@ 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">
|
||||||
{children}
|
{label}
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
{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}
|
||||||
|
</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">
|
||||||
<div>
|
{/* Required fields info banner */}
|
||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<p className="text-sm font-medium text-blue-900">ℹ️ Erforderliche Felder</p>
|
||||||
<Field label="Firmenname *" error={errors.name?.message}>
|
<p className="text-sm text-blue-800 mt-1">Folgende Felder sind erforderlich: <strong>Firmenname, Adresse, Postleitzahl, Ort</strong></p>
|
||||||
<Input {...register("name")} placeholder="Muster GmbH" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Rechtsform" error={errors.legalForm?.message}>
|
|
||||||
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
|
||||||
</Field>
|
|
||||||
<Field label="Steuernummer" error={errors.taxId?.message}>
|
|
||||||
<Input {...register("taxId")} placeholder="123/456/78901" />
|
|
||||||
</Field>
|
|
||||||
<Field label="USt-IdNr." error={errors.vatId?.message}>
|
|
||||||
<Input {...register("vatId")} placeholder="DE123456789" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Validation error banner */}
|
||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
|
{validationError && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
<div className="md:col-span-2">
|
<p className="text-sm font-medium text-yellow-900">⚠️ Eingabefehler</p>
|
||||||
<Field label="Straße & Hausnummer *" error={errors.address?.message}>
|
<p className="text-sm text-yellow-800 mt-1">{validationError}</p>
|
||||||
<Input {...register("address")} placeholder="Musterstraße 1" />
|
</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"
|
||||||
|
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}
|
||||||
|
tooltip="z.B. GmbH, AG, UG, Einzelunternehmen"
|
||||||
|
>
|
||||||
|
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
||||||
|
</Field>
|
||||||
|
<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}
|
||||||
|
tooltip="Umsatzsteuer-Identifikationsnummer (z.B. DE123456789)"
|
||||||
|
>
|
||||||
|
<Input {...register("vatId")} placeholder="DE123456789" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="PLZ *" error={errors.zip?.message}>
|
</div>
|
||||||
<Input {...register("zip")} placeholder="10115" />
|
|
||||||
</Field>
|
<div>
|
||||||
<Field label="Ort *" error={errors.city?.message}>
|
<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"
|
||||||
|
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"
|
||||||
|
required={true}
|
||||||
|
error={errors.zip?.message}
|
||||||
|
tooltip="Deutsche Postleitzahl (5-stellig)"
|
||||||
|
>
|
||||||
|
<Input {...register("zip")} placeholder="10115" />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Ort"
|
||||||
|
required={true}
|
||||||
|
error={errors.city?.message}
|
||||||
|
tooltip="Stadt oder Gemeinde"
|
||||||
|
>
|
||||||
<Input {...register("city")} placeholder="Berlin" />
|
<Input {...register("city")} placeholder="Berlin" />
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}>
|
|
||||||
<Input {...register("email")} type="email" placeholder="info@firma.de" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Telefon" error={errors.phone?.message}>
|
|
||||||
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Website" error={errors.website?.message}>
|
|
||||||
<Input {...register("website")} placeholder="https://firma.de" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<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}>
|
|
||||||
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="BIC" error={errors.bankBic?.message}>
|
|
||||||
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Kreditinstitut" error={errors.bankName?.message}>
|
|
||||||
<Input {...register("bankName")} placeholder="Commerzbank" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">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="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}>
|
<Field
|
||||||
<Input {...register("invoicePrefix")} placeholder="RE" />
|
label="E-Mail"
|
||||||
</Field>
|
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}
|
||||||
|
tooltip="Telefonnummer mit Landesvorwahl (optional)"
|
||||||
|
>
|
||||||
|
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Website"
|
||||||
|
error={errors.website?.message}
|
||||||
|
tooltip="URL der Unternehmenswebseite (optional)"
|
||||||
|
>
|
||||||
|
<Input {...register("website")} placeholder="https://firma.de" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
{...register("kleinunternehmer")}
|
|
||||||
className="h-4 w-4 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
Kleinunternehmer (§19 UStG) — keine Umsatzsteuer
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
|
||||||
{isSubmitting ? "Speichern..." : submitLabel}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
</Button>
|
<div className="md:col-span-2">
|
||||||
</div>
|
<Field
|
||||||
</form>
|
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}
|
||||||
|
tooltip="Bank Identifier Code (z.B. COBADEFFXXX) (optional)"
|
||||||
|
>
|
||||||
|
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Kreditinstitut"
|
||||||
|
error={errors.bankName?.message}
|
||||||
|
tooltip="Name der Bank (optional)"
|
||||||
|
>
|
||||||
|
<Input {...register("bankName")} placeholder="Commerzbank" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
tooltip="Präfix für Rechnungsnummern (z.B. RE oder INV)"
|
||||||
|
>
|
||||||
|
<Input {...register("invoicePrefix")} placeholder="RE" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register("kleinunternehmer")}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Kleinunternehmer (§19 UStG) — keine Umsatzsteuer
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isValid}>
|
||||||
|
{isSubmitting ? "Speichern..." : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
: 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
@@ -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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
+42
-22
@@ -1,36 +1,56 @@
|
|||||||
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 }) {
|
||||||
const user = await getApiUser(request);
|
try {
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const companies = await prisma.company.findMany({
|
const companies = await prisma.company.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
include: { _count: { select: { invoices: true, customers: true } } },
|
include: { _count: { select: { invoices: true, customers: true } } },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return 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 }) {
|
||||||
const user = await getApiUser(request);
|
try {
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
const user = await getApiUser(request);
|
||||||
|
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) {
|
||||||
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
|
||||||
|
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.create({
|
||||||
|
data: { ...parsed.data, userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
||||||
|
|
||||||
|
return Response.json(company, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logApiError(error, {
|
||||||
|
request,
|
||||||
|
endpoint: "/api/companies",
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const company = await prisma.company.create({
|
|
||||||
data: { ...parsed.data, userId: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
|
||||||
|
|
||||||
return Response.json(company, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Generated
+53
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user