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:
@@ -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 });
|
||||
}
|
||||
+42
-22
@@ -1,36 +1,56 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { log } from "@/lib/logger.server";
|
||||
import { logApiError } from "@/lib/error-logger.server";
|
||||
import { companySchema } from "@/lib/schemas";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
try {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { invoices: true, customers: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { invoices: true, customers: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return Response.json(companies);
|
||||
return Response.json(companies);
|
||||
} catch (error) {
|
||||
logApiError(error, {
|
||||
request,
|
||||
endpoint: "/api/companies",
|
||||
statusCode: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
try {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = companySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const parsed = companySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
|
||||
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: { ...parsed.data, userId: user.id },
|
||||
});
|
||||
|
||||
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
||||
|
||||
return Response.json(company, { status: 201 });
|
||||
} catch (error) {
|
||||
logApiError(error, {
|
||||
request,
|
||||
endpoint: "/api/companies",
|
||||
statusCode: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: { ...parsed.data, userId: user.id },
|
||||
});
|
||||
|
||||
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
||||
|
||||
return Response.json(company, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user