feat: split pricing into one-time and monthly totals, fix invoice pdf rendering
Some checks failed
Staging Build / build (push) Has been cancelled
Some checks failed
Staging Build / build (push) Has been cancelled
This commit is contained in:
@@ -94,7 +94,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||||
|
const items = orderSnapshot?.items ?? [];
|
||||||
|
const subtotal = orderSnapshot?.subtotal ?? 0;
|
||||||
|
const taxAmount = orderSnapshot?.tax_amount ?? 0;
|
||||||
|
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||||
|
const total = orderSnapshot?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -123,38 +130,58 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.label}>Bestelldetails</Text>
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
<Text>Bestellnummer: {order.id}</Text>
|
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
||||||
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.table}>
|
<View style={styles.table}>
|
||||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
|
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{items.map((item: any, idx: number) => (
|
||||||
|
<View key={idx}>
|
||||||
<View style={styles.tableRow}>
|
<View style={styles.tableRow}>
|
||||||
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
|
<View style={styles.tableCol}>
|
||||||
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
|
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
{modules.map((mod: any) => (
|
<Text>
|
||||||
<View key={mod.id} style={styles.tableRow}>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||||
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
|
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||||
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{item.selected_modules?.map((mod: any, mIdx: number) => (
|
||||||
|
<View key={mIdx} style={styles.tableRow}>
|
||||||
|
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||||
|
<Text style={{ color: '#666' }}>+ {mod.module_name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.total}>
|
<View style={styles.total}>
|
||||||
<View style={{ width: '40%' }}>
|
<View style={{ width: '50%' }}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text>Zwischensumme:</Text>
|
<Text>Netto-Zwischensumme:</Text>
|
||||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
|
<Text>zzgl. {taxRate}% USt:</Text>
|
||||||
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(taxAmount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||||
|
<Text style={styles.totalLabel}>Gesamtbetrag (brutto):</Text>
|
||||||
|
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(total)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -165,4 +192,5 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -118,20 +118,29 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Total price across all selections
|
// Total price across all selections
|
||||||
const totalPrice = useMemo(() => {
|
// Total price calculations (split by billing interval)
|
||||||
let total = 0
|
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||||
|
let monthly = 0
|
||||||
|
let oneTime = 0
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
if (!sel?.productId) continue
|
if (!sel?.productId) continue
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
const prod = products.find(p => p.id === sel.productId)
|
||||||
if (!prod) continue
|
if (!prod) continue
|
||||||
total += Number(prod.base_price)
|
const base = Number(prod.base_price)
|
||||||
|
let modulesSum = 0
|
||||||
sel.moduleIds.forEach(mId => {
|
sel.moduleIds.forEach(mId => {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
if (mod) total += Number(mod.price)
|
if (mod) modulesSum += Number(mod.price)
|
||||||
})
|
})
|
||||||
|
const totalItem = base + modulesSum
|
||||||
|
if (prod.billing_interval === 'one_time') {
|
||||||
|
oneTime += totalItem
|
||||||
|
} else {
|
||||||
|
monthly += totalItem
|
||||||
}
|
}
|
||||||
return total
|
}
|
||||||
|
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
|
||||||
}, [selections, products, categories])
|
}, [selections, products, categories])
|
||||||
|
|
||||||
// Helper: update product selection for a category (resets modules)
|
// Helper: update product selection for a category (resets modules)
|
||||||
@@ -425,12 +434,38 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-base font-semibold text-white">
|
||||||
|
<span className="text-slate-400">Einmalig gesamt:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-base font-semibold text-white">
|
||||||
|
<span className="text-slate-400">Monatlich gesamt:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-xs font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : oneTimeTotal > 0 ? (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
|
<span>Gesamt (einmalig):</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<span>Gesamt:</span>
|
<span>Gesamt:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{!allCategoriesFilled && (
|
{!allCategoriesFilled && (
|
||||||
<p className="text-xs text-destructive flex items-center gap-1">
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
@@ -669,12 +704,38 @@ export function OrderWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
<span>Gesamtbetrag:</span>
|
<div className="space-y-2 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : oneTimeTotal > 0 ? (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 italic">
|
<p className="text-xs text-slate-500 italic">
|
||||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
|
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
|
||||||
Datenschutzerklärung.
|
Datenschutzerklärung.
|
||||||
|
|||||||
Reference in New Issue
Block a user