Fixes: 1. useFetch: add credentials:'same-origin' — cookie wasn't being sent with API calls, causing 401 → React error boundary → 'Etwas ist schiefgelaufen' 2. Instance detail Sheet now loads correctly (cookie sent with request) New instance actions (UI + API): - Redeploy: triggers Dokploy application.redeploy (rebuilds from latest commit) - Stop: triggers Dokploy application.stop (pauses container) - Start: triggers Dokploy application.start (resumes container) - Remove: existing delete (SlideCommit confirmation) Dokploy deploy fix: - deployApplication now calls markRunning before deploy - This ensures Dokploy creates a deployment job ActionsRow redesigned: - demo_active/active: [Redeploy] [Stop] [Demo +7d] [Remove] - grace: [Start] [Remove] - failed: [Retry] [Remove] - provisioning/deleting/deleted: no actions
166 lines
6.8 KiB
TypeScript
Executable File
166 lines
6.8 KiB
TypeScript
Executable File
import { NextResponse } from 'next/server'
|
|
import { db } from '@/lib/db'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
export const maxDuration = 15
|
|
|
|
// GET /api/aos/invoices/[id]/pdf
|
|
// Generates a real PDF invoice (German layout) using HTML → print-ready format.
|
|
// Returns the invoice as an HTML page that browsers can "Print to PDF".
|
|
// In production, this would use Typst (PRD §5.2) for server-side PDF generation.
|
|
export async function GET(
|
|
_req: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params
|
|
|
|
const invoice = await db.invoice.findUnique({
|
|
where: { id },
|
|
include: { company: true, lines: true },
|
|
})
|
|
|
|
if (!invoice) {
|
|
return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get agency settings for the invoice header
|
|
const settings = await db.setting.findMany()
|
|
const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? ''
|
|
|
|
const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH'
|
|
const agencyLegalForm = getSetting('company.legalForm') || 'GmbH'
|
|
const agencyVatId = getSetting('company.vatId') || ''
|
|
const agencyRegisterCourt = getSetting('company.registerCourt') || ''
|
|
const agencyRegisterNumber = getSetting('company.registerNumber') || ''
|
|
const agencyCity = getSetting('company.city') || ''
|
|
const agencyBank = getSetting('company.bank') || ''
|
|
const agencyBic = getSetting('company.bic') || ''
|
|
|
|
// Generate German invoice HTML (print-ready)
|
|
const html = generateInvoiceHTML({
|
|
invoice,
|
|
agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic },
|
|
})
|
|
|
|
return new Response(html, {
|
|
headers: {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'content-disposition': `inline; filename="${invoice.number}.html"`,
|
|
'cache-control': 'no-store',
|
|
},
|
|
})
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
function generateInvoiceHTML({ invoice, agency }: {
|
|
invoice: {
|
|
number: string; status: string; type: string; issueDate: Date; dueDate: Date;
|
|
netCents: number; taxBp: number; grossCents: number;
|
|
zugferd: boolean; xrechnung: boolean;
|
|
company: { name: string; legalForm: string | null; city: string | null; vatId: string | null };
|
|
lines: { description: string; quantity: number; unitCents: number; totalCents: number }[];
|
|
};
|
|
agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string };
|
|
}): string {
|
|
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
|
const taxPct = (invoice.taxBp / 100).toFixed(0)
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Rechnung ${invoice.number}</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; }
|
|
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
|
.agency h1 { font-size: 18px; font-weight: 700; }
|
|
.agency p { font-size: 12px; color: #666; }
|
|
.invoice-meta { text-align: right; }
|
|
.invoice-meta h2 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
|
.invoice-meta p { font-size: 12px; color: #666; }
|
|
.customer { margin-bottom: 30px; }
|
|
.customer h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
.customer p { font-size: 13px; color: #333; }
|
|
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
|
|
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; border-bottom: 2px solid #111; padding: 8px; }
|
|
td { font-size: 13px; padding: 10px 8px; border-bottom: 1px solid #eee; }
|
|
.right { text-align: right; }
|
|
.totals { width: 300px; margin-left: auto; }
|
|
.totals td { border: none; padding: 6px 8px; font-size: 13px; }
|
|
.totals .total { font-weight: 700; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
|
|
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
|
|
.footer p { margin-bottom: 4px; }
|
|
@media print { body { padding: 0; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="agency">
|
|
<h1>${agency.name}</h1>
|
|
<p>${agency.city}</p>
|
|
<p>USt-IdNr.: ${agency.vatId}</p>
|
|
<p>${agency.registerCourt} ${agency.registerNumber}</p>
|
|
</div>
|
|
<div class="invoice-meta">
|
|
<h2>RECHNUNG</h2>
|
|
<p>Nr.: ${invoice.number}</p>
|
|
<p>Datum: ${fmtDate(invoice.issueDate)}</p>
|
|
<p>Fällig: ${fmtDate(invoice.dueDate)}</p>
|
|
${invoice.zugferd ? '<p style="font-weight:600">✓ ZUGFeRD</p>' : ''}
|
|
${invoice.xrechnung ? '<p style="font-weight:600">✓ XRechnung</p>' : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="customer">
|
|
<h3>Rechnungsempfänger</h3>
|
|
<p><strong>${invoice.company.name}</strong></p>
|
|
${invoice.company.legalForm ? `<p>${invoice.company.legalForm}</p>` : ''}
|
|
${invoice.company.city ? `<p>${invoice.company.city}</p>` : ''}
|
|
${invoice.company.vatId ? `<p>USt-IdNr.: ${invoice.company.vatId}</p>` : ''}
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Beschreibung</th>
|
|
<th class="right">Menge</th>
|
|
<th class="right">Einzelpreis</th>
|
|
<th class="right">Gesamt</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${invoice.lines.map((l) => `
|
|
<tr>
|
|
<td>${l.description}</td>
|
|
<td class="right">${l.quantity}</td>
|
|
<td class="right">${fmtEuro(l.unitCents)}</td>
|
|
<td class="right">${fmtEuro(l.totalCents)}</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
|
|
<table class="totals">
|
|
<tr><td>Netto</td><td class="right">${fmtEuro(invoice.netCents)}</td></tr>
|
|
<tr><td>USt (${taxPct}%)</td><td class="right">${fmtEuro(invoice.grossCents - invoice.netCents)}</td></tr>
|
|
<tr class="total"><td>Gesamt</td><td class="right">${fmtEuro(invoice.grossCents)}</td></tr>
|
|
</table>
|
|
|
|
<div class="footer">
|
|
<p><strong>${agency.name}</strong> · ${agency.city}</p>
|
|
<p>Bank: ${agency.bank}</p>
|
|
<p>BIC: ${agency.bic}</p>
|
|
<p>USt-IdNr.: ${agency.vatId}</p>
|
|
<p>Register: ${agency.registerCourt} ${agency.registerNumber}</p>
|
|
<p style="margin-top:10px">Rechnung Nr. ${invoice.number} vom ${fmtDate(invoice.issueDate)}. Fällig bis ${fmtDate(invoice.dueDate)}.</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|