144 lines
5.9 KiB
TypeScript
144 lines
5.9 KiB
TypeScript
// /opt/erp-system/app/billing/[customerId]/page.tsx
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { ArrowLeft, Printer } from 'lucide-react';
|
|
import { jsPDF } from 'jspdf';
|
|
import autoTable from 'jspdf-autotable';
|
|
|
|
export default function InvoiceDraftPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const customerId = params.customerId;
|
|
|
|
const [entries, setEntries] = useState<any[]>([]);
|
|
const [customer, setCustomer] = useState<any>(null);
|
|
const [settings, setSettings] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (customerId) fetchData();
|
|
}, [customerId]);
|
|
|
|
const fetchData = async () => {
|
|
const [billingRes, settingsRes] = await Promise.all([
|
|
fetch('/api/billing'),
|
|
fetch('/api/settings')
|
|
]);
|
|
|
|
if (billingRes.ok && settingsRes.ok) {
|
|
const allEntries = await billingRes.json();
|
|
const filtered = allEntries.filter((e: any) => e.ticket.customerId === parseInt(customerId as string));
|
|
setEntries(filtered);
|
|
if (filtered.length > 0) setCustomer(filtered[0].ticket.customer);
|
|
setSettings(await settingsRes.json());
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleUpdateEntry = async (id: number, field: string, value: any) => {
|
|
const updatedEntries = entries.map(e => e.id === id ? { ...e, [field]: value } : e);
|
|
setEntries(updatedEntries);
|
|
const entry = updatedEntries.find(e => e.id === id);
|
|
await fetch(`/api/time-entries/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ description: entry.description, durationMins: entry.durationMins })
|
|
});
|
|
};
|
|
|
|
const generatePDF = () => {
|
|
const doc = new jsPDF();
|
|
const dateStr = new Date().toLocaleDateString('de-DE');
|
|
const invNo = `RE-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
|
|
|
doc.setFontSize(20);
|
|
doc.text(settings.companyName || "ERP SYSTEM", 14, 20);
|
|
doc.setFontSize(9);
|
|
doc.text(settings.companyInfo || "", 14, 30);
|
|
|
|
doc.setFontSize(11);
|
|
doc.text("Rechnung an:", 14, 55);
|
|
doc.setFont("helvetica", "bold");
|
|
doc.text(customer.companyName || `${customer.firstName} ${customer.lastName}`, 14, 62);
|
|
doc.setFont("helvetica", "normal");
|
|
doc.text(`${customer.address || ''}\n${customer.zipCode || ''} ${customer.city || ''}`, 14, 68);
|
|
|
|
doc.text(`Datum: ${dateStr}`, 140, 62);
|
|
doc.text(`Rechnungs-Nr: ${invNo}`, 140, 68);
|
|
|
|
const tableData = entries.map(e => [
|
|
new Date(e.createdAt).toLocaleDateString('de-DE'),
|
|
`${e.ticket.title}\n${e.description}`,
|
|
(e.durationMins / 60).toFixed(2) + " h",
|
|
settings.hourlyRate.toFixed(2) + " €",
|
|
((e.durationMins / 60) * settings.hourlyRate).toFixed(2) + " €"
|
|
]);
|
|
|
|
autoTable(doc, {
|
|
startY: 90,
|
|
head: [['Datum', 'Leistung', 'Menge', 'Einzelpreis', 'Gesamt']],
|
|
body: tableData,
|
|
theme: 'striped',
|
|
headStyles: { fillColor: [79, 70, 229] }
|
|
});
|
|
|
|
const netTotal = entries.reduce((sum, e) => sum + (e.durationMins / 60 * settings.hourlyRate), 0);
|
|
const tax = netTotal * (settings.taxRate / 100);
|
|
const grossTotal = netTotal + tax;
|
|
|
|
const finalY = (doc as any).lastAutoTable.finalY + 10;
|
|
doc.text(`Netto Gesamt:`, 140, finalY);
|
|
doc.text(`${netTotal.toFixed(2)} €`, 180, finalY, { align: 'right' });
|
|
doc.text(`USt. ${settings.taxRate}%:`, 140, finalY + 6);
|
|
doc.text(`${tax.toFixed(2)} €`, 180, finalY + 6, { align: 'right' });
|
|
doc.setFont("helvetica", "bold");
|
|
doc.text(`Rechnungsbetrag:`, 140, finalY + 14);
|
|
doc.text(`${grossTotal.toFixed(2)} €`, 180, finalY + 14, { align: 'right' });
|
|
|
|
doc.save(`Rechnung_${invNo}.pdf`);
|
|
};
|
|
|
|
if (loading) return <div className="p-8">Lade Entwurf...</div>;
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<button onClick={() => router.back()} className="text-slate-500 hover:text-slate-800 flex items-center gap-2 transition">
|
|
<ArrowLeft className="w-4 h-4" /> Abbrechen
|
|
</button>
|
|
<button onClick={generatePDF} className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg font-bold shadow-lg flex items-center gap-2 hover:bg-indigo-700 transition">
|
|
<Printer className="w-5 h-5" /> PDF erstellen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200">
|
|
<h1 className="text-2xl font-bold text-slate-900 mb-6">Rechnung korrigieren</h1>
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-slate-600 border-y border-slate-100">
|
|
<tr>
|
|
<th className="py-3 px-4 text-left">Beschreibung</th>
|
|
<th className="py-3 px-4 text-right">Minuten</th>
|
|
<th className="py-3 px-4 text-right">Betrag</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{entries.map((entry) => (
|
|
<tr key={entry.id}>
|
|
<td className="py-4 px-4">
|
|
<textarea className="w-full border-none p-0 focus:ring-0 text-slate-700 bg-transparent resize-none" rows={2} value={entry.description} onChange={(e) => handleUpdateEntry(entry.id, 'description', e.target.value)} />
|
|
</td>
|
|
<td className="py-4 px-4 text-right">
|
|
<input type="number" step="15" className="w-20 text-right border border-slate-200 rounded p-1" value={entry.durationMins} onChange={(e) => handleUpdateEntry(entry.id, 'durationMins', parseInt(e.target.value))} />
|
|
</td>
|
|
<td className="py-4 px-4 text-right font-mono">{(entry.durationMins / 60 * settings.hourlyRate).toFixed(2)} €</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|