feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer
Invoices now mirror the issued-orders document model: - New invoice_sections table (CZ/EN rich-text "Obsah") edited via the shared SectionsEditor, printed inline right after the items on the PDF. Full-replace on update, same transaction as items. - Printed notes dropped: the notes column is removed (migration merges existing content into internal_notes first); the form field is now "Interni poznamky", never printed. Legacy payloads sending notes are silently stripped. - Form cleanup: Cislo faktury and Vystavil fields removed (number lives in the header, issued_by auto-fills); page header title restyled to the orders/offers pattern (number span + status chip). - Unified per-page PDF header for the red-accent family: shared buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red rule) rendered by a Puppeteer headerTemplate on EVERY page of both invoices and issued orders (incl. the /file fallback render); body headers are print-hidden. htmlToPdf gained the headerTemplate option. - Footer parity: invoices get the per-page "Vystavil + Strana X z Y" footer; the invoice bottom block (notice + QR/VAT recap + Prevzal) is break-inside: avoid so a page break can never split it. - @page margins now match the template space (32mm top, 18mm bottom) - Chromium lays out by CSS @page margins, which also fixes issued orders' content running into the 18mm footer zone on full pages. BREAKING CHANGE: invoices.notes column dropped (data merged into internal_notes); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,12 @@ interface InvoiceItemInput {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface InvoiceSectionInput {
|
||||
title?: string | null;
|
||||
title_cz?: string | null;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
|
||||
* loosely typed because the payload arrives validated-but-coerced from the Zod
|
||||
@@ -62,10 +68,10 @@ export interface InvoiceInput {
|
||||
issued_by?: string | null;
|
||||
billing_text?: string | null;
|
||||
language?: string;
|
||||
notes?: string | null;
|
||||
internal_notes?: string | null;
|
||||
paid_date?: string | null;
|
||||
items?: InvoiceItemInput[];
|
||||
sections?: InvoiceSectionInput[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -469,14 +475,17 @@ export async function getInvoice(id: number) {
|
||||
include: {
|
||||
customers: true,
|
||||
invoice_items: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
invoice_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
orders: { select: { id: true, order_number: true } },
|
||||
},
|
||||
});
|
||||
if (!invoice) return null;
|
||||
const { invoice_items, ...rest } = invoice;
|
||||
const { invoice_items, invoice_sections, ...rest } = invoice;
|
||||
return {
|
||||
...rest,
|
||||
items: invoice_items,
|
||||
sections: invoice_sections,
|
||||
customer: invoice.customers,
|
||||
customer_name: invoice.customers?.name || null,
|
||||
order_number: invoice.orders?.order_number || null,
|
||||
@@ -499,48 +508,69 @@ export async function createInvoice(body: InvoiceInput) {
|
||||
explicit ??
|
||||
(status === "draft" ? null : (await generateInvoiceNumber()).number);
|
||||
|
||||
const invoice = await prisma.invoices.create({
|
||||
data: {
|
||||
invoice_number: invoiceNumber,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
payment_method: body.payment_method ? String(body.payment_method) : null,
|
||||
constant_symbol: body.constant_symbol
|
||||
? String(body.constant_symbol)
|
||||
: null,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
|
||||
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
|
||||
bank_account: body.bank_account ? String(body.bank_account) : null,
|
||||
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
|
||||
due_date: body.due_date ? new Date(String(body.due_date)) : null,
|
||||
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
billing_text: body.billing_text ? String(body.billing_text) : null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await prisma.invoice_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
invoice_id: invoice.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
// Header + items + sections are ONE write — a failure mid-way must not
|
||||
// leave a headerless/partial invoice behind.
|
||||
const invoice = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.invoices.create({
|
||||
data: {
|
||||
invoice_number: invoiceNumber,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
payment_method: body.payment_method
|
||||
? String(body.payment_method)
|
||||
: null,
|
||||
constant_symbol: body.constant_symbol
|
||||
? String(body.constant_symbol)
|
||||
: null,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
|
||||
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
|
||||
bank_account: body.bank_account ? String(body.bank_account) : null,
|
||||
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
|
||||
due_date: body.due_date ? new Date(String(body.due_date)) : null,
|
||||
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
billing_text: body.billing_text ? String(body.billing_text) : null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.invoice_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
invoice_id: created.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.invoice_sections.createMany({
|
||||
data: body.sections.map((s, i) => ({
|
||||
invoice_id: created.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return invoice;
|
||||
}
|
||||
@@ -601,10 +631,8 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
||||
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||
}
|
||||
|
||||
// Notes editable in draft/issued/overdue
|
||||
// Internal notes editable in draft/issued/overdue (never printed)
|
||||
if (editable) {
|
||||
if (body.notes !== undefined)
|
||||
data.notes = body.notes ? String(body.notes) : null;
|
||||
if (body.internal_notes !== undefined)
|
||||
data.internal_notes = body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
@@ -644,22 +672,39 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
||||
await prisma.invoices.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
// Allow items update while editable (draft/issued/overdue).
|
||||
if (editable && Array.isArray(body.items)) {
|
||||
// Allow items/sections full-replace while editable (draft/issued/overdue) —
|
||||
// both in ONE transaction so a failure can't leave a half-replaced document.
|
||||
const replaceItems = editable && Array.isArray(body.items);
|
||||
const replaceSections = editable && Array.isArray(body.sections);
|
||||
if (replaceItems || replaceSections) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
||||
await tx.invoice_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
invoice_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
if (replaceItems) {
|
||||
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
||||
await tx.invoice_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
invoice_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (replaceSections) {
|
||||
await tx.invoice_sections.deleteMany({ where: { invoice_id: id } });
|
||||
await tx.invoice_sections.createMany({
|
||||
data: body.sections!.map((s, i) => ({
|
||||
invoice_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user