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:
@@ -0,0 +1,32 @@
|
|||||||
|
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
|
||||||
|
-- the printed notes block; notes become internal-only. Existing printed notes
|
||||||
|
-- are preserved by merging them into internal_notes before the column drop
|
||||||
|
-- (both are Quill HTML — concatenation is valid markup).
|
||||||
|
|
||||||
|
-- Preserve data: move printed notes into internal_notes
|
||||||
|
UPDATE `invoices`
|
||||||
|
SET `internal_notes` = CASE
|
||||||
|
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
|
||||||
|
ELSE CONCAT(`internal_notes`, `notes`)
|
||||||
|
END
|
||||||
|
WHERE `notes` IS NOT NULL AND `notes` <> '';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` DROP COLUMN `notes`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `invoice_sections` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`invoice_id` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
`title` VARCHAR(500) NULL,
|
||||||
|
`title_cz` VARCHAR(500) NULL,
|
||||||
|
`content` TEXT NULL,
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `invoice_sections_invoice_id`(`invoice_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -234,11 +234,11 @@ model invoices {
|
|||||||
issued_by String? @db.VarChar(255)
|
issued_by String? @db.VarChar(255)
|
||||||
billing_text String? @db.VarChar(500)
|
billing_text String? @db.VarChar(500)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
notes String? @db.Text
|
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
invoice_items invoice_items[]
|
invoice_items invoice_items[]
|
||||||
|
invoice_sections invoice_sections[]
|
||||||
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
|
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
|
||||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
|
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
|
||||||
|
|
||||||
@@ -249,6 +249,19 @@ model invoices {
|
|||||||
@@index([order_id], map: "order_id")
|
@@index([order_id], map: "order_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model invoice_sections {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
invoice_id Int
|
||||||
|
position Int? @default(0)
|
||||||
|
title String? @db.VarChar(500)
|
||||||
|
title_cz String? @db.VarChar(500)
|
||||||
|
content String? @db.Text
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
|
||||||
|
|
||||||
|
@@index([invoice_id], map: "invoice_sections_invoice_id")
|
||||||
|
}
|
||||||
|
|
||||||
model item_templates {
|
model item_templates {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String? @db.VarChar(255)
|
name String? @db.VarChar(255)
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import {
|
|||||||
invoiceTotalWithVat,
|
invoiceTotalWithVat,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
updateInvoice,
|
updateInvoice,
|
||||||
|
getInvoice,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
getInvoiceListTotals,
|
getInvoiceListTotals,
|
||||||
} from "../services/invoices.service";
|
} 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
|
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
// `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);
|
invoiceIds.push(draft.id);
|
||||||
|
|
||||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
// 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 } });
|
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||||
expect(row?.billing_text).toBe("Text na PDF");
|
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 */
|
/* Month filter boundaries — issue_date is @db.Date */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export interface InvoiceItem {
|
|||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoiceSection {
|
||||||
|
id?: number;
|
||||||
|
title: string | null;
|
||||||
|
title_cz: string | null;
|
||||||
|
content: string | null;
|
||||||
|
position?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceDetail {
|
export interface InvoiceDetail {
|
||||||
id: number;
|
id: number;
|
||||||
invoice_number: string;
|
invoice_number: string;
|
||||||
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
|
|||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
exchange_rate: string;
|
exchange_rate: string;
|
||||||
notes?: string;
|
internal_notes?: string | null;
|
||||||
payment_method?: string;
|
payment_method?: string;
|
||||||
variable_symbol?: string;
|
variable_symbol?: string;
|
||||||
constant_symbol?: string;
|
constant_symbol?: string;
|
||||||
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
|
|||||||
bank_account?: string;
|
bank_account?: string;
|
||||||
bank_account_id?: number | null;
|
bank_account_id?: number | null;
|
||||||
items?: InvoiceItem[];
|
items?: InvoiceItem[];
|
||||||
|
sections?: InvoiceSection[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
vat_amount: number;
|
vat_amount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import { useAlert } from "../context/AlertContext";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
|
import SectionsEditor, {
|
||||||
|
type DocumentSection,
|
||||||
|
} from "../components/document/SectionsEditor";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -198,7 +201,7 @@ interface InvoiceForm {
|
|||||||
constant_symbol: string;
|
constant_symbol: string;
|
||||||
issued_by: string;
|
issued_by: string;
|
||||||
billing_text: string;
|
billing_text: string;
|
||||||
notes: string;
|
internal_notes: string;
|
||||||
language: string;
|
language: string;
|
||||||
bank_account_id: number | string;
|
bank_account_id: number | string;
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
@@ -540,7 +543,7 @@ export default function InvoiceDetail() {
|
|||||||
constant_symbol: "0308",
|
constant_symbol: "0308",
|
||||||
issued_by: user?.fullName || "",
|
issued_by: user?.fullName || "",
|
||||||
billing_text: "",
|
billing_text: "",
|
||||||
notes: "",
|
internal_notes: "",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
bank_account_id: "",
|
bank_account_id: "",
|
||||||
bank_name: "",
|
bank_name: "",
|
||||||
@@ -550,6 +553,9 @@ export default function InvoiceDetail() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const [dueDays, setDueDays] = useState(14);
|
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[]>(() => [
|
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||||
{
|
{
|
||||||
_key: "inv-1",
|
_key: "inv-1",
|
||||||
@@ -626,7 +632,6 @@ export default function InvoiceDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Edit mode state ───
|
// ─── Edit mode state ───
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
||||||
const [statusConfirm, setStatusConfirm] = useState<{
|
const [statusConfirm, setStatusConfirm] = useState<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -696,7 +701,7 @@ export default function InvoiceDetail() {
|
|||||||
constant_symbol: inv.constant_symbol || "0308",
|
constant_symbol: inv.constant_symbol || "0308",
|
||||||
issued_by: inv.issued_by || "",
|
issued_by: inv.issued_by || "",
|
||||||
billing_text: inv.billing_text || "",
|
billing_text: inv.billing_text || "",
|
||||||
notes: inv.notes || "",
|
internal_notes: inv.internal_notes || "",
|
||||||
language: inv.language || "cs",
|
language: inv.language || "cs",
|
||||||
bank_account_id: matchedBankId,
|
bank_account_id: matchedBankId,
|
||||||
bank_name: inv.bank_name || "",
|
bank_name: inv.bank_name || "",
|
||||||
@@ -705,7 +710,6 @@ export default function InvoiceDetail() {
|
|||||||
bank_account: inv.bank_account || "",
|
bank_account: inv.bank_account || "",
|
||||||
};
|
};
|
||||||
setForm(formData);
|
setForm(formData);
|
||||||
setNotes(inv.notes || "");
|
|
||||||
setInvoiceNumber(inv.invoice_number || "");
|
setInvoiceNumber(inv.invoice_number || "");
|
||||||
|
|
||||||
// Calculate dueDays from existing dates
|
// Calculate dueDays from existing dates
|
||||||
@@ -741,10 +745,22 @@ export default function InvoiceDetail() {
|
|||||||
setItems(mappedItems);
|
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
|
// Capture initial snapshot for dirty-checking
|
||||||
initialSnapshotRef.current = JSON.stringify({
|
initialSnapshotRef.current = JSON.stringify({
|
||||||
form: formData,
|
form: formData,
|
||||||
items: mappedItems,
|
items: mappedItems,
|
||||||
|
sections: mappedSections,
|
||||||
});
|
});
|
||||||
|
|
||||||
setDataReady(true);
|
setDataReady(true);
|
||||||
@@ -841,13 +857,15 @@ export default function InvoiceDetail() {
|
|||||||
// Edit mode: captured inside the sync effect from raw query data.
|
// Edit mode: captured inside the sync effect from raw query data.
|
||||||
// Create mode: captured on the first render after sync effects populate the form.
|
// Create mode: captured on the first render after sync effects populate the form.
|
||||||
if (dataReady && !initialSnapshotRef.current) {
|
if (dataReady && !initialSnapshotRef.current) {
|
||||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (!initialSnapshotRef.current) return false;
|
if (!initialSnapshotRef.current) return false;
|
||||||
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
|
return (
|
||||||
}, [form, items]);
|
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
||||||
|
);
|
||||||
|
}, [form, items, sections]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
@@ -1005,6 +1023,12 @@ export default function InvoiceDetail() {
|
|||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
position: i,
|
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
|
// 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
|
// / 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);
|
const data = await saveMutation.mutateAsync(payload);
|
||||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
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) {
|
if (!isEdit) {
|
||||||
navigate(`/invoices/${data.invoice_id}`);
|
navigate(`/invoices/${data.invoice_id}`);
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1172,13 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<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>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||||
@@ -1281,11 +1311,6 @@ export default function InvoiceDetail() {
|
|||||||
<Field label="Variabilní symbol">
|
<Field label="Variabilní symbol">
|
||||||
<Typography variant="body2">{invoice.invoice_number}</Typography>
|
<Typography variant="body2">{invoice.invoice_number}</Typography>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Vystavil">
|
|
||||||
<Typography variant="body2">
|
|
||||||
{invoice.issued_by || "—"}
|
|
||||||
</Typography>
|
|
||||||
</Field>
|
|
||||||
</Box>
|
</Box>
|
||||||
{invoice.paid_date && (
|
{invoice.paid_date && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -1597,14 +1622,55 @@ export default function InvoiceDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</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 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
Veřejné poznámky na faktuře
|
Obsah
|
||||||
</Typography>
|
</Typography>
|
||||||
{notes && notes.trim() && notes !== "<p><br></p>" ? (
|
<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
|
<RichTextView
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
|
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 }}>
|
||||||
|
Interní poznámky
|
||||||
|
</Typography>
|
||||||
|
{invoice.internal_notes &&
|
||||||
|
invoice.internal_notes.trim() &&
|
||||||
|
invoice.internal_notes !== "<p><br></p>" ? (
|
||||||
|
<RichTextView
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -1659,7 +1725,6 @@ export default function InvoiceDetail() {
|
|||||||
Zpět
|
Zpět
|
||||||
</Button>
|
</Button>
|
||||||
<Box>
|
<Box>
|
||||||
{isEdit && invoice ? (
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -1669,24 +1734,28 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
{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>
|
</Typography>
|
||||||
|
{isEdit && invoice && (
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||||
color={statusColor(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>
|
</Box>
|
||||||
{fromOrderId && (
|
{!isEdit && fromOrderId && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
@@ -1695,8 +1764,6 @@ export default function InvoiceDetail() {
|
|||||||
Z objednávky
|
Z objednávky
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={headerActionsSx}>
|
<Box sx={headerActionsSx}>
|
||||||
@@ -1866,20 +1933,10 @@ export default function InvoiceDetail() {
|
|||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
Základní údaje
|
Základní údaje
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
{/* Číslo faktury and Vystavil are not form fields anymore: the
|
||||||
sx={{
|
number lives in the page header (deferred numbering — "Koncept"
|
||||||
display: "grid",
|
until finalize) and issued_by is auto-filled from the logged-in
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
|
user and still submitted with the payload (the PDF prints it). */}
|
||||||
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>
|
<Field label="Odběratel" error={errors.customer_id} required>
|
||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
customers={customers}
|
customers={customers}
|
||||||
@@ -1889,14 +1946,6 @@ export default function InvoiceDetail() {
|
|||||||
placeholder="Hledat zákazníka (název, IČ)..."
|
placeholder="Hledat zákazníka (název, IČ)..."
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Vystavil">
|
|
||||||
<TextField
|
|
||||||
value={form.issued_by}
|
|
||||||
InputProps={{ readOnly: true }}
|
|
||||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Field label="Text fakturace (na PDF)">
|
<Field label="Text fakturace (na PDF)">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -2242,16 +2291,26 @@ export default function InvoiceDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Rich-text PDF sections — printed after the items (like orders) */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<SectionsEditor
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
sections={sections}
|
||||||
Veřejné poznámky na faktuře
|
onChange={setSections}
|
||||||
</Typography>
|
readOnly={false}
|
||||||
<RichEditor
|
title="Obsah"
|
||||||
value={form.notes}
|
language={form.language}
|
||||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
|
||||||
placeholder="Poznámky zobrazené na faktuře..."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Internal notes */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<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>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
cleanQuillHtml,
|
cleanQuillHtml,
|
||||||
buildQuillIndentCss,
|
buildQuillIndentCss,
|
||||||
|
logoDataUriFromSettings,
|
||||||
|
buildPdfHeaderTemplate,
|
||||||
} from "../../utils/pdf-shared";
|
} from "../../utils/pdf-shared";
|
||||||
import createDOMPurify from "dompurify";
|
import createDOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
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 ───────────────────────────────────────────────────────── */
|
/* ── Route ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default async function invoicesPdfRoutes(
|
export default async function invoicesPdfRoutes(
|
||||||
@@ -264,6 +307,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
where: { invoice_id: id },
|
where: { invoice_id: id },
|
||||||
orderBy: { position: "asc" },
|
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;
|
let customer: Record<string, unknown> | null = null;
|
||||||
if (invoice.customer_id) {
|
if (invoice.customer_id) {
|
||||||
@@ -300,16 +348,12 @@ export default async function invoicesPdfRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let logoImg = "";
|
// Logo as a data URI — used by the body header (screen/HTML view) AND
|
||||||
if (settings?.logo_data) {
|
// the Puppeteer headerTemplate (templates can only load data: URLs).
|
||||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
const logoDataUri = logoDataUriFromSettings(settings);
|
||||||
let mime = "image/png";
|
const logoImg = logoDataUri
|
||||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
? `<img src="${logoDataUri}" class="logo" />`
|
||||||
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" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currency = invoice.currency || "CZK";
|
const currency = invoice.currency || "CZK";
|
||||||
const applyVat = !!invoice.apply_vat;
|
const applyVat = !!invoice.apply_vat;
|
||||||
@@ -472,17 +516,41 @@ export default async function invoicesPdfRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notesRaw = invoice.notes ?? "";
|
// ── Sections ("Obsah") — flow INLINE right after the items/totals
|
||||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
// block (mirrors issued orders): no separate page, long content
|
||||||
const notesHtml = notesStripped
|
// paginates naturally and the Puppeteer footer template stamps every
|
||||||
? `
|
// page. Suppressed entirely when every section is empty (tag-stripped
|
||||||
<!-- Poznamky -->
|
// check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
|
||||||
<div class="invoice-notes">
|
// the Czech title when present; en (or a missing Czech title) falls
|
||||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
// back to the EN title.
|
||||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
const resolveSectionTitle = (s: {
|
||||||
</div>
|
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
|
// Quill indent CSS
|
||||||
const indentCSS = buildQuillIndentCss();
|
const indentCSS = buildQuillIndentCss();
|
||||||
@@ -496,7 +564,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
@@ -509,11 +581,19 @@ export default async function invoicesPdfRoutes(
|
|||||||
.invoice-page {
|
.invoice-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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; }
|
.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 {
|
.invoice-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accent { color: #de3a3a; }
|
.accent { color: #de3a3a; }
|
||||||
@@ -725,13 +805,8 @@ export default async function invoicesPdfRoutes(
|
|||||||
margin-top: 2mm;
|
margin-top: 2mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vystavil */
|
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
|
||||||
.issued-by {
|
every page — same as the issued-order PDF; no body block. */
|
||||||
font-size: 9pt;
|
|
||||||
margin: 2mm 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.issued-by .lbl { font-weight: 600; }
|
|
||||||
|
|
||||||
/* Upozorneni */
|
/* Upozorneni */
|
||||||
.notice {
|
.notice {
|
||||||
@@ -797,32 +872,42 @@ export default async function invoicesPdfRoutes(
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Poznamky */
|
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||||
.invoice-notes {
|
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||||
margin-top: 4mm;
|
stranu (mirrors issued-orders-pdf). */
|
||||||
font-size: 10pt;
|
.scope-sections {
|
||||||
line-height: 1.5;
|
margin-top: 6mm;
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
}
|
||||||
.invoice-notes-label {
|
.scope-section {
|
||||||
font-weight: 600;
|
width: 100%;
|
||||||
font-size: 9pt;
|
max-width: 100%;
|
||||||
text-transform: uppercase;
|
margin-bottom: 3mm;
|
||||||
color: #555;
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
.scope-section-title {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
margin-bottom: 1mm;
|
margin-bottom: 1mm;
|
||||||
}
|
}
|
||||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
.section-content {
|
||||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
color: #1a1a1a;
|
||||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
line-height: 1.5;
|
||||||
.invoice-notes-content,
|
word-break: normal;
|
||||||
.invoice-notes-content * {
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.section-content,
|
||||||
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: Tahoma, sans-serif !important;
|
||||||
}
|
}
|
||||||
.invoice-notes-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.invoice-notes-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
.invoice-notes-content h2 { font-size: 18px; }
|
.section-content h2 { font-size: 18px; }
|
||||||
.invoice-notes-content h3 { font-size: 16px; }
|
.section-content h3 { font-size: 16px; }
|
||||||
.invoice-notes-content h4 { font-size: 15px; }
|
.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 */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||||
@@ -833,6 +918,10 @@ ${indentCSS}
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
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 {
|
@media screen {
|
||||||
html { background: #525659; }
|
html { background: #525659; }
|
||||||
@@ -944,16 +1033,12 @@ ${indentCSS}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${notesHtml}
|
${sectionsHtml}
|
||||||
|
|
||||||
</div><!-- /.invoice-content -->
|
</div><!-- /.invoice-content -->
|
||||||
<div class="invoice-footer">
|
<div class="invoice-footer">
|
||||||
|
|
||||||
<!-- Vystavil -->
|
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
|
||||||
<div class="issued-by">
|
|
||||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
|
||||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upozorneni -->
|
<!-- Upozorneni -->
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
@@ -1018,7 +1103,20 @@ ${indentCSS}
|
|||||||
? new Date(invoice.issue_date)
|
? new Date(invoice.issue_date)
|
||||||
: new Date();
|
: new Date();
|
||||||
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
|
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(
|
nasInvoicesManager.saveIssuedPdf(
|
||||||
invoice.invoice_number!,
|
invoice.invoice_number!,
|
||||||
issueDate.getFullYear(),
|
issueDate.getFullYear(),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
cleanQuillHtml,
|
cleanQuillHtml,
|
||||||
buildQuillIndentCss,
|
buildQuillIndentCss,
|
||||||
|
logoDataUriFromSettings,
|
||||||
|
buildPdfHeaderTemplate,
|
||||||
} from "../../utils/pdf-shared";
|
} from "../../utils/pdf-shared";
|
||||||
import createDOMPurify from "dompurify";
|
import createDOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
@@ -226,13 +228,28 @@ export function buildIssuedOrderPdfFooter(
|
|||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||||
const ofWord = lang === "cs" ? "z" : "of";
|
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: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; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||||
<div style="flex:1;"></div>
|
<div style="flex:1;"></div>
|
||||||
</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(
|
export function renderIssuedOrderHtml(
|
||||||
order: IssuedOrderPdfData,
|
order: IssuedOrderPdfData,
|
||||||
items: IssuedOrderPdfItem[],
|
items: IssuedOrderPdfItem[],
|
||||||
@@ -249,17 +266,11 @@ export function renderIssuedOrderHtml(
|
|||||||
const currency = order.currency || "CZK";
|
const currency = order.currency || "CZK";
|
||||||
const poNumber = escapeHtml(order.po_number || "");
|
const poNumber = escapeHtml(order.po_number || "");
|
||||||
|
|
||||||
// Logo embedding (same logic as the confirmation template).
|
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
|
||||||
let logoImg = "";
|
const logoDataUri = logoDataUriFromSettings(settings);
|
||||||
if (settings?.logo_data) {
|
const logoImg = logoDataUri
|
||||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
? `<img src="${logoDataUri}" class="logo" />`
|
||||||
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" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PO direction: our company (settings) = Odběratel (buyer);
|
// PO direction: our company (settings) = Odběratel (buyer);
|
||||||
// the sklad_suppliers record = Dodavatel (supplier).
|
// the sklad_suppliers record = Dodavatel (supplier).
|
||||||
@@ -350,7 +361,11 @@ export function renderIssuedOrderHtml(
|
|||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
@@ -621,6 +636,10 @@ ${indentCSS}
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
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 {
|
@media screen {
|
||||||
html { background: #525659; }
|
html { background: #525659; }
|
||||||
@@ -795,6 +814,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
: new Date();
|
: new Date();
|
||||||
nasOrdersManager.cleanIssued(order.po_number);
|
nasOrdersManager.cleanIssued(order.po_number);
|
||||||
const pdfBuffer = await htmlToPdf(html, {
|
const pdfBuffer = await htmlToPdf(html, {
|
||||||
|
headerTemplate: buildIssuedOrderPdfHeader(
|
||||||
|
lang,
|
||||||
|
logoDataUriFromSettings(settings),
|
||||||
|
order.po_number || "",
|
||||||
|
),
|
||||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||||
});
|
});
|
||||||
nasOrdersManager.saveIssuedPdf(
|
nasOrdersManager.saveIssuedPdf(
|
||||||
@@ -807,6 +831,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pdf = await htmlToPdf(html, {
|
const pdf = await htmlToPdf(html, {
|
||||||
|
headerTemplate: buildIssuedOrderPdfHeader(
|
||||||
|
lang,
|
||||||
|
logoDataUriFromSettings(settings),
|
||||||
|
order.po_number || "",
|
||||||
|
),
|
||||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||||
});
|
});
|
||||||
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
renderIssuedOrderHtml,
|
renderIssuedOrderHtml,
|
||||||
buildIssuedOrderPdfFooter,
|
buildIssuedOrderPdfFooter,
|
||||||
|
buildIssuedOrderPdfHeader,
|
||||||
} from "./issued-orders-pdf";
|
} from "./issued-orders-pdf";
|
||||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
|
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
|
||||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||||
import { contentDisposition } from "../../utils/content-disposition";
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
|
|
||||||
@@ -310,6 +312,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
sections,
|
sections,
|
||||||
);
|
);
|
||||||
const pdf = await htmlToPdf(html, {
|
const pdf = await htmlToPdf(html, {
|
||||||
|
headerTemplate: buildIssuedOrderPdfHeader(
|
||||||
|
lang,
|
||||||
|
logoDataUriFromSettings(settings),
|
||||||
|
order.po_number || "",
|
||||||
|
),
|
||||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
booleanFromForm,
|
booleanFromForm,
|
||||||
|
DocumentSectionSchema,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
|
||||||
const InvoiceItemSchema = z.object({
|
const InvoiceItemSchema = z.object({
|
||||||
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
|
|||||||
issued_by: z.string().max(255).nullish(),
|
issued_by: z.string().max(255).nullish(),
|
||||||
billing_text: z.string().max(8000).nullish(),
|
billing_text: z.string().max(8000).nullish(),
|
||||||
language: z.string().max(20).optional().default("cs"),
|
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(),
|
internal_notes: z.string().max(8000).nullish(),
|
||||||
items: z.array(InvoiceItemSchema).optional(),
|
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({
|
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(),
|
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||||
due_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(),
|
tax_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||||
notes: z.string().max(8000).nullish(),
|
|
||||||
internal_notes: z.string().max(8000).nullish(),
|
internal_notes: z.string().max(8000).nullish(),
|
||||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||||
items: z.array(InvoiceItemSchema).optional(),
|
items: z.array(InvoiceItemSchema).optional(),
|
||||||
|
sections: z.array(DocumentSectionSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ interface InvoiceItemInput {
|
|||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InvoiceSectionInput {
|
||||||
|
title?: string | null;
|
||||||
|
title_cz?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
|
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
|
||||||
* loosely typed because the payload arrives validated-but-coerced from the Zod
|
* loosely typed because the payload arrives validated-but-coerced from the Zod
|
||||||
@@ -62,10 +68,10 @@ export interface InvoiceInput {
|
|||||||
issued_by?: string | null;
|
issued_by?: string | null;
|
||||||
billing_text?: string | null;
|
billing_text?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
notes?: string | null;
|
|
||||||
internal_notes?: string | null;
|
internal_notes?: string | null;
|
||||||
paid_date?: string | null;
|
paid_date?: string | null;
|
||||||
items?: InvoiceItemInput[];
|
items?: InvoiceItemInput[];
|
||||||
|
sections?: InvoiceSectionInput[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,14 +475,17 @@ export async function getInvoice(id: number) {
|
|||||||
include: {
|
include: {
|
||||||
customers: true,
|
customers: true,
|
||||||
invoice_items: { orderBy: { position: "asc" } },
|
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 } },
|
orders: { select: { id: true, order_number: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
const { invoice_items, ...rest } = invoice;
|
const { invoice_items, invoice_sections, ...rest } = invoice;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: invoice_items,
|
items: invoice_items,
|
||||||
|
sections: invoice_sections,
|
||||||
customer: invoice.customers,
|
customer: invoice.customers,
|
||||||
customer_name: invoice.customers?.name || null,
|
customer_name: invoice.customers?.name || null,
|
||||||
order_number: invoice.orders?.order_number || null,
|
order_number: invoice.orders?.order_number || null,
|
||||||
@@ -499,7 +508,10 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
explicit ??
|
explicit ??
|
||||||
(status === "draft" ? null : (await generateInvoiceNumber()).number);
|
(status === "draft" ? null : (await generateInvoiceNumber()).number);
|
||||||
|
|
||||||
const invoice = await prisma.invoices.create({
|
// 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: {
|
data: {
|
||||||
invoice_number: invoiceNumber,
|
invoice_number: invoiceNumber,
|
||||||
order_id: body.order_id ? Number(body.order_id) : null,
|
order_id: body.order_id ? Number(body.order_id) : null,
|
||||||
@@ -508,7 +520,9 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
payment_method: body.payment_method ? String(body.payment_method) : null,
|
payment_method: body.payment_method
|
||||||
|
? String(body.payment_method)
|
||||||
|
: null,
|
||||||
constant_symbol: body.constant_symbol
|
constant_symbol: body.constant_symbol
|
||||||
? String(body.constant_symbol)
|
? String(body.constant_symbol)
|
||||||
: null,
|
: null,
|
||||||
@@ -522,15 +536,16 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||||
billing_text: body.billing_text ? String(body.billing_text) : null,
|
billing_text: body.billing_text ? String(body.billing_text) : null,
|
||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
notes: body.notes ? String(body.notes) : null,
|
internal_notes: body.internal_notes
|
||||||
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
|
? String(body.internal_notes)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(body.items)) {
|
if (Array.isArray(body.items)) {
|
||||||
await prisma.invoice_items.createMany({
|
await tx.invoice_items.createMany({
|
||||||
data: body.items.map((item, i) => ({
|
data: body.items.map((item, i) => ({
|
||||||
invoice_id: invoice.id,
|
invoice_id: created.id,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
item_description: item.item_description ?? null,
|
item_description: item.item_description ?? null,
|
||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
@@ -542,6 +557,21 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
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;
|
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 (editable) {
|
||||||
if (body.notes !== undefined)
|
|
||||||
data.notes = body.notes ? String(body.notes) : null;
|
|
||||||
if (body.internal_notes !== undefined)
|
if (body.internal_notes !== undefined)
|
||||||
data.internal_notes = body.internal_notes
|
data.internal_notes = body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
@@ -644,9 +672,13 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
await prisma.invoices.update({ where: { id }, data });
|
await prisma.invoices.update({ where: { id }, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow items update while editable (draft/issued/overdue).
|
// Allow items/sections full-replace while editable (draft/issued/overdue) —
|
||||||
if (editable && Array.isArray(body.items)) {
|
// 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 prisma.$transaction(async (tx) => {
|
||||||
|
if (replaceItems) {
|
||||||
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
||||||
await tx.invoice_items.createMany({
|
await tx.invoice_items.createMany({
|
||||||
data: body.items!.map((item, i) => ({
|
data: body.items!.map((item, i) => ({
|
||||||
@@ -660,6 +692,19 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
position: item.position ?? i,
|
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.
|
* Chromium's default date/title header.
|
||||||
*/
|
*/
|
||||||
footerTemplate?: string;
|
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(
|
export async function htmlToPdf(
|
||||||
@@ -95,16 +104,16 @@ export async function htmlToPdf(
|
|||||||
format: "A4",
|
format: "A4",
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: {
|
margin: {
|
||||||
top: "10mm",
|
top: options.headerTemplate ? "32mm" : "10mm",
|
||||||
bottom: options.footerTemplate ? "18mm" : "10mm",
|
bottom: options.footerTemplate ? "18mm" : "10mm",
|
||||||
left: "10mm",
|
left: "10mm",
|
||||||
right: "10mm",
|
right: "10mm",
|
||||||
},
|
},
|
||||||
...(options.footerTemplate
|
...(options.footerTemplate || options.headerTemplate
|
||||||
? {
|
? {
|
||||||
displayHeaderFooter: true,
|
displayHeaderFooter: true,
|
||||||
headerTemplate: "<span></span>",
|
headerTemplate: options.headerTemplate || "<span></span>",
|
||||||
footerTemplate: options.footerTemplate,
|
footerTemplate: options.footerTemplate || "<span></span>",
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
|
|||||||
@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
|
|||||||
.replace(/"/g, """);
|
.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
|
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
|
||||||
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
|
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
|
||||||
|
|||||||
Reference in New Issue
Block a user