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:
@@ -5,10 +5,14 @@ import {
|
||||
invoiceTotalWithVat,
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
getInvoice,
|
||||
listInvoices,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../schemas/invoices.schema";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
@@ -168,7 +172,10 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
||||
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
|
||||
);
|
||||
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Text na PDF");
|
||||
|
||||
@@ -182,6 +189,94 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("creates, returns and replaces CZ/EN sections in position order", async () => {
|
||||
const parsed = CreateInvoiceSchema.parse({
|
||||
billing_text: "Sekce test",
|
||||
sections: [
|
||||
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
|
||||
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
|
||||
],
|
||||
});
|
||||
const draft = await createInvoice(parsed);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
const detail = await getInvoice(draft.id);
|
||||
expect(detail?.sections?.length).toBe(2);
|
||||
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
|
||||
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
|
||||
expect(detail?.sections?.[0].position).toBe(0);
|
||||
expect(detail?.sections?.[1].position).toBe(1);
|
||||
|
||||
// Full-replace on update (same model as items).
|
||||
const upd = UpdateInvoiceSchema.parse({
|
||||
sections: [
|
||||
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
|
||||
],
|
||||
});
|
||||
const res = await updateInvoice(draft.id, upd);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Jediná");
|
||||
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
|
||||
});
|
||||
|
||||
it("sections survive an items-only update untouched (absent = keep)", async () => {
|
||||
const draft = await createInvoice(
|
||||
CreateInvoiceSchema.parse({
|
||||
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
|
||||
}),
|
||||
);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({
|
||||
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Drží");
|
||||
expect(after?.items?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("silently strips the dropped `notes` key from legacy payloads", async () => {
|
||||
// The printed-notes column was dropped (notes are internal-only now) —
|
||||
// a legacy client still sending `notes` must not 500 or write anything.
|
||||
const parsedCreate = CreateInvoiceSchema.parse({
|
||||
notes: "<p>stará poznámka</p>",
|
||||
internal_notes: "<p>interní</p>",
|
||||
});
|
||||
expect("notes" in parsedCreate).toBe(false);
|
||||
|
||||
const draft = await createInvoice(parsedCreate);
|
||||
invoiceIds.push(draft.id);
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.internal_notes).toBe("<p>interní</p>");
|
||||
|
||||
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
|
||||
expect("notes" in parsedUpdate).toBe(false);
|
||||
const res = await updateInvoice(draft.id, parsedUpdate);
|
||||
expect("error" in res).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Month filter boundaries — issue_date is @db.Date */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -43,6 +43,14 @@ export interface InvoiceItem {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
notes?: string;
|
||||
internal_notes?: string | null;
|
||||
payment_method?: string;
|
||||
variable_symbol?: string;
|
||||
constant_symbol?: string;
|
||||
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
|
||||
bank_account?: string;
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
sections?: InvoiceSection[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
|
||||
@@ -24,6 +24,9 @@ import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -198,7 +201,7 @@ interface InvoiceForm {
|
||||
constant_symbol: string;
|
||||
issued_by: string;
|
||||
billing_text: string;
|
||||
notes: string;
|
||||
internal_notes: string;
|
||||
language: string;
|
||||
bank_account_id: number | string;
|
||||
bank_name: string;
|
||||
@@ -540,7 +543,7 @@ export default function InvoiceDetail() {
|
||||
constant_symbol: "0308",
|
||||
issued_by: user?.fullName || "",
|
||||
billing_text: "",
|
||||
notes: "",
|
||||
internal_notes: "",
|
||||
language: "cs",
|
||||
bank_account_id: "",
|
||||
bank_name: "",
|
||||
@@ -550,6 +553,9 @@ export default function InvoiceDetail() {
|
||||
}));
|
||||
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
||||
// shared editor with offers/issued orders).
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_key: "inv-1",
|
||||
@@ -626,7 +632,6 @@ export default function InvoiceDetail() {
|
||||
});
|
||||
|
||||
// ─── Edit mode state ───
|
||||
const [notes, setNotes] = useState("");
|
||||
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
||||
const [statusConfirm, setStatusConfirm] = useState<{
|
||||
show: boolean;
|
||||
@@ -696,7 +701,7 @@ export default function InvoiceDetail() {
|
||||
constant_symbol: inv.constant_symbol || "0308",
|
||||
issued_by: inv.issued_by || "",
|
||||
billing_text: inv.billing_text || "",
|
||||
notes: inv.notes || "",
|
||||
internal_notes: inv.internal_notes || "",
|
||||
language: inv.language || "cs",
|
||||
bank_account_id: matchedBankId,
|
||||
bank_name: inv.bank_name || "",
|
||||
@@ -705,7 +710,6 @@ export default function InvoiceDetail() {
|
||||
bank_account: inv.bank_account || "",
|
||||
};
|
||||
setForm(formData);
|
||||
setNotes(inv.notes || "");
|
||||
setInvoiceNumber(inv.invoice_number || "");
|
||||
|
||||
// Calculate dueDays from existing dates
|
||||
@@ -741,10 +745,22 @@ export default function InvoiceDetail() {
|
||||
setItems(mappedItems);
|
||||
}
|
||||
|
||||
// Populate sections from existing invoice
|
||||
const mappedSections =
|
||||
Array.isArray(inv.sections) && inv.sections.length > 0
|
||||
? inv.sections.map((s) => ({
|
||||
title: s.title || "",
|
||||
title_cz: s.title_cz || "",
|
||||
content: s.content || "",
|
||||
}))
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
|
||||
// Capture initial snapshot for dirty-checking
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form: formData,
|
||||
items: mappedItems,
|
||||
sections: mappedSections,
|
||||
});
|
||||
|
||||
setDataReady(true);
|
||||
@@ -841,13 +857,15 @@ export default function InvoiceDetail() {
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
// Create mode: captured on the first render after sync effects populate the form.
|
||||
if (dataReady && !initialSnapshotRef.current) {
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
}
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!initialSnapshotRef.current) return false;
|
||||
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
|
||||
}, [form, items]);
|
||||
return (
|
||||
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
||||
);
|
||||
}, [form, items, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
@@ -1005,6 +1023,12 @@ export default function InvoiceDetail() {
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
position: i,
|
||||
})),
|
||||
sections: sections.map((s, i) => ({
|
||||
title: s.title,
|
||||
title_cz: s.title_cz,
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
};
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live invoice sends no status — the backend keeps
|
||||
@@ -1017,7 +1041,7 @@ export default function InvoiceDetail() {
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
if (!isEdit) {
|
||||
navigate(`/invoices/${data.invoice_id}`);
|
||||
}
|
||||
@@ -1148,7 +1172,13 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
Faktura
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{documentNumberLabel(invoice.invoice_number)}
|
||||
</Box>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
@@ -1281,11 +1311,6 @@ export default function InvoiceDetail() {
|
||||
<Field label="Variabilní symbol">
|
||||
<Typography variant="body2">{invoice.invoice_number}</Typography>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<Typography variant="body2">
|
||||
{invoice.issued_by || "—"}
|
||||
</Typography>
|
||||
</Field>
|
||||
</Box>
|
||||
{invoice.paid_date && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
@@ -1597,14 +1622,55 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes (read-only) */}
|
||||
{/* Obsah sections (read-only) — shown only when any section has content */}
|
||||
{(invoice.sections ?? []).some(
|
||||
(s) =>
|
||||
(s.title || "").trim() ||
|
||||
(s.title_cz || "").trim() ||
|
||||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||
) && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Obsah
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{(invoice.sections ?? []).map((s, idx) => (
|
||||
<Box key={s.id ?? idx}>
|
||||
{((invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title) ||
|
||||
"") && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title}
|
||||
</Typography>
|
||||
)}
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Internal notes (read-only, never printed) */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
Interní poznámky
|
||||
</Typography>
|
||||
{notes && notes.trim() && notes !== "<p><br></p>" ? (
|
||||
{invoice.internal_notes &&
|
||||
invoice.internal_notes.trim() &&
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -1659,43 +1725,44 @@ export default function InvoiceDetail() {
|
||||
Zpět
|
||||
</Button>
|
||||
<Box>
|
||||
{isEdit && invoice ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? "Faktura" : "Nová faktura"}
|
||||
{/* Edit mode: a draft has no number yet → show "Koncept".
|
||||
Create mode: show the previewed next-number when available. */}
|
||||
{(isEdit || invoiceNumber) && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{isEdit
|
||||
? documentNumberLabel(invoice?.invoice_number)
|
||||
: invoiceNumber}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{isEdit && invoice && (
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
color={statusColor(INVOICE_STATUS, invoice.status)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="h4">
|
||||
Nová faktura{" "}
|
||||
{invoiceNumber && (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
({invoiceNumber})
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{fromOrderId && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Z objednávky
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{!isEdit && fromOrderId && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Z objednávky
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1866,37 +1933,19 @@ export default function InvoiceDetail() {
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Číslo faktury">
|
||||
<TextField
|
||||
value={invoiceNumber}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<TextField
|
||||
value={form.issued_by}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
{/* Číslo faktury and Vystavil are not form fields anymore: the
|
||||
number lives in the page header (deferred numbering — "Koncept"
|
||||
until finalize) and issued_by is auto-filled from the logged-in
|
||||
user and still submitted with the payload (the PDF prints it). */}
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Text fakturace (na PDF)">
|
||||
<TextField
|
||||
@@ -2242,16 +2291,26 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{/* Rich-text PDF sections — printed after the items (like orders) */}
|
||||
<SectionsEditor
|
||||
sections={sections}
|
||||
onChange={setSections}
|
||||
readOnly={false}
|
||||
title="Obsah"
|
||||
language={form.language}
|
||||
/>
|
||||
|
||||
{/* Internal notes */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
</Typography>
|
||||
<RichEditor
|
||||
value={form.notes}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
||||
placeholder="Poznámky zobrazené na faktuře..."
|
||||
/>
|
||||
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
|
||||
<RichEditor
|
||||
value={form.internal_notes}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, internal_notes: val }))
|
||||
}
|
||||
placeholder="Interní poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
logoDataUriFromSettings,
|
||||
buildPdfHeaderTemplate,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -232,6 +234,47 @@ const translations: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
|
||||
function stripTags(html: string | null | undefined): string {
|
||||
return (html || "").replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page footer for the invoice PDF, rendered by Puppeteer in the
|
||||
* bottom page margin (mirrors buildIssuedOrderPdfFooter): "Vystavil: <name>"
|
||||
* left, "Strana X z Y" / "Page X of Y" (by document language) centered.
|
||||
* Styles must stay inline and the font-size explicit — Chromium defaults
|
||||
* footer text to 0.
|
||||
*/
|
||||
export function buildInvoicePdfFooter(
|
||||
lang: string,
|
||||
issuerLine: string,
|
||||
): string {
|
||||
const t = translations[lang] || translations.cs;
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page header for the invoice PDF — the unified red-accent
|
||||
* family header (shared with issued orders): logo left, red
|
||||
* "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page.
|
||||
* The body's own header is print-hidden so page 1 doesn't show it twice.
|
||||
*/
|
||||
export function buildInvoicePdfHeader(
|
||||
lang: string,
|
||||
logoDataUri: string,
|
||||
invoiceNumber: string,
|
||||
): string {
|
||||
const t = translations[lang] || translations.cs;
|
||||
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`);
|
||||
}
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function invoicesPdfRoutes(
|
||||
@@ -264,6 +307,11 @@ export default async function invoicesPdfRoutes(
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
const sections = await prisma.invoice_sections.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
@@ -300,16 +348,12 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// Logo as a data URI — used by the body header (screen/HTML view) AND
|
||||
// the Puppeteer headerTemplate (templates can only load data: URLs).
|
||||
const logoDataUri = logoDataUriFromSettings(settings);
|
||||
const logoImg = logoDataUri
|
||||
? `<img src="${logoDataUri}" class="logo" />`
|
||||
: "";
|
||||
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
@@ -472,17 +516,41 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
// ── Sections ("Obsah") — flow INLINE right after the items/totals
|
||||
// block (mirrors issued orders): no separate page, long content
|
||||
// paginates naturally and the Puppeteer footer template stamps every
|
||||
// page. Suppressed entirely when every section is empty (tag-stripped
|
||||
// check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
|
||||
// the Czech title when present; en (or a missing Czech title) falls
|
||||
// back to the EN title.
|
||||
const resolveSectionTitle = (s: {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
}): string =>
|
||||
lang === "cs" && (s.title_cz || "").trim()
|
||||
? s.title_cz || ""
|
||||
: s.title || "";
|
||||
// Visibility must use the SAME per-language title rule as the render
|
||||
// loop — otherwise a section with only the other language's title
|
||||
// would count as content and produce an empty sections block.
|
||||
const visibleSections = sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
||||
);
|
||||
let sectionsHtml = "";
|
||||
if (visibleSections.length > 0) {
|
||||
sectionsHtml += '<div class="scope-sections">';
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
sectionsHtml += '<div class="scope-section">';
|
||||
if (title.trim())
|
||||
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
sectionsHtml += "</div>";
|
||||
}
|
||||
sectionsHtml += "</div>";
|
||||
}
|
||||
|
||||
// Quill indent CSS
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
@@ -496,7 +564,11 @@ export default async function invoicesPdfRoutes(
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 8mm 12mm 10mm 12mm;
|
||||
/* Chromium uses THESE margins for layout (they override Puppeteer's
|
||||
margin option) — they must match the header/footer template space:
|
||||
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
|
||||
Strana X z Y). Same geometry as the issued-order PDF. */
|
||||
margin: 32mm 12mm 18mm 12mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -509,11 +581,19 @@ export default async function invoicesPdfRoutes(
|
||||
.invoice-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(297mm - 27mm);
|
||||
/* Printable area with the Puppeteer header (32mm top) + footer (18mm
|
||||
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
|
||||
min-height: calc(297mm - 51mm);
|
||||
}
|
||||
.invoice-content { flex: 1 1 auto; }
|
||||
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
|
||||
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
|
||||
at the page bottom; when the content overflows, break-inside moves the
|
||||
whole block to the next page as one unit instead of stranding the notice
|
||||
on the previous page. */
|
||||
.invoice-footer {
|
||||
flex-shrink: 0;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.accent { color: #de3a3a; }
|
||||
@@ -725,13 +805,8 @@ export default async function invoicesPdfRoutes(
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
/* Vystavil */
|
||||
.issued-by {
|
||||
font-size: 9pt;
|
||||
margin: 2mm 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.issued-by .lbl { font-weight: 600; }
|
||||
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
|
||||
every page — same as the issued-order PDF; no body block. */
|
||||
|
||||
/* Upozorneni */
|
||||
.notice {
|
||||
@@ -797,32 +872,42 @@ export default async function invoicesPdfRoutes(
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Poznamky */
|
||||
.invoice-notes {
|
||||
margin-top: 4mm;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||
stranu (mirrors issued-orders-pdf). */
|
||||
.scope-sections {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
.invoice-notes-label {
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
.scope-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 3mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.scope-section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
.section-content {
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
.invoice-notes-content h2 { font-size: 18px; }
|
||||
.invoice-notes-content h3 { font-size: 16px; }
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
.section-content h2 { font-size: 18px; }
|
||||
.section-content h3 { font-size: 16px; }
|
||||
.section-content h4 { font-size: 15px; }
|
||||
.section-content p { margin: 0 0 0.4em 0; }
|
||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
@@ -833,6 +918,10 @@ ${indentCSS}
|
||||
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
|
||||
page — hide the body copy so page 1 doesn't show it twice. The screen
|
||||
(HTML preview) keeps the body header. */
|
||||
.invoice-header { display: none; }
|
||||
}
|
||||
@media screen {
|
||||
html { background: #525659; }
|
||||
@@ -944,16 +1033,12 @@ ${indentCSS}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${notesHtml}
|
||||
${sectionsHtml}
|
||||
|
||||
</div><!-- /.invoice-content -->
|
||||
<div class="invoice-footer">
|
||||
|
||||
<!-- Vystavil -->
|
||||
<div class="issued-by">
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
||||
</div>
|
||||
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
|
||||
|
||||
<!-- Upozorneni -->
|
||||
<div class="notice">
|
||||
@@ -1018,7 +1103,20 @@ ${indentCSS}
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
// Per-page "Vystavil + Strana X z Y" footer (same as issued
|
||||
// orders); the supplier e-mail rides along on the left (it used to
|
||||
// print under the body Vystavil block).
|
||||
const issuerLine = `${invoice.issued_by || ""}${
|
||||
suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : ""
|
||||
}`;
|
||||
const pdfBuffer = await htmlToPdf(html, {
|
||||
headerTemplate: buildInvoicePdfHeader(
|
||||
lang,
|
||||
logoDataUri,
|
||||
invoice.invoice_number || "",
|
||||
),
|
||||
footerTemplate: buildInvoicePdfFooter(lang, issuerLine),
|
||||
});
|
||||
nasInvoicesManager.saveIssuedPdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
logoDataUriFromSettings,
|
||||
buildPdfHeaderTemplate,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -226,13 +228,28 @@ export function buildIssuedOrderPdfFooter(
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page header for the issued-order PDF — the unified
|
||||
* red-accent family header (shared with invoices): logo left, red
|
||||
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
|
||||
* own header is print-hidden so page 1 doesn't show it twice.
|
||||
*/
|
||||
export function buildIssuedOrderPdfHeader(
|
||||
lang: Lang,
|
||||
logoDataUri: string,
|
||||
poNumber: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
|
||||
}
|
||||
|
||||
export function renderIssuedOrderHtml(
|
||||
order: IssuedOrderPdfData,
|
||||
items: IssuedOrderPdfItem[],
|
||||
@@ -249,17 +266,11 @@ export function renderIssuedOrderHtml(
|
||||
const currency = order.currency || "CZK";
|
||||
const poNumber = escapeHtml(order.po_number || "");
|
||||
|
||||
// Logo embedding (same logic as the confirmation template).
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
|
||||
const logoDataUri = logoDataUriFromSettings(settings);
|
||||
const logoImg = logoDataUri
|
||||
? `<img src="${logoDataUri}" class="logo" />`
|
||||
: "";
|
||||
|
||||
// PO direction: our company (settings) = Odběratel (buyer);
|
||||
// the sklad_suppliers record = Dodavatel (supplier).
|
||||
@@ -350,7 +361,11 @@ export function renderIssuedOrderHtml(
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 8mm 12mm 10mm 12mm;
|
||||
/* Chromium uses THESE margins for layout (they override Puppeteer's
|
||||
margin option) — they must match the header/footer template space:
|
||||
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
|
||||
Strana X z Y). Same geometry as the invoice PDF. */
|
||||
margin: 32mm 12mm 18mm 12mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -621,6 +636,10 @@ ${indentCSS}
|
||||
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
|
||||
page — hide the body copy so page 1 doesn't show it twice. The screen
|
||||
(HTML preview) keeps the body header. */
|
||||
.invoice-header { display: none; }
|
||||
}
|
||||
@media screen {
|
||||
html { background: #525659; }
|
||||
@@ -795,6 +814,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
: new Date();
|
||||
nasOrdersManager.cleanIssued(order.po_number);
|
||||
const pdfBuffer = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
nasOrdersManager.saveIssuedPdf(
|
||||
@@ -807,6 +831,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
|
||||
const pdf = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
import {
|
||||
renderIssuedOrderHtml,
|
||||
buildIssuedOrderPdfFooter,
|
||||
buildIssuedOrderPdfHeader,
|
||||
} from "./issued-orders-pdf";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
|
||||
@@ -310,6 +312,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
sections,
|
||||
);
|
||||
const pdf = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
const InvoiceItemSchema = z.object({
|
||||
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
// `notes` was dropped (printed-notes block removed — free-form PDF content
|
||||
// lives in the sections); legacy clients still sending it are silently
|
||||
// stripped by z.object. internal_notes is never printed.
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
|
||||
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateInvoiceSchema = z.object({
|
||||
@@ -61,10 +67,10 @@ export const UpdateInvoiceSchema = z.object({
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
tax_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ export interface HtmlToPdfOptions {
|
||||
* Chromium's default date/title header.
|
||||
*/
|
||||
footerTemplate?: string;
|
||||
/**
|
||||
* Chromium headerTemplate HTML rendered in the top margin of EVERY page
|
||||
* (same rules as footerTemplate: inline styles, explicit font-size; images
|
||||
* must be data: URLs). When set, the top margin grows to 32mm to make room
|
||||
* — the document body must NOT render its own header then, or page 1 shows
|
||||
* it twice. NOTE: Chromium lays pages out by the document's CSS @page
|
||||
* margins when present (they override this option) — keep them in sync.
|
||||
*/
|
||||
headerTemplate?: string;
|
||||
}
|
||||
|
||||
export async function htmlToPdf(
|
||||
@@ -95,16 +104,16 @@ export async function htmlToPdf(
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: "10mm",
|
||||
top: options.headerTemplate ? "32mm" : "10mm",
|
||||
bottom: options.footerTemplate ? "18mm" : "10mm",
|
||||
left: "10mm",
|
||||
right: "10mm",
|
||||
},
|
||||
...(options.footerTemplate
|
||||
...(options.footerTemplate || options.headerTemplate
|
||||
? {
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: "<span></span>",
|
||||
footerTemplate: options.footerTemplate,
|
||||
headerTemplate: options.headerTemplate || "<span></span>",
|
||||
footerTemplate: options.footerTemplate || "<span></span>",
|
||||
}
|
||||
: {}),
|
||||
timeout: 15_000,
|
||||
|
||||
@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Company logo from company_settings.logo_data as a data: URI (mime sniffed
|
||||
* from magic bytes). Data URIs are the ONLY image source Chromium loads in
|
||||
* header/footer templates; the body templates reuse the same URI.
|
||||
*/
|
||||
export function logoDataUriFromSettings(
|
||||
settings: Record<string, unknown> | null,
|
||||
): string {
|
||||
if (!settings?.logo_data) return "";
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
return `data:${escapeHtml(mime)};base64,${buf.toString("base64")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified repeating per-page header for the red-accent PDF family (invoices +
|
||||
* issued orders), rendered by Puppeteer in the top page margin: company logo
|
||||
* left (max 22mm tall — same size as the documents' original body header),
|
||||
* red bold heading right, 2pt #de3a3a rule underneath.
|
||||
*
|
||||
* Contract shared with the footers: inline styles only, explicit font-size
|
||||
* (Chromium defaults template text to 0), images as data: URIs only, padding
|
||||
* matched to the documents' 12mm @page side margins. The @page top margin
|
||||
* must be 32mm to make room (htmlToPdf grows it when headerTemplate is set,
|
||||
* but Chromium lays out by the CSS @page margins — keep them in sync).
|
||||
*/
|
||||
export function buildPdfHeaderTemplate(
|
||||
logoDataUri: string,
|
||||
heading: string,
|
||||
): string {
|
||||
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
||||
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
||||
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
|
||||
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
|
||||
|
||||
Reference in New Issue
Block a user