ADD: adden rechnungne ohne ust id und logout button
This commit is contained in:
@@ -43,3 +43,5 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
/db/data
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (kleinunternehmer) {
|
||||||
|
const { netAmount, taxAmount, grossAmount } = calcItemAmountsKleinunternehmer(qty, price);
|
||||||
|
setValue(`items.${index}.netAmount`, netAmount);
|
||||||
|
setValue(`items.${index}.taxAmount`, taxAmount);
|
||||||
|
setValue(`items.${index}.grossAmount`, grossAmount);
|
||||||
|
} else {
|
||||||
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);
|
||||||
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]);
|
}
|
||||||
|
}, [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,6 +253,7 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
|
|||||||
onBlur={() => recalcItem(index)}
|
onBlur={() => recalcItem(index)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!kleinunternehmer && (
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<Select
|
<Select
|
||||||
defaultValue="19"
|
defaultValue="19"
|
||||||
@@ -232,6 +274,7 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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,6 +297,18 @@ 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">
|
||||||
|
{kleinunternehmer ? (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
|
||||||
|
<span>Gesamtbetrag</span>
|
||||||
|
<span>{formatCurrency(totals.grossTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 pt-1">
|
||||||
|
Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
<span>Netto</span>
|
<span>Netto</span>
|
||||||
<span>{formatCurrency(totals.netTotal)}</span>
|
<span>{formatCurrency(totals.netTotal)}</span>
|
||||||
@@ -266,6 +321,8 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
|
|||||||
<span>Gesamt (brutto)</span>
|
<span>Gesamt (brutto)</span>
|
||||||
<span>{formatCurrency(totals.grossTotal)}</span>
|
<span>{formatCurrency(totals.grossTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
{invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
|
||||||
|
</Text>
|
||||||
|
{!invoice.kleinunternehmer && (
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
|
||||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt (brutto)</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>
|
||||||
|
{!invoice.kleinunternehmer && (
|
||||||
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
|
<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,6 +352,20 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
|||||||
|
|
||||||
<View style={styles.totalsSection}>
|
<View style={styles.totalsSection}>
|
||||||
<View style={styles.totalsTable}>
|
<View style={styles.totalsTable}>
|
||||||
|
{invoice.kleinunternehmer ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.totalsFinalRow}>
|
||||||
|
<Text style={styles.totalsFinalLabel}>Gesamtbetrag</Text>
|
||||||
|
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={{ ...styles.totalsLabel, fontSize: 8, fontStyle: "italic" }}>
|
||||||
|
Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View style={styles.totalsRow}>
|
<View style={styles.totalsRow}>
|
||||||
<Text style={styles.totalsLabel}>Nettobetrag</Text>
|
<Text style={styles.totalsLabel}>Nettobetrag</Text>
|
||||||
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
|
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
|
||||||
@@ -365,6 +380,8 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
|||||||
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
|
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
|
||||||
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
|
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 } }) {
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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,6 +240,18 @@ 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">
|
||||||
|
{invoice.kleinunternehmer ? (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
|
||||||
|
<span>Gesamtbetrag</span>
|
||||||
|
<span>{formatCurrency(invoice.grossTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 pt-1">
|
||||||
|
Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
<span>Nettobetrag</span>
|
<span>Nettobetrag</span>
|
||||||
<span>{formatCurrency(invoice.netTotal)}</span>
|
<span>{formatCurrency(invoice.netTotal)}</span>
|
||||||
@@ -256,6 +266,8 @@ export default function InvoiceDetailPage() {
|
|||||||
<span>Gesamtbetrag (brutto)</span>
|
<span>Gesamtbetrag (brutto)</span>
|
||||||
<span>{formatCurrency(invoice.grossTotal)}</span>
|
<span>{formatCurrency(invoice.grossTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,6 +297,16 @@ 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">
|
||||||
|
{invoice.kleinunternehmer ? (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200 pt-2">
|
||||||
|
<p className="text-xs text-gray-500">Gesamtbetrag</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">Keine USt. gem. §19 UStG</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500">Netto</p>
|
<p className="text-xs text-gray-500">Netto</p>
|
||||||
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
|
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
|
||||||
@@ -297,6 +319,8 @@ export default function InvoiceDetailPage() {
|
|||||||
<p className="text-xs text-gray-500">Brutto</p>
|
<p className="text-xs text-gray-500">Brutto</p>
|
||||||
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
|
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -5,6 +5,7 @@ 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[]
|
||||||
@@ -77,6 +79,7 @@ model Invoice {
|
|||||||
deliveryDate DateTime?
|
deliveryDate DateTime?
|
||||||
dueDate DateTime
|
dueDate DateTime
|
||||||
status InvoiceStatus @default(DRAFT)
|
status InvoiceStatus @default(DRAFT)
|
||||||
|
kleinunternehmer Boolean @default(false)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
items InvoiceItem[]
|
items InvoiceItem[]
|
||||||
netTotal Decimal @db.Decimal(10, 2)
|
netTotal Decimal @db.Decimal(10, 2)
|
||||||
|
|||||||
+5
-3
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user