ADD: added some quiality of life features

This commit is contained in:
hwinkel
2026-03-15 19:53:11 +01:00
parent f5b259cae2
commit 40a2764dd0
30 changed files with 1397 additions and 51 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ export async function loader({ request, params }: { request: Request; params: {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
"Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.pdf"`,
},
});
}
+68 -2
View File
@@ -1,5 +1,6 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
import { InvoiceStatus } from "@prisma/client";
import { z } from "zod";
@@ -22,6 +23,31 @@ export async function loader({ request, params }: { request: Request; params: {
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
const itemSchema = z.object({
position: z.number().int(),
description: z.string().min(1),
quantity: z.number().positive(),
unit: z.string().optional(),
unitPrice: z.number(),
taxRate: z.number(),
netAmount: z.number(),
taxAmount: z.number(),
grossAmount: z.number(),
});
const updateSchema = z.object({
customerId: z.string().min(1),
issueDate: z.string(),
deliveryDate: z.string().optional(),
dueDate: z.string(),
notes: z.string().optional(),
kleinunternehmer: z.boolean().optional().default(false),
items: z.array(itemSchema).min(1),
netTotal: z.number(),
taxTotal: z.number(),
grossTotal: z.number(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
@@ -29,9 +55,36 @@ export async function action({ request, params }: { request: Request; params: {
const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "PUT") {
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const { items, ...invoiceData } = parsed.data;
const updated = await prisma.$transaction(async (tx) => {
await tx.invoiceItem.deleteMany({ where: { invoiceId: params.id } });
return tx.invoice.update({
where: { id: params.id },
data: {
customerId: invoiceData.customerId,
issueDate: new Date(invoiceData.issueDate),
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
dueDate: new Date(invoiceData.dueDate),
notes: invoiceData.notes ?? null,
kleinunternehmer: invoiceData.kleinunternehmer,
netTotal: invoiceData.netTotal,
taxTotal: invoiceData.taxTotal,
grossTotal: invoiceData.grossTotal,
items: { create: items },
},
});
});
return Response.json(updated);
}
if (request.method === "DELETE") {
const isAdmin = user.role === "ADMIN";
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED];
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED, InvoiceStatus.DELETED];
if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
return Response.json(
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." },
@@ -47,9 +100,22 @@ export async function action({ request, params }: { request: Request; params: {
const parsed = statusSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const newStatus = parsed.data.status;
let numberUpdate: string | null | undefined = undefined;
if (newStatus === "DELETED") {
numberUpdate = null;
} else if (invoice.status === "DELETED") {
numberUpdate = await generateInvoiceNumber(invoice.companyId);
}
const updated = await prisma.invoice.update({
where: { id: params.id },
data: { status: parsed.data.status },
data: {
status: newStatus,
deletedAt: newStatus === "DELETED" ? new Date() : null,
...(numberUpdate !== undefined && { number: numberUpdate }),
},
});
return Response.json(updated);
}
+33
View File
@@ -29,6 +29,39 @@ const invoiceSchema = z.object({
grossTotal: z.number(),
});
/**
* Creates a new invoice for a given company.
*
* Requires a JSON object in the request body with the following shape:
* {
* companyId: string,
* customerId: string,
* issueDate: string,
* deliveryDate?: string,
* dueDate: string,
* notes?: string,
* kleinunternehmer?: boolean,
* items: [
* {
* position: number,
* description: string,
* quantity: number,
* unit?: string,
* unitPrice: number,
* taxRate: number,
* netAmount: number,
* taxAmount: number,
* grossAmount: number,
* },
* ],
* }
*
* Returns the created invoice as a JSON object.
*
* If the request is unauthorized, returns a 401 response with an error message.
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
* If the company is not found, returns a 404 response with an error message.
*/
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
+34
View File
@@ -0,0 +1,34 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const serviceSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
unit: z.string().optional(),
unitPrice: z.number(),
taxRate: z.number(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const service = await prisma.service.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!service) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.service.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = serviceSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.service.update({ where: { id: params.id }, data: parsed.data });
return Response.json(updated);
}
+29
View File
@@ -0,0 +1,29 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const serviceSchema = z.object({
companyId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
unit: z.string().optional(),
unitPrice: z.number(),
taxRate: z.number(),
});
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 = serviceSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const service = await prisma.service.create({ data: parsed.data });
return Response.json(service, { status: 201 });
}
@@ -0,0 +1,163 @@
import { Link, useLoaderData, useNavigate } from "react-router";
export const handle = {
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
{ label: data.invoice.number ?? "-", href: `/companies/${data.invoice.companyId}/invoices/${data.invoice.id}` },
{ label: "Bearbeiten" },
],
};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceForm } from "@/components/invoice/invoice-form";
import { ChevronLeft } from "lucide-react";
/**
* Loads an invoice by its ID.
*
* The response contains the invoice's details, including the issue date, delivery date, due date, items, customers, and services.
*
* If the invoice is not found, returns a 404 response with an error message.
* If the user is not authorized, returns a 401 response with an error message.
*
* @param {Request} request - The request object.
* @param {{ id: string; invoiceId: string }} params - The route parameters.
* @returns {Promise<Response>} - The response data.
*/
export async function loader({
request,
params,
}: {
request: Request;
params: { id: string; invoiceId: string };
}) {
const user = await requireUser(request);
const { id, invoiceId } = params;
const invoice = await prisma.invoice.findFirst({
where: { id: invoiceId, companyId: id, company: { userId: user.id } },
include: {
items: { orderBy: { position: "asc" } },
company: true,
},
});
if (!invoice) throw new Response("Not Found", { status: 404 });
const [customers, services] = await Promise.all([
prisma.customer.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
select: { id: true, name: true },
}),
prisma.service.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
}),
]);
return {
invoice: {
...invoice,
issueDate: invoice.issueDate.toISOString(),
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
dueDate: invoice.dueDate.toISOString(),
items: invoice.items.map((item) => ({
...item,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
taxRate: String(item.taxRate),
netAmount: Number(item.netAmount),
taxAmount: Number(item.taxAmount),
grossAmount: Number(item.grossAmount),
})),
},
customers,
services: services.map((s) => ({
...s,
unitPrice: Number(s.unitPrice),
taxRate: Number(s.taxRate),
})),
};
}
/**
* EditInvoicePage
*
* This page allows the user to edit an existing invoice.
* It will display the current data of the invoice and allow the user to update it.
* The page will automatically revalidate when the user updates the invoice.
*
* @returns {JSX.Element} The JSX element representing the EditInvoicePage.
*/
export default function EditInvoicePage() {
const { invoice, customers, services } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const defaultValues = {
customerId: invoice.customerId,
issueDate: invoice.issueDate.split("T")[0],
deliveryDate: invoice.deliveryDate?.split("T")[0] ?? "",
dueDate: invoice.dueDate.split("T")[0],
notes: invoice.notes ?? "",
items: invoice.items,
};
/**
* Submits the edited invoice to the API.
* If the request is successful, navigates the user to the invoice detail page.
* If the request fails, displays an error message.
*
* @param {Record<string, unknown>} data - The edited invoice data.
*/
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
navigate(`/companies/${invoice.companyId}/invoices/${invoice.id}`);
} else {
alert("Fehler beim Speichern der Rechnung.");
}
}
return (
<div>
<Link
to={`/companies/${invoice.companyId}/invoices/${invoice.id}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zur Rechnung
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Rechnung bearbeiten</h1>
<p className="text-gray-500 mt-1">Rechnungsnummer: {invoice.number ?? "-"}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent>
<InvoiceForm
customers={customers}
companyId={invoice.companyId}
defaultValues={defaultValues}
defaultKleinunternehmer={invoice.kleinunternehmer}
services={services}
submitLabel="Änderungen speichern"
onSubmit={handleSubmit}
/>
</CardContent>
</Card>
</div>
);
}
@@ -1,11 +1,11 @@
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
export const handle = {
breadcrumbs: (data: { invoice: { companyId: string; number: string; company: { name: string } } }) => [
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
{ label: data.invoice.number },
{ label: data.invoice.number ?? "-" },
],
};
import { requireUser } from "@/session.server";
@@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw } from "lucide-react";
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw, Pencil } from "lucide-react";
import type { InvoiceStatus } from "@prisma/client";
export async function loader({
@@ -63,6 +63,17 @@ export async function loader({
};
}
/**
* InvoiceDetailPage
*
* This page displays the details of a single invoice, including the customer, items, and totals.
* It also allows the user to download the invoice as a PDF and to update the status of the invoice.
*
* The page will only be accessible if the user is logged in and has the necessary permissions.
* The page will automatically revalidate when the user updates the status of the invoice.
*
* @returns {JSX.Element} The JSX element representing the InvoiceDetailPage.
*/
export default function InvoiceDetailPage() {
const { invoice, isAdmin } = useLoaderData<typeof loader>();
const navigate = useNavigate();
@@ -81,6 +92,15 @@ export default function InvoiceDetailPage() {
{} as Record<number, { net: number; tax: number }>
);
/**
* Updates the status of an invoice.
*
* This function sends a PATCH request to the API to update the status of the invoice.
* It sets the loading state to true before sending the request and to false after the request is finished.
* It also revalidates the page after the request is finished, so the user is shown the updated status.
*
* @param {InvoiceStatus} status The new status of the invoice.
*/
async function updateStatus(status: InvoiceStatus) {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
@@ -92,6 +112,13 @@ export default function InvoiceDetailPage() {
revalidate();
}
/**
* Soft deletes an invoice.
*
* This function shows a confirmation dialog to the user and if the user confirms, it sends a PATCH request to the API to update the status of the invoice to "DELETED".
* It sets the loading state to true before sending the request and to false after the request is finished.
* It also revalidates the page after the request is finished, so the user is shown the updated status.
*/
async function handleSoftDelete() {
if (!confirm("Rechnung in den Papierkorb verschieben?")) return;
setLoading(true);
@@ -104,23 +131,43 @@ export default function InvoiceDetailPage() {
revalidate();
}
/**
* Restores an invoice to the "DRAFT" status.
*
* This function sends a PATCH request to the API to update the status of the invoice to "DRAFT".
* It sets the loading state to true before sending the request and to false after the request is finished.
* It also revalidates the page after the request is finished, so the user is shown the updated status.
*/
async function handleRestore() {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "CANCELLED" }),
body: JSON.stringify({ status: "DRAFT" }),
});
setLoading(false);
revalidate();
}
/**
* Hard deletes an invoice.
*
* This function shows a confirmation dialog to the user and if the user confirms, it sends a DELETE request to the API to delete the invoice.
* It then navigates to the invoice list page.
*/
async function handleHardDelete() {
if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
navigate(`/companies/${id}/invoices`);
}
/**
* Downloads the invoice as a PDF file.
*
* This function sends a GET request to the API to get the PDF file.
* It then creates a blob URL from the response and creates a new anchor element with the blob URL and a download attribute with the filename.
* It then simulates a click event on the anchor element, so the user is prompted to download the PDF file.
*/
async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
if (!res.ok) return;
@@ -128,7 +175,7 @@ export default function InvoiceDetailPage() {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rechnung-${invoice.number}.pdf`;
a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
@@ -145,7 +192,7 @@ export default function InvoiceDetailPage() {
<div className="flex items-start justify-between mb-8">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900">{invoice.number}</h1>
<h1 className="text-2xl font-bold text-gray-900">{invoice.number ?? "-"}</h1>
<InvoiceStatusBadge status={invoice.status} />
</div>
<p className="text-gray-500">
@@ -160,6 +207,13 @@ export default function InvoiceDetailPage() {
<Download className="h-4 w-4" /> PDF
</Button>
)}
{invoice.status === "DRAFT" && (
<Button variant="outline" size="sm" asChild>
<Link to={`/companies/${id}/invoices/${invoice.id}/edit`}>
<Pencil className="h-4 w-4" /> Bearbeiten
</Link>
</Button>
)}
{invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus("SENT")} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren
+34 -3
View File
@@ -14,6 +14,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceForm } from "@/components/invoice/invoice-form";
import { ChevronLeft } from "lucide-react";
/**
* Loads the company, customers, and services for the given company ID.
*
* If the company is not found, returns a 404 response with an error message.
* If the user is not authorized, returns a 401 response with an error message.
* If there are no customers, redirects to the customers page.
*
* @param {Request} request - The request object.
* @param {{ id: string }} params - The route parameters.
* @returns {Promise<Response>} - The response data.
*/
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
@@ -33,11 +44,31 @@ export async function loader({ request, params }: { request: Request; params: {
throw redirect(`/companies/${id}/customers`);
}
return { company, customers };
const services = await prisma.service.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
});
return {
company,
customers,
services: services.map((s) => ({
...s,
unitPrice: Number(s.unitPrice),
taxRate: Number(s.taxRate),
})),
};
}
/**
* NewInvoicePage
*
* This page allows the user to create a new invoice for a given company.
* It will display the company's name and allow the user to select a customer and add items to the invoice.
* After submitting the form, the user will be redirected to the invoice detail page.
*/
export default function NewInvoicePage() {
const { company, customers } = useLoaderData<typeof loader>();
const { company, customers, services } = useLoaderData<typeof loader>();
const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) {
@@ -74,7 +105,7 @@ export default function NewInvoicePage() {
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent>
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} onSubmit={handleSubmit} />
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} services={services} onSubmit={handleSubmit} />
</CardContent>
</Card>
</div>
+46 -4
View File
@@ -1,4 +1,4 @@
import { Link, useLoaderData } from "react-router";
import { Link, useLoaderData, useRevalidator } from "react-router";
export const handle = {
breadcrumbs: (data: { company: { id: string; name: string } }) => [
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2, X } from "lucide-react";
import { useState } from "react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
@@ -38,6 +38,7 @@ export async function loader({ request, params }: { request: Request; params: {
grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.toISOString(),
deletedAt: inv.deletedAt?.toISOString() ?? null,
})),
};
}
@@ -65,7 +66,7 @@ function InvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: st
<FileText className="h-3.5 w-3.5 text-slate-400 group-hover:text-indigo-500 transition-colors" />
</div>
<div>
<p className="font-medium text-slate-800 text-sm">{invoice.number}</p>
<p className="font-medium text-slate-800 text-sm">{invoice.number ?? "-"}</p>
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
</div>
</div>
@@ -126,6 +127,47 @@ function YearPanel({
);
}
function DeletedInvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: string }) {
const revalidator = useRevalidator();
async function handleDelete(e: React.MouseEvent) {
e.preventDefault();
if (!window.confirm(`Rechnung ${invoice.number ?? "-"} endgültig löschen? Dies kann nicht rückgängig gemacht werden.`)) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
revalidator.revalidate();
}
return (
<div className="flex items-center justify-between px-5 py-3.5 hover:bg-red-50/30 transition-colors group">
<Link
to={`/companies/${companyId}/invoices/${invoice.id}`}
className="flex items-center gap-4 flex-1 min-w-0"
>
<div className="p-1.5 rounded-lg bg-red-50">
<FileText className="h-3.5 w-3.5 text-red-300" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-500 text-sm">{invoice.number ?? "-"}</p>
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
</div>
</Link>
<div className="flex items-center gap-4">
<p className="text-sm text-slate-400 hidden sm:block">{formatDate(invoice.issueDate)}</p>
<p className="font-medium text-slate-400 w-24 text-right text-sm">
{formatCurrency(invoice.grossTotal)}
</p>
<button
onClick={handleDelete}
className="p-1.5 rounded text-red-300 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Endgültig löschen"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
function DeletedPanel({
invoices,
companyId,
@@ -159,7 +201,7 @@ function DeletedPanel({
{open && (
<div className="divide-y divide-red-50 border-t border-red-100">
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
<DeletedInvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
))}
</div>
)}
+300
View File
@@ -0,0 +1,300 @@
import { useState } from "react";
import { Link, useLoaderData, useRevalidator } from "react-router";
export const handle = {
breadcrumbs: (data: { companyId: string; companyName: string }) => [
{ label: "Mandanten", href: "/companies" },
{ label: data.companyName, href: `/companies/${data.companyId}` },
{ label: "Leistungen" },
],
};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; // CardContent used for empty state
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Briefcase, Plus, Edit, Trash2, ChevronLeft, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { TAX_RATES, formatCurrency } from "@/lib/tax";
const schema = z.object({
name: z.string().min(1, "Pflichtfeld"),
description: z.string().optional(),
unit: z.string().optional(),
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
taxRate: z.coerce.number(),
});
type FormData = z.infer<typeof schema>;
interface Service {
id: string;
name: string;
description: string | null;
unit: string | null;
unitPrice: number;
taxRate: number;
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
const services = await prisma.service.findMany({
where: { companyId: params.id },
orderBy: { name: "asc" },
});
return {
services: services.map((s) => ({
...s,
unitPrice: Number(s.unitPrice),
taxRate: Number(s.taxRate),
})),
companyId: params.id,
companyName: company.name,
};
}
function ServiceForm({
defaultValues,
onSubmit,
submitLabel,
}: {
defaultValues?: Partial<FormData>;
onSubmit: (d: FormData) => Promise<void>;
submitLabel: string;
}) {
const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { taxRate: 19, ...defaultValues },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label>Bezeichnung *</Label>
<Input {...register("name")} placeholder="z.B. Beratung, Programmierung" />
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Beschreibung</Label>
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Einheit</Label>
<Input {...register("unit")} placeholder="Stunde, Stück, ..." />
</div>
<div className="space-y-1.5">
<Label>Einzelpreis () *</Label>
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label>Steuersatz</Label>
<Select
defaultValue={String(defaultValues?.taxRate ?? 19)}
onValueChange={(v) => setValue("taxRate", Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAX_RATES.map((r) => (
<SelectItem key={r.value} value={String(r.value)}>
{r.value}%
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Speichern..." : submitLabel}
</Button>
</div>
</form>
);
}
type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate";
type SortDir = "asc" | "desc";
export default function LeistungenPage() {
const { services, companyId } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [open, setOpen] = useState(false);
const [editService, setEditService] = useState<Service | null>(null);
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
}
const sorted = [...services].sort((a, b) => {
const av = a[sortKey] ?? "";
const bv = b[sortKey] ?? "";
const cmp = typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv), "de");
return sortDir === "asc" ? cmp : -cmp;
});
async function handleCreate(data: FormData) {
await fetch("/api/services", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, companyId }),
});
setOpen(false);
revalidate();
}
async function handleEdit(data: FormData) {
if (!editService) return;
await fetch(`/api/services/${editService.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setEditService(null);
revalidate();
}
async function handleDelete(serviceId: string) {
if (!confirm("Leistung wirklich löschen?")) return;
await fetch(`/api/services/${serviceId}`, { method: "DELETE" });
revalidate();
}
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Leistungen</h1>
<p className="text-gray-500 mt-1">{services.length} {services.length === 1 ? "Leistung" : "Leistungen"}</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="h-4 w-4" /> Leistung anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Leistung</DialogTitle>
</DialogHeader>
<ServiceForm onSubmit={handleCreate} submitLabel="Anlegen" />
</DialogContent>
</Dialog>
</div>
<Dialog open={!!editService} onOpenChange={(o) => !o && setEditService(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Leistung bearbeiten</DialogTitle>
</DialogHeader>
{editService && (
<ServiceForm
defaultValues={{
name: editService.name,
description: editService.description ?? undefined,
unit: editService.unit ?? undefined,
unitPrice: editService.unitPrice,
taxRate: editService.taxRate,
}}
onSubmit={handleEdit}
submitLabel="Speichern"
/>
)}
</DialogContent>
</Dialog>
{services.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Briefcase className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Noch keine Leistungen angelegt</p>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
{(["name", "description", "unit", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unit: "Einheit", unitPrice: "Preis", taxRate: "MwSt." };
const isNum = key === "unitPrice" || key === "taxRate";
const active = sortKey === key;
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
return (
<th key={key} className={`px-4 py-3 ${isNum ? "text-right" : "text-left"}`}>
<button
type="button"
onClick={() => toggleSort(key)}
className={`inline-flex items-center gap-1 hover:text-slate-800 transition-colors ${active ? "text-slate-800" : ""}`}
>
{labels[key]}
<Icon className="h-3 w-3" />
</button>
</th>
);
})}
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sorted.map((service) => (
<tr key={service.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-800">{service.name}</td>
<td className="px-4 py-3 text-slate-500 max-w-xs truncate">{service.description ?? "-"}</td>
<td className="px-4 py-3 text-slate-500">{service.unit ?? "-"}</td>
<td className="px-4 py-3 text-right text-slate-800">{formatCurrency(service.unitPrice)}</td>
<td className="px-4 py-3 text-right text-slate-500">{service.taxRate}%</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setEditService(service)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(service.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}
+41 -2
View File
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import {
FileText, Users, BarChart3, Plus, Edit, Building2,
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
import { useState } from "react";
@@ -35,6 +35,18 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
DELETED: "outline",
};
/**
* Loads a company by its ID.
*
* The response contains the company's name, archived at date, invoices, and revenue.
*
* The invoices are paginated to show the 5 most recent ones.
*
* The revenue is the sum of all paid invoices.
*
* If the company is not found, returns a 404 response with an error message.
* If the user is not authorized, returns a 401 response with an error message.
*/
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
@@ -43,6 +55,7 @@ export async function loader({ request, params }: { request: Request; params: {
where: { id, userId: user.id },
include: {
invoices: {
where: { status: { not: InvoiceStatus.DELETED } },
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
take: 5,
@@ -74,6 +87,22 @@ export async function loader({ request, params }: { request: Request; params: {
};
}
/**
* CompanyPage displays information about a company.
*
* The page displays the company's name, address, and legal form.
* It also displays the company's archived status, if applicable.
* If the user is an admin, the page displays buttons to toggle the company's archived status and to edit the company.
*
* The page also displays a list of the company's most recent invoices.
* The list shows the invoice number, customer name, issue date, and gross total.
* The user can click on an invoice to view its details.
*
* The page also displays the company's revenue, which is the sum of all paid invoices.
* If the company has a tax ID or VAT ID, the page displays it.
*
* Finally, the page displays contact information for the company, if applicable.
*/
export default function CompanyPage() {
const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
@@ -183,6 +212,16 @@ export default function CompanyPage() {
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/leistungen`} className="block">
<Card className="hover:border-orange-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-50">
<Briefcase className="h-4 w-4 text-orange-600" />
</div>
<span className="text-sm font-medium text-gray-700">Leistungen</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/reports`} className="block">
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
@@ -218,7 +257,7 @@ export default function CompanyPage() {
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div>
<p className="text-sm font-medium text-gray-900">{invoice.number}</p>
<p className="text-sm font-medium text-gray-900">{invoice.number ?? "-"}</p>
<p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
</div>
<div className="flex items-center gap-3">
+1 -1
View File
@@ -319,7 +319,7 @@ export default function CompaniesPage() {
className="flex items-center justify-between py-2.5 hover:bg-slate-50 -mx-1 px-1 rounded-lg transition-colors"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{invoice.number}</p>
<p className="text-sm font-medium text-slate-900">{invoice.number ?? "-"}</p>
<p className="text-xs text-slate-400 truncate">
{invoice.customer.name} · {formatDate(invoice.issueDate)}
</p>
+22 -8
View File
@@ -3,7 +3,8 @@ import { login, createUserSession, getUserSession } from "@/session.server";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Calculator, AlertCircle } from "lucide-react";
import { Calculator, AlertCircle, Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export async function loader({ request }: { request: Request }) {
const { userId } = await getUserSession(request);
@@ -26,6 +27,7 @@ export default function LoginPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
const [showPassword, setShowPassword] = useState(false);
return (
<div className="min-h-screen flex">
@@ -109,13 +111,25 @@ export default function LoginPage() {
<div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
tabIndex={-1}
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button type="submit" className="w-full h-10 mt-2" disabled={loading}>