ADD: added e-rechnung
This commit is contained in:
@@ -140,6 +140,11 @@ type Pages = {
|
|||||||
"id": string;
|
"id": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/api/invoices/:id/xml": {
|
||||||
|
params: {
|
||||||
|
"id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"/api/reports": {
|
"/api/reports": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -148,7 +153,7 @@ type Pages = {
|
|||||||
type RouteFiles = {
|
type RouteFiles = {
|
||||||
"root.tsx": {
|
"root.tsx": {
|
||||||
id: "root";
|
id: "root";
|
||||||
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
|
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/invoices/:id/xml" | "/api/reports";
|
||||||
};
|
};
|
||||||
"routes/login.tsx": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
@@ -282,6 +287,10 @@ type RouteFiles = {
|
|||||||
id: "routes/api.invoices.$id.pdf";
|
id: "routes/api.invoices.$id.pdf";
|
||||||
page: "/api/invoices/:id/pdf";
|
page: "/api/invoices/:id/pdf";
|
||||||
};
|
};
|
||||||
|
"routes/api.invoices.$id.xml.ts": {
|
||||||
|
id: "routes/api.invoices.$id.xml";
|
||||||
|
page: "/api/invoices/:id/xml";
|
||||||
|
};
|
||||||
"routes/api.reports.ts": {
|
"routes/api.reports.ts": {
|
||||||
id: "routes/api.reports";
|
id: "routes/api.reports";
|
||||||
page: "/api/reports";
|
page: "/api/reports";
|
||||||
@@ -323,5 +332,6 @@ type RouteModules = {
|
|||||||
"routes/api.invoices": typeof import("./app/routes/api.invoices.ts");
|
"routes/api.invoices": typeof import("./app/routes/api.invoices.ts");
|
||||||
"routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts");
|
"routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts");
|
||||||
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
|
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
|
||||||
|
"routes/api.invoices.$id.xml": typeof import("./app/routes/api.invoices.$id.xml.ts");
|
||||||
"routes/api.reports": typeof import("./app/routes/api.reports.ts");
|
"routes/api.reports": typeof import("./app/routes/api.reports.ts");
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Generated by React Router
|
||||||
|
|
||||||
|
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||||
|
|
||||||
|
type Module = typeof import("../api.invoices.$id.xml.js")
|
||||||
|
|
||||||
|
type Info = GetInfo<{
|
||||||
|
file: "routes/api.invoices.$id.xml.ts",
|
||||||
|
module: Module
|
||||||
|
}>
|
||||||
|
|
||||||
|
type Matches = [{
|
||||||
|
id: "root";
|
||||||
|
module: typeof import("../../root.js");
|
||||||
|
}, {
|
||||||
|
id: "routes/api.invoices.$id.xml";
|
||||||
|
module: typeof import("../api.invoices.$id.xml.js");
|
||||||
|
}];
|
||||||
|
|
||||||
|
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||||
|
|
||||||
|
export namespace Route {
|
||||||
|
// links
|
||||||
|
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||||
|
export type LinksFunction = Annotations["LinksFunction"];
|
||||||
|
|
||||||
|
// meta
|
||||||
|
export type MetaArgs = Annotations["MetaArgs"];
|
||||||
|
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||||
|
export type MetaFunction = Annotations["MetaFunction"];
|
||||||
|
|
||||||
|
// headers
|
||||||
|
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||||
|
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||||
|
|
||||||
|
// middleware
|
||||||
|
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||||
|
|
||||||
|
// clientMiddleware
|
||||||
|
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||||
|
|
||||||
|
// loader
|
||||||
|
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||||
|
|
||||||
|
// clientLoader
|
||||||
|
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||||
|
|
||||||
|
// action
|
||||||
|
export type ActionArgs = Annotations["ActionArgs"];
|
||||||
|
|
||||||
|
// clientAction
|
||||||
|
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||||
|
|
||||||
|
// HydrateFallback
|
||||||
|
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||||
|
|
||||||
|
// Component
|
||||||
|
export type ComponentProps = Annotations["ComponentProps"];
|
||||||
|
|
||||||
|
// ErrorBoundary
|
||||||
|
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||||
|
}
|
||||||
@@ -276,10 +276,9 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-gray-200 rounded-xl">
|
<div className="border border-gray-200 rounded-xl">
|
||||||
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
|
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
|
||||||
<div className="col-span-4">Beschreibung</div>
|
<div className="col-span-4">Beschreibung</div>
|
||||||
<div className="col-span-1">Menge</div>
|
<div className="col-span-1">Menge</div>
|
||||||
<div className="col-span-1">Einh.</div>
|
|
||||||
<div className="col-span-2">{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}</div>
|
<div className="col-span-2">{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}</div>
|
||||||
{!kleinunternehmer && <div className="col-span-1">MwSt.</div>}
|
{!kleinunternehmer && <div className="col-span-1">MwSt.</div>}
|
||||||
<div className="col-span-2 text-right">Gesamt (brutto)</div>
|
<div className="col-span-2 text-right">Gesamt (brutto)</div>
|
||||||
@@ -287,7 +286,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
|
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
|
||||||
<div className="col-span-4 relative">
|
<div className="col-span-4 relative">
|
||||||
{(() => {
|
{(() => {
|
||||||
const descValue = watchedItems[index]?.description ?? "";
|
const descValue = watchedItems[index]?.description ?? "";
|
||||||
@@ -341,13 +340,6 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
|
|||||||
onBlur={() => recalcItem(index)}
|
onBlur={() => recalcItem(index)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1">
|
|
||||||
<Input
|
|
||||||
{...register(`items.${index}.unit`)}
|
|
||||||
placeholder="Stück"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Input
|
<Input
|
||||||
{...register(`items.${index}.unitPrice`)}
|
{...register(`items.${index}.unitPrice`)}
|
||||||
|
|||||||
@@ -112,8 +112,7 @@ const styles = StyleSheet.create({
|
|||||||
col_pos: { width: "5%" },
|
col_pos: { width: "5%" },
|
||||||
col_desc: { width: "40%" },
|
col_desc: { width: "40%" },
|
||||||
col_qty: { width: "10%", textAlign: "right" },
|
col_qty: { width: "10%", textAlign: "right" },
|
||||||
col_unit: { width: "8%", textAlign: "center" },
|
col_price: { width: "22%", textAlign: "right" },
|
||||||
col_price: { width: "14%", textAlign: "right" },
|
|
||||||
col_tax: { width: "8%", textAlign: "center" },
|
col_tax: { width: "8%", textAlign: "center" },
|
||||||
col_total: { width: "15%", textAlign: "right" },
|
col_total: { width: "15%", textAlign: "right" },
|
||||||
totalsSection: {
|
totalsSection: {
|
||||||
@@ -324,7 +323,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
|||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_unit }}>Einh.</Text>
|
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>
|
||||||
{invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
|
{invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -339,7 +337,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
|||||||
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
|
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
|
||||||
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
|
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
|
||||||
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
|
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
|
||||||
<Text style={{ ...styles.col_unit, fontSize: 9 }}>{item.unit ?? ""}</Text>
|
|
||||||
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
|
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
|
||||||
{!invoice.kleinunternehmer && (
|
{!invoice.kleinunternehmer && (
|
||||||
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
|
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
|
||||||
|
|||||||
@@ -41,5 +41,6 @@ export default [
|
|||||||
route("api/invoices", "routes/api.invoices.ts"),
|
route("api/invoices", "routes/api.invoices.ts"),
|
||||||
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
|
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
|
||||||
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
|
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
|
||||||
|
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
|
||||||
route("api/reports", "routes/api.reports.ts"),
|
route("api/reports", "routes/api.reports.ts"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
|
||||||
|
const UNIT_C62 = "C62" as const;
|
||||||
|
const UNIT_HUR = "HUR" as const;
|
||||||
|
const UNIT_DAY = "DAY" as const;
|
||||||
|
const UNIT_MON = "MON" as const;
|
||||||
|
const UNIT_ANN = "ANN" as const;
|
||||||
|
const UNIT_KMT = "KMT" as const;
|
||||||
|
type UnitCode = typeof UNIT_C62 | typeof UNIT_HUR | typeof UNIT_DAY | typeof UNIT_MON | typeof UNIT_ANN | typeof UNIT_KMT;
|
||||||
|
|
||||||
|
function toUnitCode(unit: string | null | undefined): UnitCode {
|
||||||
|
if (!unit) return UNIT_C62;
|
||||||
|
const u = unit.toLowerCase().trim();
|
||||||
|
if (u === "stunde" || u === "stunden" || u === "h" || u === "std" || u === "hour") return UNIT_HUR;
|
||||||
|
if (u === "tag" || u === "tage" || u === "day" || u === "days") return UNIT_DAY;
|
||||||
|
if (u === "monat" || u === "monate" || u === "month") return UNIT_MON;
|
||||||
|
if (u === "jahr" || u === "jahre" || u === "year") return UNIT_ANN;
|
||||||
|
if (u === "km") return UNIT_KMT;
|
||||||
|
return UNIT_C62;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { id: params.id, company: { userId: user.id } },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: { position: "asc" } },
|
||||||
|
customer: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const { zugferd } = await import("node-zugferd");
|
||||||
|
const { EN16931 } = await import("node-zugferd/profile");
|
||||||
|
|
||||||
|
const z = zugferd({ profile: EN16931, strict: false });
|
||||||
|
|
||||||
|
const isKleinunternehmer = invoice.kleinunternehmer;
|
||||||
|
const netTotal = Number(invoice.netTotal);
|
||||||
|
const taxTotal = Number(invoice.taxTotal);
|
||||||
|
const grossTotal = Number(invoice.grossTotal);
|
||||||
|
|
||||||
|
// Group taxes by rate for vatBreakdown
|
||||||
|
const taxGroups: Record<number, { basis: number; tax: number }> = {};
|
||||||
|
for (const item of invoice.items) {
|
||||||
|
const rate = Number(item.taxRate);
|
||||||
|
if (!taxGroups[rate]) taxGroups[rate] = { basis: 0, tax: 0 };
|
||||||
|
taxGroups[rate].basis += Number(item.netAmount);
|
||||||
|
taxGroups[rate].tax += Number(item.taxAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vatBreakdown = isKleinunternehmer
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
calculatedAmount: 0,
|
||||||
|
typeCode: "VAT",
|
||||||
|
basisAmount: netTotal,
|
||||||
|
categoryCode: "E" as const,
|
||||||
|
rateApplicablePercent: 0,
|
||||||
|
exemptionReasonText: "Steuerbefreiung gemäß §19 Abs. 1 UStG",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: Object.entries(taxGroups).map(([rate, { basis, tax }]) => ({
|
||||||
|
calculatedAmount: tax,
|
||||||
|
typeCode: "VAT",
|
||||||
|
basisAmount: basis,
|
||||||
|
categoryCode: "S" as const,
|
||||||
|
rateApplicablePercent: Number(rate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lines = invoice.items.map((item) => ({
|
||||||
|
tradeProduct: { name: item.description },
|
||||||
|
tradeDelivery: {
|
||||||
|
billedQuantity: {
|
||||||
|
amount: Number(item.quantity),
|
||||||
|
unitMeasureCode: toUnitCode(item.unit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tradeAgreement: {
|
||||||
|
netTradePrice: { chargeAmount: Number(item.unitPrice) },
|
||||||
|
},
|
||||||
|
tradeSettlement: {
|
||||||
|
tradeTax: {
|
||||||
|
typeCode: "VAT",
|
||||||
|
categoryCode: (isKleinunternehmer ? "E" : "S") as "E" | "S",
|
||||||
|
rateApplicablePercent: isKleinunternehmer ? 0 : Number(item.taxRate),
|
||||||
|
},
|
||||||
|
monetarySummation: { lineTotalAmount: Number(item.netAmount) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doc = z.create({
|
||||||
|
number: invoice.number ?? invoice.id,
|
||||||
|
typeCode: "380",
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
transaction: {
|
||||||
|
tradeAgreement: {
|
||||||
|
seller: {
|
||||||
|
name: invoice.company.name,
|
||||||
|
postalAddress: {
|
||||||
|
...(invoice.company.zip ? { postCode: invoice.company.zip } : {}),
|
||||||
|
...(invoice.company.address ? { line1: invoice.company.address } : {}),
|
||||||
|
...(invoice.company.city ? { city: invoice.company.city } : {}),
|
||||||
|
countryCode: "DE",
|
||||||
|
},
|
||||||
|
taxRegistration: {
|
||||||
|
...(invoice.company.vatId ? { vatIdentifier: invoice.company.vatId } : {}),
|
||||||
|
...(invoice.company.taxId ? { localIdentifier: invoice.company.taxId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
buyer: {
|
||||||
|
name: invoice.customer.name,
|
||||||
|
postalAddress: {
|
||||||
|
...(invoice.customer.zip ? { postCode: invoice.customer.zip } : {}),
|
||||||
|
...(invoice.customer.address ? { line1: invoice.customer.address } : {}),
|
||||||
|
...(invoice.customer.city ? { city: invoice.customer.city } : {}),
|
||||||
|
countryCode: "DE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tradeDelivery: {
|
||||||
|
...(invoice.deliveryDate
|
||||||
|
? { information: { deliveryDate: invoice.deliveryDate } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
tradeSettlement: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
paymentTerms: { dueDate: invoice.dueDate },
|
||||||
|
vatBreakdown,
|
||||||
|
monetarySummation: {
|
||||||
|
lineTotalAmount: netTotal,
|
||||||
|
taxBasisTotalAmount: netTotal,
|
||||||
|
taxTotal: { amount: taxTotal, currencyCode: "EUR" as const },
|
||||||
|
grandTotalAmount: grossTotal,
|
||||||
|
duePayableAmount: grossTotal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
line: lines,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = await doc.toXML();
|
||||||
|
|
||||||
|
return new Response(xml as string, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.xml"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -168,16 +168,24 @@ export default function InvoiceDetailPage() {
|
|||||||
* 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 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.
|
* It then simulates a click event on the anchor element, so the user is prompted to download the PDF file.
|
||||||
*/
|
*/
|
||||||
async function downloadPdf() {
|
async function downloadFile(url: string, filename: string) {
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = objectUrl;
|
||||||
a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPdf() {
|
||||||
|
return downloadFile(`/api/invoices/${invoice.id}/pdf`, `rechnung-${invoice.number ?? invoice.id}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadXml() {
|
||||||
|
return downloadFile(`/api/invoices/${invoice.id}/xml`, `rechnung-${invoice.number ?? invoice.id}.xml`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,9 +211,14 @@ export default function InvoiceDetailPage() {
|
|||||||
{/* Invoice Actions */}
|
{/* Invoice Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{invoice.status !== "DELETED" && (
|
{invoice.status !== "DELETED" && (
|
||||||
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
<>
|
||||||
<Download className="h-4 w-4" /> PDF
|
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
||||||
</Button>
|
<Download className="h-4 w-4" /> PDF
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadXml}>
|
||||||
|
<Download className="h-4 w-4" /> E-Rechnung
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{invoice.status === "DRAFT" && (
|
{invoice.status === "DRAFT" && (
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
@@ -336,9 +349,7 @@ export default function InvoiceDetailPage() {
|
|||||||
<tr key={item.id} className="hover:bg-gray-50">
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 text-gray-500">{item.position}</td>
|
<td className="px-4 py-3 text-gray-500">{item.position}</td>
|
||||||
<td className="px-4 py-3 text-gray-900">{item.description}</td>
|
<td className="px-4 py-3 text-gray-900">{item.description}</td>
|
||||||
<td className="px-4 py-3 text-right text-gray-700">
|
<td className="px-4 py-3 text-right text-gray-700">{item.quantity}</td>
|
||||||
{item.quantity} {item.unit && <span className="text-gray-500">{item.unit}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
|
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
|
||||||
<td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
|
<td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
|
||||||
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
|
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { TAX_RATES, formatCurrency } from "@/lib/tax";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Pflichtfeld"),
|
name: z.string().min(1, "Pflichtfeld"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
unit: z.string().optional(),
|
|
||||||
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
|
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
|
||||||
taxRate: z.coerce.number(),
|
taxRate: z.coerce.number(),
|
||||||
});
|
});
|
||||||
@@ -36,7 +35,6 @@ interface Service {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
unit: string | null;
|
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
}
|
}
|
||||||
@@ -89,16 +87,10 @@ function ServiceForm({
|
|||||||
<Label>Beschreibung</Label>
|
<Label>Beschreibung</Label>
|
||||||
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
|
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<Label>Einzelpreis (€) *</Label>
|
||||||
<Label>Einheit</Label>
|
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
|
||||||
<Input {...register("unit")} placeholder="Stunde, Stück, ..." />
|
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
|
||||||
</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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Steuersatz</Label>
|
<Label>Steuersatz</Label>
|
||||||
@@ -127,7 +119,7 @@ function ServiceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate";
|
type SortKey = "name" | "description" | "unitPrice" | "taxRate";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
export default function LeistungenPage() {
|
export default function LeistungenPage() {
|
||||||
@@ -220,7 +212,6 @@ export default function LeistungenPage() {
|
|||||||
defaultValues={{
|
defaultValues={{
|
||||||
name: editService.name,
|
name: editService.name,
|
||||||
description: editService.description ?? undefined,
|
description: editService.description ?? undefined,
|
||||||
unit: editService.unit ?? undefined,
|
|
||||||
unitPrice: editService.unitPrice,
|
unitPrice: editService.unitPrice,
|
||||||
taxRate: editService.taxRate,
|
taxRate: editService.taxRate,
|
||||||
}}
|
}}
|
||||||
@@ -244,8 +235,8 @@ export default function LeistungenPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
<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) => {
|
{(["name", "description", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
|
||||||
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unit: "Einheit", unitPrice: "Preis", taxRate: "MwSt." };
|
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unitPrice: "Preis", taxRate: "MwSt." };
|
||||||
const isNum = key === "unitPrice" || key === "taxRate";
|
const isNum = key === "unitPrice" || key === "taxRate";
|
||||||
const active = sortKey === key;
|
const active = sortKey === key;
|
||||||
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
|
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
|
||||||
@@ -270,7 +261,6 @@ export default function LeistungenPage() {
|
|||||||
<tr key={service.id} className="hover:bg-slate-50 transition-colors">
|
<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 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 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-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 text-right text-slate-500">{service.taxRate}%</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
|||||||
Generated
+125
@@ -28,6 +28,7 @@
|
|||||||
"isbot": "^5",
|
"isbot": "^5",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-zugferd": "^0.1.1-beta.1",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
@@ -1288,6 +1289,22 @@
|
|||||||
"@tybys/wasm-util": "^0.10.0"
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
@@ -3954,6 +3971,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -4419,6 +4441,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "4.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
||||||
|
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^1.0.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5503,6 +5542,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-zugferd": {
|
||||||
|
"version": "0.1.1-beta.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-zugferd/-/node-zugferd-0.1.1-beta.1.tgz",
|
||||||
|
"integrity": "sha512-FTX2SMSl2qT0C0iwfggdYcwoPBndTyqP7SiQyW9ClkLFwTPTm4ZUSpn4eqwBcrc2wFGLrEtr+w+s7602JCcG6Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"fast-xml-parser": "^4.5.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"xsd-schema-validator": "^0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-zugferd/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-svg-path": {
|
"node_modules/normalize-svg-path": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
@@ -5678,6 +5739,22 @@
|
|||||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6357,6 +6434,17 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -6885,6 +6973,43 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xsd-schema-validator": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xsd-schema-validator/-/xsd-schema-validator-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-G1GtYp9Smww5D9U3QJy/uMeoaDlEYg5BR4qZYSBZWa/5TG5az2j3Np27uLKaRcg6ajwe3Ew6SJrAo3B/QFrgdg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"which": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xsd-schema-validator/node_modules/isexe": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xsd-schema-validator/node_modules/which": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/which.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"isbot": "^5",
|
"isbot": "^5",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-zugferd": "^0.1.1-beta.1",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user