feat: add automated PDF invoice generation and storage in Supabase
This commit is contained in:
168
shop/components/invoice-pdf.tsx
Normal file
168
shop/components/invoice-pdf.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
// Create styles
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
padding: 50,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#333',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
companyInfo: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#000',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#999',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
display: 'table',
|
||||||
|
width: 'auto',
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#eee',
|
||||||
|
borderRightWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
margin: 'auto',
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
tableCol: {
|
||||||
|
width: '60%',
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#eee',
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
tableColPrice: {
|
||||||
|
width: '40%',
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#eee',
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
padding: 8,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
margin: 'auto',
|
||||||
|
marginTop: 5,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 20,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: '#000',
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 30,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 8,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: '#eee',
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||||
|
<Text>Software Solutions</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.companyInfo}>
|
||||||
|
<Text>CASPOS GmbH</Text>
|
||||||
|
<Text>Musterstraße 1</Text>
|
||||||
|
<Text>12345 Musterstadt</Text>
|
||||||
|
<Text>Deutschland</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Rechnungsempfänger</Text>
|
||||||
|
<Text>{customer.company_name}</Text>
|
||||||
|
<Text>{customer.first_name} {customer.last_name}</Text>
|
||||||
|
<Text>{customer.address}</Text>
|
||||||
|
<Text>{customer.zip_code} {customer.city}</Text>
|
||||||
|
<Text>USt-IdNr: {customer.vat_id}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
|
<Text>Bestellnummer: {order.id}</Text>
|
||||||
|
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
|
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.tableRow}>
|
||||||
|
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
|
||||||
|
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{modules.map((mod: any) => (
|
||||||
|
<View key={mod.id} style={styles.tableRow}>
|
||||||
|
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
|
||||||
|
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.total}>
|
||||||
|
<View style={{ width: '40%' }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text>Zwischensumme:</Text>
|
||||||
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
|
||||||
|
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
|
||||||
|
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text>Vielen Dank für Ihre Bestellung!</Text>
|
||||||
|
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { Order } from '../types'
|
import { InvoicePDF } from '@/components/invoice-pdf'
|
||||||
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
export async function submitOrder(orderData: {
|
export async function submitOrder(orderData: {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
@@ -15,23 +17,77 @@ export async function submitOrder(orderData: {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) throw new Error('Not authenticated')
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
const { data, error } = await supabase
|
// 1. Fetch Product and Module Details for Invoice
|
||||||
|
const { data: product } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', orderData.product_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const { data: modules } = await supabase
|
||||||
|
.from('product_modules')
|
||||||
|
.select('*')
|
||||||
|
.in('id', orderData.selected_modules)
|
||||||
|
|
||||||
|
if (!product) throw new Error('Product not found')
|
||||||
|
|
||||||
|
// 2. Create the Order in DB
|
||||||
|
const { data: order, error: orderError } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.insert([{
|
.insert([{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
total_amount: orderData.total_amount,
|
total_price: orderData.total_amount, // Matched column name in initial_schema.sql
|
||||||
items: {
|
order_data: {
|
||||||
product_id: orderData.product_id,
|
product_id: orderData.product_id,
|
||||||
modules: orderData.selected_modules
|
modules: orderData.selected_modules
|
||||||
},
|
},
|
||||||
billing_details: orderData.customer_details,
|
customer_data: orderData.customer_details,
|
||||||
status: 'pending'
|
status: 'pending'
|
||||||
}])
|
}])
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) throw error
|
if (orderError) throw orderError
|
||||||
|
|
||||||
revalidatePath('/admin/orders') // If there was an admin orders page
|
// 3. Generate PDF
|
||||||
return data
|
try {
|
||||||
|
const buffer = await renderToBuffer(
|
||||||
|
React.createElement(InvoicePDF, {
|
||||||
|
order: order,
|
||||||
|
product: product,
|
||||||
|
modules: modules || [],
|
||||||
|
customer: orderData.customer_details
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Upload to Supabase Storage
|
||||||
|
const fileName = `invoice_${order.id}.pdf`
|
||||||
|
const { data: uploadData, error: uploadError } = await supabase
|
||||||
|
.storage
|
||||||
|
.from('invoices')
|
||||||
|
.upload(fileName, buffer, {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
upsert: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error('PDF Upload Error:', uploadError)
|
||||||
|
} else {
|
||||||
|
// 5. Update Order with PDF URL
|
||||||
|
const { data: publicUrlData } = supabase
|
||||||
|
.storage
|
||||||
|
.from('invoices')
|
||||||
|
.getPublicUrl(fileName)
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('orders')
|
||||||
|
.update({ pdf_url: publicUrlData.publicUrl })
|
||||||
|
.eq('id', order.id)
|
||||||
|
}
|
||||||
|
} catch (pdfError) {
|
||||||
|
console.error('PDF Generation Error:', pdfError)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/protected')
|
||||||
|
return order
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Create bucket for invoices
|
||||||
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
|
VALUES ('invoices', 'invoices', true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Policies for invoices bucket
|
||||||
|
-- Allow public access to invoices (simplified for this demo, usually should be restricted)
|
||||||
|
CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'invoices');
|
||||||
|
CREATE POLICY "Authenticated Upload" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'invoices' AND auth.role() = 'authenticated');
|
||||||
Reference in New Issue
Block a user