ADD: adden rechnungne ohne ust id und logout button

This commit is contained in:
hwinkel
2026-03-11 23:23:24 +01:00
parent f9307d9f4a
commit b8e981eaf3
17 changed files with 263 additions and 115 deletions
+2
View File
@@ -43,3 +43,5 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
/src/generated/prisma /src/generated/prisma
/db/data
+14 -1
View File
@@ -21,6 +21,7 @@ const schema = z.object({
bankBic: z.string().optional(), bankBic: z.string().optional(),
bankName: z.string().optional(), bankName: z.string().optional(),
invoicePrefix: z.string().optional(), invoicePrefix: z.string().optional(),
kleinunternehmer: z.boolean().optional(),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
@@ -44,7 +45,7 @@ function Field({ label, error, children }: { label: string; error?: string; chil
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 { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { country: "DE", invoicePrefix: "RE", ...defaultValues }, defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues },
}); });
return ( return (
@@ -124,6 +125,18 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
</Field> </Field>
</div> </div>
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p> <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>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
+101 -44
View File
@@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { calcItemAmounts, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax"; import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
interface Customer { interface Customer {
@@ -39,6 +39,7 @@ interface InvoiceFormProps {
companyId: string; companyId: string;
onSubmit: (data: Record<string, unknown>) => Promise<void>; onSubmit: (data: Record<string, unknown>) => Promise<void>;
defaultValues?: Partial<InvoiceFormValues>; defaultValues?: Partial<InvoiceFormValues>;
defaultKleinunternehmer?: boolean;
} }
const defaultItem = (): ItemFormData => ({ const defaultItem = (): ItemFormData => ({
@@ -53,9 +54,10 @@ const defaultItem = (): ItemFormData => ({
grossAmount: 0, grossAmount: 0,
}); });
export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps) { export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinunternehmer = false }: InvoiceFormProps) {
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const [kleinunternehmer, setKleinunternehmer] = useState(defaultKleinunternehmer);
const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({ const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
defaultValues: { defaultValues: {
@@ -76,12 +78,19 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
if (!item) return; if (!item) return;
const qty = parseFloat(item.quantity) || 0; const qty = parseFloat(item.quantity) || 0;
const price = parseFloat(item.unitPrice) || 0; const price = parseFloat(item.unitPrice) || 0;
const rate = parseFloat(item.taxRate) || 0; if (kleinunternehmer) {
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate); const { netAmount, taxAmount, grossAmount } = calcItemAmountsKleinunternehmer(qty, price);
setValue(`items.${index}.netAmount`, netAmount); setValue(`items.${index}.netAmount`, netAmount);
setValue(`items.${index}.taxAmount`, taxAmount); setValue(`items.${index}.taxAmount`, taxAmount);
setValue(`items.${index}.grossAmount`, grossAmount); setValue(`items.${index}.grossAmount`, grossAmount);
}, [watchedItems, setValue]); } else {
const rate = parseFloat(item.taxRate) || 0;
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
setValue(`items.${index}.netAmount`, netAmount);
setValue(`items.${index}.taxAmount`, taxAmount);
setValue(`items.${index}.grossAmount`, grossAmount);
}
}, [watchedItems, setValue, kleinunternehmer]);
const totals = calcInvoiceTotals( const totals = calcInvoiceTotals(
watchedItems.map((item) => ({ watchedItems.map((item) => ({
@@ -95,6 +104,20 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
const items = data.items.map((item, i) => { const items = data.items.map((item, i) => {
const qty = parseFloat(item.quantity) || 0; const qty = parseFloat(item.quantity) || 0;
const price = parseFloat(item.unitPrice) || 0; const price = parseFloat(item.unitPrice) || 0;
if (kleinunternehmer) {
const { netAmount, taxAmount, grossAmount } = calcItemAmountsKleinunternehmer(qty, price);
return {
position: i + 1,
description: item.description,
quantity: qty,
unit: item.unit || undefined,
unitPrice: price,
taxRate: 0,
netAmount,
taxAmount,
grossAmount,
};
}
const rate = parseFloat(item.taxRate) || 0; const rate = parseFloat(item.taxRate) || 0;
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate); const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
return { return {
@@ -119,6 +142,7 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
deliveryDate: data.deliveryDate || undefined, deliveryDate: data.deliveryDate || undefined,
dueDate: data.dueDate, dueDate: data.dueDate,
notes: data.notes || undefined, notes: data.notes || undefined,
kleinunternehmer,
items, items,
...totals, ...totals,
}); });
@@ -141,6 +165,23 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
</Select> </Select>
{errors.customerId && <p className="text-xs text-red-600">Pflichtfeld</p>} {errors.customerId && <p className="text-xs text-red-600">Pflichtfeld</p>}
</div> </div>
<div className="flex items-end pb-0.5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={kleinunternehmer}
onChange={(e) => {
setKleinunternehmer(e.target.checked);
watchedItems.forEach((_, i) => recalcItem(i));
}}
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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -172,18 +213,18 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
</div> </div>
<div className="border border-gray-200 rounded-xl overflow-hidden"> <div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600"> <div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
<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-1">Einh.</div>
<div className="col-span-2">Einzelpreis</div> <div className="col-span-2">{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}</div>
<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>
<div className="col-span-1"></div> <div className="col-span-1"></div>
</div> </div>
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-12 gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center"> <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 className="col-span-4"> <div className="col-span-4">
<Input <Input
{...register(`items.${index}.description`, { required: true })} {...register(`items.${index}.description`, { required: true })}
@@ -212,26 +253,28 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
onBlur={() => recalcItem(index)} onBlur={() => recalcItem(index)}
/> />
</div> </div>
<div className="col-span-1"> {!kleinunternehmer && (
<Select <div className="col-span-1">
defaultValue="19" <Select
onValueChange={(v) => { defaultValue="19"
setValue(`items.${index}.taxRate`, v); onValueChange={(v) => {
recalcItem(index); setValue(`items.${index}.taxRate`, v);
}} recalcItem(index);
> }}
<SelectTrigger className="text-sm"> >
<SelectValue /> <SelectTrigger className="text-sm">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
{TAX_RATES.map((r) => ( <SelectContent>
<SelectItem key={r.value} value={String(r.value)}> {TAX_RATES.map((r) => (
{r.value}% <SelectItem key={r.value} value={String(r.value)}>
</SelectItem> {r.value}%
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</div> </Select>
</div>
)}
<div className="col-span-2 text-right text-sm font-medium text-gray-900"> <div className="col-span-2 text-right text-sm font-medium text-gray-900">
{formatCurrency(watchedItems[index]?.grossAmount ?? 0)} {formatCurrency(watchedItems[index]?.grossAmount ?? 0)}
</div> </div>
@@ -254,18 +297,32 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<div className="w-64 space-y-1.5"> <div className="w-64 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600"> {kleinunternehmer ? (
<span>Netto</span> <>
<span>{formatCurrency(totals.netTotal)}</span> <div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
</div> <span>Gesamtbetrag</span>
<div className="flex justify-between text-sm text-gray-600"> <span>{formatCurrency(totals.grossTotal)}</span>
<span>MwSt.</span> </div>
<span>{formatCurrency(totals.taxTotal)}</span> <p className="text-xs text-gray-500 pt-1">
</div> Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5"> </p>
<span>Gesamt (brutto)</span> </>
<span>{formatCurrency(totals.grossTotal)}</span> ) : (
</div> <>
<div className="flex justify-between text-sm text-gray-600">
<span>Netto</span>
<span>{formatCurrency(totals.netTotal)}</span>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span>MwSt.</span>
<span>{formatCurrency(totals.taxTotal)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
<span>Gesamt (brutto)</span>
<span>{formatCurrency(totals.grossTotal)}</span>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>
+41 -24
View File
@@ -204,6 +204,7 @@ interface InvoicePDFProps {
deliveryDate?: Date | string | null; deliveryDate?: Date | string | null;
dueDate: Date | string; dueDate: Date | string;
notes?: string | null; notes?: string | null;
kleinunternehmer?: boolean;
netTotal: number | string | { toString(): string }; netTotal: number | string | { toString(): string };
taxTotal: number | string | { toString(): string }; taxTotal: number | string | { toString(): string };
grossTotal: number | string | { toString(): string }; grossTotal: number | string | { toString(): string };
@@ -223,7 +224,6 @@ interface InvoicePDFProps {
}; };
customer: { customer: {
name: string; name: string;
vatId?: string | null;
address: string; address: string;
zip: string; zip: string;
city: string; city: string;
@@ -296,11 +296,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
{invoice.customer.country !== "DE" && ( {invoice.customer.country !== "DE" && (
<Text style={styles.addressLine}>{invoice.customer.country}</Text> <Text style={styles.addressLine}>{invoice.customer.country}</Text>
)} )}
{invoice.customer.vatId && (
<Text style={{ ...styles.addressLine, marginTop: 3 }}>
USt-IdNr.: {invoice.customer.vatId}
</Text>
)}
</View> </View>
</View> </View>
@@ -330,9 +325,13 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<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_unit }}>Einh.</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>EP (netto)</Text> <Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text> {invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt (brutto)</Text> </Text>
{!invoice.kleinunternehmer && (
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
)}
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt</Text>
</View> </View>
{invoice.items.map((item, idx) => ( {invoice.items.map((item, idx) => (
@@ -342,7 +341,9 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<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_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>
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text> {!invoice.kleinunternehmer && (
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
)}
<Text style={{ ...styles.col_total, fontSize: 9, fontFamily: "Helvetica-Bold" }}> <Text style={{ ...styles.col_total, fontSize: 9, fontFamily: "Helvetica-Bold" }}>
{formatMoney(n(item.grossAmount))} {formatMoney(n(item.grossAmount))}
</Text> </Text>
@@ -351,20 +352,36 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<View style={styles.totalsSection}> <View style={styles.totalsSection}>
<View style={styles.totalsTable}> <View style={styles.totalsTable}>
<View style={styles.totalsRow}> {invoice.kleinunternehmer ? (
<Text style={styles.totalsLabel}>Nettobetrag</Text> <>
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text> <View style={styles.totalsFinalRow}>
</View> <Text style={styles.totalsFinalLabel}>Gesamtbetrag</Text>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => ( <Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
<View key={rate} style={styles.totalsRow}> </View>
<Text style={styles.totalsLabel}>MwSt. {rate}% auf {formatMoney(net)}</Text> <View style={styles.totalsRow}>
<Text style={styles.totalsValue}>{formatMoney(tax)}</Text> <Text style={{ ...styles.totalsLabel, fontSize: 8, fontStyle: "italic" }}>
</View> Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
))} </Text>
<View style={styles.totalsFinalRow}> </View>
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text> </>
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text> ) : (
</View> <>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>Nettobetrag</Text>
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
</View>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
<View key={rate} style={styles.totalsRow}>
<Text style={styles.totalsLabel}>MwSt. {rate}% auf {formatMoney(net)}</Text>
<Text style={styles.totalsValue}>{formatMoney(tax)}</Text>
</View>
))}
<View style={styles.totalsFinalRow}>
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
</View>
</>
)}
</View> </View>
</View> </View>
+20 -3
View File
@@ -1,5 +1,5 @@
import { useMatches, useLocation, Link } from "react-router"; import { useMatches, useLocation, Link, Form } from "react-router";
import { ChevronRight, LayoutDashboard } from "lucide-react"; import { ChevronRight, LayoutDashboard, LogOut } from "lucide-react";
interface Breadcrumb { interface Breadcrumb {
label: string; label: string;
@@ -107,7 +107,7 @@ export function Topbar({ userName }: { userName?: string | null }) {
</Link> </Link>
)} )}
{/* User */} {/* User + Logout */}
{userName && ( {userName && (
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "1rem", flexShrink: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "1rem", flexShrink: 0 }}>
<span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span> <span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span>
@@ -127,6 +127,23 @@ export function Topbar({ userName }: { userName?: string | null }) {
> >
{getInitials(userName)} {getInitials(userName)}
</div> </div>
<Form method="post" action="/logout">
<button
type="submit"
title="Abmelden"
style={{
display: "flex",
alignItems: "center",
background: "none",
border: "none",
cursor: "pointer",
color: "#94a3b8",
padding: "0.25rem",
}}
>
<LogOut style={{ width: "1rem", height: "1rem" }} />
</button>
</Form>
</div> </div>
)} )}
</header> </header>
+8
View File
@@ -15,6 +15,14 @@ export function calcItemAmounts(
return { netAmount, taxAmount, grossAmount }; return { netAmount, taxAmount, grossAmount };
} }
export function calcItemAmountsKleinunternehmer(
quantity: number,
unitPrice: number
) {
const grossAmount = Math.round(quantity * unitPrice * 100) / 100;
return { netAmount: grossAmount, taxAmount: 0, grossAmount };
}
export function calcInvoiceTotals( export function calcInvoiceTotals(
items: Array<{ netAmount: number; taxAmount: number; grossAmount: number }> items: Array<{ netAmount: number; taxAmount: number; grossAmount: number }>
) { ) {
+1
View File
@@ -18,6 +18,7 @@ const companySchema = z.object({
bankBic: z.string().optional(), bankBic: z.string().optional(),
bankName: z.string().optional(), bankName: z.string().optional(),
invoicePrefix: z.string().optional(), invoicePrefix: z.string().optional(),
kleinunternehmer: z.boolean().optional(),
}); });
export async function loader({ request, params }: { request: Request; params: { id: string } }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
+1
View File
@@ -17,6 +17,7 @@ const companySchema = z.object({
bankBic: z.string().optional(), bankBic: z.string().optional(),
bankName: z.string().optional(), bankName: z.string().optional(),
invoicePrefix: z.string().optional().default("RE"), invoicePrefix: z.string().optional().default("RE"),
kleinunternehmer: z.boolean().optional().default(false),
}); });
export async function loader({ request }: { request: Request }) { export async function loader({ request }: { request: Request }) {
+1
View File
@@ -22,6 +22,7 @@ const invoiceSchema = z.object({
deliveryDate: z.string().optional(), deliveryDate: z.string().optional(),
dueDate: z.string(), dueDate: z.string(),
notes: z.string().optional(), notes: z.string().optional(),
kleinunternehmer: z.boolean().optional().default(false),
items: z.array(itemSchema).min(1), items: z.array(itemSchema).min(1),
netTotal: z.number(), netTotal: z.number(),
taxTotal: z.number(), taxTotal: z.number(),
@@ -45,6 +45,7 @@ export async function loader({
netTotal: Number(invoice.netTotal), netTotal: Number(invoice.netTotal),
taxTotal: Number(invoice.taxTotal), taxTotal: Number(invoice.taxTotal),
grossTotal: Number(invoice.grossTotal), grossTotal: Number(invoice.grossTotal),
kleinunternehmer: invoice.kleinunternehmer,
issueDate: invoice.issueDate.toISOString(), issueDate: invoice.issueDate.toISOString(),
dueDate: invoice.dueDate.toISOString(), dueDate: invoice.dueDate.toISOString(),
deliveryDate: invoice.deliveryDate?.toISOString() ?? null, deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
@@ -184,9 +185,6 @@ export default function InvoiceDetailPage() {
<p className="font-semibold text-gray-900">{invoice.customer.name}</p> <p className="font-semibold text-gray-900">{invoice.customer.name}</p>
<p className="text-sm text-gray-600">{invoice.customer.address}</p> <p className="text-sm text-gray-600">{invoice.customer.address}</p>
<p className="text-sm text-gray-600">{invoice.customer.zip} {invoice.customer.city}</p> <p className="text-sm text-gray-600">{invoice.customer.zip} {invoice.customer.city}</p>
{invoice.customer.vatId && (
<p className="text-xs text-gray-500 mt-1">USt-IdNr.: {invoice.customer.vatId}</p>
)}
</div> </div>
</div> </div>
@@ -242,20 +240,34 @@ export default function InvoiceDetailPage() {
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-72 space-y-1.5"> <div className="w-72 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600"> {invoice.kleinunternehmer ? (
<span>Nettobetrag</span> <>
<span>{formatCurrency(invoice.netTotal)}</span> <div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
</div> <span>Gesamtbetrag</span>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => ( <span>{formatCurrency(invoice.grossTotal)}</span>
<div key={rate} className="flex justify-between text-sm text-gray-600"> </div>
<span>MwSt. {rate}% auf {formatCurrency(net)}</span> <p className="text-xs text-gray-500 pt-1">
<span>{formatCurrency(tax)}</span> Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
</div> </p>
))} </>
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2"> ) : (
<span>Gesamtbetrag (brutto)</span> <>
<span>{formatCurrency(invoice.grossTotal)}</span> <div className="flex justify-between text-sm text-gray-600">
</div> <span>Nettobetrag</span>
<span>{formatCurrency(invoice.netTotal)}</span>
</div>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
<div key={rate} className="flex justify-between text-sm text-gray-600">
<span>MwSt. {rate}% auf {formatCurrency(net)}</span>
<span>{formatCurrency(tax)}</span>
</div>
))}
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
<span>Gesamtbetrag (brutto)</span>
<span>{formatCurrency(invoice.grossTotal)}</span>
</div>
</>
)}
</div> </div>
</div> </div>
@@ -285,18 +297,30 @@ export default function InvoiceDetailPage() {
<CardTitle className="text-sm">Zusammenfassung</CardTitle> <CardTitle className="text-sm">Zusammenfassung</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div> {invoice.kleinunternehmer ? (
<p className="text-xs text-gray-500">Netto</p> <>
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p> <div className="border-t border-gray-200 pt-2">
</div> <p className="text-xs text-gray-500">Gesamtbetrag</p>
<div> <p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
<p className="text-xs text-gray-500">MwSt.</p> </div>
<p className="font-medium text-gray-900">{formatCurrency(invoice.taxTotal)}</p> <p className="text-xs text-gray-400">Keine USt. gem. §19 UStG</p>
</div> </>
<div className="border-t border-gray-200 pt-2"> ) : (
<p className="text-xs text-gray-500">Brutto</p> <>
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p> <div>
</div> <p className="text-xs text-gray-500">Netto</p>
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
</div>
<div>
<p className="text-xs text-gray-500">MwSt.</p>
<p className="font-medium text-gray-900">{formatCurrency(invoice.taxTotal)}</p>
</div>
<div className="border-t border-gray-200 pt-2">
<p className="text-xs text-gray-500">Brutto</p>
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
</div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+1 -1
View File
@@ -74,7 +74,7 @@ export default function NewInvoicePage() {
<CardTitle>Rechnungsdaten</CardTitle> <CardTitle>Rechnungsdaten</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<InvoiceForm customers={customers} companyId={company.id} onSubmit={handleSubmit} /> <InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} onSubmit={handleSubmit} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+1 -4
View File
@@ -9,7 +9,7 @@ services:
MYSQL_USER: annas_user MYSQL_USER: annas_user
MYSQL_PASSWORD: annas_password MYSQL_PASSWORD: annas_password
volumes: volumes:
- mariadb_data:/var/lib/mysql - ./data:/var/lib/mysql
ports: ports:
- "3306:3306" - "3306:3306"
networks: networks:
@@ -39,9 +39,6 @@ services:
networks: networks:
- db_network - db_network
volumes:
mariadb_data:
networks: networks:
db_network: db_network:
driver: bridge driver: bridge
+3 -2
View File
@@ -4,13 +4,14 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "docker-compose -f db/docker-compose.yml up -d && react-router dev", "devfull": "docker-compose -f db/docker-compose.yml up -d && react-router dev",
"dev": "react-router dev",
"build": "react-router build", "build": "react-router build",
"start": "react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc", "typecheck": "react-router typegen && tsc",
"lint": "eslint", "lint": "eslint",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts", "db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio"
}, },
"prisma": { "prisma": {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoices` ADD COLUMN `kleinunternehmer` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `companies` ADD COLUMN `kleinunternehmer` BOOLEAN NOT NULL DEFAULT false;
+7 -4
View File
@@ -3,8 +3,9 @@ generator client {
} }
datasource db { datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
} }
model User { model User {
@@ -37,6 +38,7 @@ model Company {
bankName String? bankName String?
invoicePrefix String @default("RE") invoicePrefix String @default("RE")
invoiceSequence Int @default(0) invoiceSequence Int @default(0)
kleinunternehmer Boolean @default(false)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customers Customer[] customers Customer[]
@@ -76,8 +78,9 @@ model Invoice {
issueDate DateTime issueDate DateTime
deliveryDate DateTime? deliveryDate DateTime?
dueDate DateTime dueDate DateTime
status InvoiceStatus @default(DRAFT) status InvoiceStatus @default(DRAFT)
notes String? @db.Text kleinunternehmer Boolean @default(false)
notes String? @db.Text
items InvoiceItem[] items InvoiceItem[]
netTotal Decimal @db.Decimal(10, 2) netTotal Decimal @db.Decimal(10, 2)
taxTotal Decimal @db.Decimal(10, 2) taxTotal Decimal @db.Decimal(10, 2)
+5 -3
View File
@@ -51,7 +51,6 @@ async function main() {
id: "demo-customer-1", id: "demo-customer-1",
companyId: company.id, companyId: company.id,
name: "Beispiel AG", name: "Beispiel AG",
vatId: "DE987654321",
address: "Beispielweg 5", address: "Beispielweg 5",
zip: "20095", zip: "20095",
city: "Hamburg", city: "Hamburg",
@@ -61,8 +60,11 @@ async function main() {
console.log(`✓ Customer created: ${customer.name}`); console.log(`✓ Customer created: ${customer.name}`);
// Create demo invoice // Create demo invoice
const invoice = await prisma.invoice.create({ const invoice = await prisma.invoice.upsert({
data: { where: { id: "demo-invoice-1" },
update: {},
create: {
id: "demo-invoice-1",
number: "RE-2024-001", number: "RE-2024-001",
companyId: company.id, companyId: company.id,
customerId: customer.id, customerId: customer.id,