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:
BOHA
2026-06-10 19:02:37 +02:00
parent ffae1a4e74
commit 40a859f5e1
12 changed files with 991 additions and 548 deletions

View File

@@ -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;

View File

@@ -7,29 +7,29 @@ datasource db {
} }
model attendance { model attendance {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0) arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8) arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8) arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2) arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500) arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0) break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0) break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0) departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8) departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8) departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2) departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500) departure_address String? @db.VarChar(500)
notes String? @db.Text notes String? @db.Text
project_id Int? project_id Int?
leave_type attendance_leave_type? @default(work) leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2) leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date") @@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@ -127,54 +127,54 @@ model bank_accounts {
} }
model company_settings { model company_settings {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
company_name String? @db.VarChar(255) company_name String? @db.VarChar(255)
street String? @db.VarChar(255) street String? @db.VarChar(255)
city String? @db.VarChar(255) city String? @db.VarChar(255)
postal_code String? @db.VarChar(20) postal_code String? @db.VarChar(20)
country String? @db.VarChar(100) country String? @db.VarChar(100)
company_id String? @db.VarChar(50) company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50) vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText custom_fields String? @db.LongText
logo_data Bytes? logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @unique @db.VarChar(36) uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
order_type_code String? @db.VarChar(10) order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10) invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false) require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2) break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15) break_duration_short Int? @default(15)
break_duration_long Int? @default(30) break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15) clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255) invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255) leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5) max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15) lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300) max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText available_vat_rates String? @db.LongText
available_currencies String? @db.LongText available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255) smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255) smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100) offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100) order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100) invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100) issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10) issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100) project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10) project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20) warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100) warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20) warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100) warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20) warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100) warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2) ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
} }
model customers { model customers {
@@ -198,49 +198,49 @@ model customers {
} }
model invoice_items { model invoice_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_id Int invoice_id Int
description String? @db.VarChar(500) description String? @db.VarChar(500)
item_description String? @db.Text item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0) position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@index([invoice_id], map: "invoice_id") @@index([invoice_id], map: "invoice_id")
} }
model invoices { model invoices {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true) apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50) payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20) constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255) bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20) bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50) bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50) bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date issue_date DateTime? @db.Date
due_date DateTime? @db.Date due_date DateTime? @db.Date
tax_date DateTime? @db.Date tax_date DateTime? @db.Date
paid_date DateTime? @db.Date paid_date DateTime? @db.Date
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")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date") @@index([due_date], map: "idx_invoices_due_date")
@@ -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)
@@ -365,24 +378,24 @@ model orders {
} }
model issued_orders { model issued_orders {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int? supplier_id Int?
status issued_orders_status @default(draft) status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date order_date DateTime? @db.Date
delivery_date DateTime? @db.Date delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500) order_text String? @db.VarChar(500)
internal_notes String? @db.Text internal_notes String? @db.Text
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
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)
issued_order_items issued_order_items[] issued_order_items issued_order_items[]
issued_order_sections issued_order_sections[] issued_order_sections issued_order_sections[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk") suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([supplier_id], map: "issued_orders_supplier_id") @@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date") @@index([status, order_date], map: "idx_issued_orders_status_date")
@@ -449,29 +462,29 @@ model project_notes {
} }
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
quotation_id Int? quotation_id Int?
order_id Int? order_id Int?
status String? @default("aktivni") @db.VarChar(30) status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date start_date DateTime? @db.Date
end_date DateTime? @db.Date end_date DateTime? @db.Date
notes String? @db.Text 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)
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
sklad_issues sklad_issues[] sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[] sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[] work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[] work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2") quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3") orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user") @@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -496,26 +509,26 @@ model quotation_items {
} }
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100) project_code String? @db.VarChar(100)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
order_id Int? order_id Int?
status String @default("active") @db.VarChar(20) status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)
scope_description String? @db.Text scope_description String? @db.Text
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
orders orders[] orders orders[]
projects projects[] projects projects[]
quotation_items quotation_items[] quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[] scope_sections scope_sections[]
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([quotation_number], map: "idx_quotations_number") @@index([quotation_number], map: "idx_quotations_number")
@@ -589,14 +602,14 @@ model roles {
} }
model scope_sections { model scope_sections {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_id Int quotation_id Int
position Int? @default(0) position Int? @default(0)
title String? @db.VarChar(500) title String? @db.VarChar(500)
title_cz String? @db.VarChar(500) title_cz String? @db.VarChar(500)
content String? @db.Text content String? @db.Text
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1") quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id") @@index([quotation_id], map: "quotation_id")
} }
@@ -665,38 +678,38 @@ model trips {
} }
model users { model users {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50) username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255) email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255) password_hash String @db.VarChar(255)
first_name String @db.VarChar(50) first_name String @db.VarChar(50)
last_name String @db.VarChar(50) last_name String @db.VarChar(50)
role_id Int? role_id Int?
is_active Boolean? @default(true) is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0) last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0) failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0) locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0) password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255) totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false) totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text totp_backup_codes String? @db.Text
totp_last_used_counter Int? totp_last_used_counter Int?
attendance attendance[] attendance attendance[]
leave_balances leave_balances[] leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers") leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[] projects projects[]
trips trips[] trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy") sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator") work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
work_plan_entries_owned work_plan_entries[] work_plan_entries_owned work_plan_entries[]
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator") work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
work_plan_overrides_owned work_plan_overrides[] work_plan_overrides_owned work_plan_overrides[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1") roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active") @@index([is_active], map: "idx_users_is_active")
@@index([role_id], map: "idx_users_role_id") @@index([role_id], map: "idx_users_role_id")
@@ -795,7 +808,7 @@ model sklad_suppliers {
name String @db.VarChar(255) name String @db.VarChar(255)
ico String? @db.VarChar(20) ico String? @db.VarChar(20)
dic String? @db.VarChar(20) dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255) contact_person String? @db.VarChar(255)
email String? @db.VarChar(255) email String? @db.VarChar(255)
phone String? @db.VarChar(50) phone String? @db.VarChar(50)
address String? @db.Text address String? @db.Text
@@ -819,7 +832,7 @@ model sklad_locations {
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)
items sklad_item_locations[] items sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[] inventory_lines sklad_inventory_lines[]
@@ -828,19 +841,19 @@ model sklad_locations {
} }
model sklad_items { model sklad_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50) item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255) name String @db.VarChar(255)
description String? @db.Text description String? @db.Text
category_id Int? category_id Int?
unit String @db.VarChar(20) unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3) min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true) is_active Boolean @default(true)
notes String? @db.Text 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)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull) category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[] batches sklad_batches[]
item_locations sklad_item_locations[] item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
@@ -855,50 +868,50 @@ model sklad_items {
model sklad_batches { model sklad_batches {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
receipt_line_id Int @unique receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3) original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0) received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false) is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict) receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo") @@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches") @@map("sklad_batches")
} }
model sklad_item_locations { model sklad_item_locations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
location_id Int location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3) quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@unique([item_id, location_id], map: "sklad_item_locations_unique") @@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations") @@map("sklad_item_locations")
} }
model sklad_receipts { model sklad_receipts {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50) receipt_number String? @unique @db.VarChar(50)
supplier_id Int? supplier_id Int?
delivery_note_number String? @db.VarChar(100) delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0) delivery_note_date DateTime? @db.DateTime(0)
received_by Int? received_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_receipt_status @default(DRAFT) status sklad_receipt_status @default(DRAFT)
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)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull) received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[] items sklad_receipt_lines[]
attachments sklad_receipt_attachments[] attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id") @@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by") @@index([received_by], map: "sklad_receipts_received_by")
@@ -906,50 +919,50 @@ model sklad_receipts {
} }
model sklad_receipt_lines { model sklad_receipt_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
item_id Int item_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
location_id Int? location_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches? batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id") @@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines") @@map("sklad_receipt_lines")
} }
model sklad_receipt_attachments { model sklad_receipt_attachments {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
file_name String @db.VarChar(255) file_name String @db.VarChar(255)
file_mime String @db.VarChar(100) file_mime String @db.VarChar(100)
file_size Int file_size Int
file_path String @db.VarChar(500) file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments") @@map("sklad_receipt_attachments")
} }
model sklad_issues { model sklad_issues {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50) issue_number String? @unique @db.VarChar(50)
project_id Int project_id Int
issued_by Int? issued_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_issue_status @default(DRAFT) status sklad_issue_status @default(DRAFT)
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)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull) issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[] items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id") @@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by") @@index([issued_by], map: "sklad_issues_issued_by")
@@ -957,72 +970,72 @@ model sklad_issues {
} }
model sklad_issue_lines { model sklad_issue_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_id Int issue_id Int
item_id Int item_id Int
batch_id Int batch_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
location_id Int? location_id Int?
reservation_id Int? reservation_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade) issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict) batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull) reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id") @@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines") @@map("sklad_issue_lines")
} }
model sklad_reservations { model sklad_reservations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
project_id Int project_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3) remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int? reserved_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_reservation_status @default(ACTIVE) status sklad_reservation_status @default(ACTIVE)
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)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull) reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status") @@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations") @@map("sklad_reservations")
} }
model sklad_inventory_sessions { model sklad_inventory_sessions {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50) session_number String? @unique @db.VarChar(50)
notes String? @db.Text notes String? @db.Text
status sklad_inventory_status @default(DRAFT) status sklad_inventory_status @default(DRAFT)
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)
items sklad_inventory_lines[] items sklad_inventory_lines[]
@@map("sklad_inventory_sessions") @@map("sklad_inventory_sessions")
} }
model sklad_inventory_lines { model sklad_inventory_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_id Int session_id Int
item_id Int item_id Int
location_id Int? location_id Int?
system_qty Decimal @db.Decimal(12, 3) system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3) actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3) difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade) session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@index([session_id], map: "sklad_inventory_lines_session_id") @@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines") @@map("sklad_inventory_lines")
@@ -1040,20 +1053,20 @@ model plan_categories {
} }
model work_plan_entries { model work_plan_entries {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
date_from DateTime @db.Date date_from DateTime @db.Date
date_to DateTime @db.Date date_to DateTime @db.Date
project_id Int? project_id Int?
category String @default("work") @db.VarChar(50) category String @default("work") @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, date_from], map: "idx_wpe_user_from") @@index([user_id, date_from], map: "idx_wpe_user_from")
@@index([user_id, date_to], map: "idx_wpe_user_to") @@index([user_id, date_to], map: "idx_wpe_user_to")
@@ -1062,19 +1075,19 @@ model work_plan_entries {
} }
model work_plan_overrides { model work_plan_overrides {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
project_id Int? project_id Int?
category String @db.VarChar(50) category String @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, shift_date], map: "idx_wpo_user_date") @@index([user_id, shift_date], map: "idx_wpo_user_date")
@@index([shift_date], map: "idx_wpo_date") @@index([shift_date], map: "idx_wpo_date")

View File

@@ -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 */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

View File

@@ -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;

View File

@@ -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 }}>
<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 }}> <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 Interní poznámky
</Typography> </Typography>
{notes && notes.trim() && notes !== "<p><br></p>" ? ( {invoice.internal_notes &&
invoice.internal_notes.trim() &&
invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes),
}}
/> />
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -1659,43 +1725,44 @@ export default function InvoiceDetail() {
Zpět Zpět
</Button> </Button>
<Box> <Box>
{isEdit && invoice ? ( <Box
<Box sx={{
sx={{ display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", gap: 1.5,
gap: 1.5, flexWrap: "wrap",
flexWrap: "wrap", }}
}} >
> <Typography variant="h4">
<Typography variant="h4"> {isEdit ? "Faktura" : "Nová faktura"}
Faktura {documentNumberLabel(invoice.invoice_number)} {/* Edit mode: a draft has no number yet → show "Koncept".
</Typography> 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 <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> )}
) : ( </Box>
<> {!isEdit && fromOrderId && (
<Typography variant="h4"> <Typography
Nová faktura{" "} variant="body2"
{invoiceNumber && ( color="text.secondary"
<Box component="span" sx={{ color: "text.secondary" }}> sx={{ mt: 0.5 }}
({invoiceNumber}) >
</Box> Z objednávky
)} </Typography>
</Typography>
{fromOrderId && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Z objednávky
</Typography>
)}
</>
)} )}
</Box> </Box>
</Box> </Box>
@@ -1866,37 +1933,19 @@ 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="Odběratel" error={errors.customer_id} required>
}} <CustomerPicker
> customers={customers}
<Field label="Číslo faktury"> value={form.customer_id}
<TextField onChange={selectCustomer}
value={invoiceNumber} error={errors.customer_id}
InputProps={{ readOnly: true }} placeholder="Hledat zákazníka (název, IČ)..."
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }} />
/> </Field>
</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>
<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) */}
<SectionsEditor
sections={sections}
onChange={setSections}
readOnly={false}
title="Obsah"
language={form.language}
/>
{/* Internal notes */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
Veřejné poznámky na faktuře <RichEditor
</Typography> value={form.internal_notes}
<RichEditor onChange={(val) =>
value={form.notes} setForm((prev) => ({ ...prev, internal_notes: val }))
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))} }
placeholder="Poznámky zobrazené na faktuře..." placeholder="Interní poznámky..."
/> />
</Field>
</Card> </Card>
</form> </form>

View File

@@ -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>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${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(),

View File

@@ -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`;

View File

@@ -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),
}); });

View File

@@ -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>;

View File

@@ -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,48 +508,69 @@ 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
data: { // leave a headerless/partial invoice behind.
invoice_number: invoiceNumber, const invoice = await prisma.$transaction(async (tx) => {
order_id: body.order_id ? Number(body.order_id) : null, const created = await tx.invoices.create({
customer_id: body.customer_id ? Number(body.customer_id) : null, data: {
status, invoice_number: invoiceNumber,
currency: body.currency ? String(body.currency) : "CZK", order_id: body.order_id ? Number(body.order_id) : null,
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, customer_id: body.customer_id ? Number(body.customer_id) : null,
apply_vat: body.apply_vat !== false, status,
payment_method: body.payment_method ? String(body.payment_method) : null, currency: body.currency ? String(body.currency) : "CZK",
constant_symbol: body.constant_symbol vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
? String(body.constant_symbol) apply_vat: body.apply_vat !== false,
: null, payment_method: body.payment_method
bank_name: body.bank_name ? String(body.bank_name) : null, ? String(body.payment_method)
bank_swift: body.bank_swift ? String(body.bank_swift) : null, : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null, constant_symbol: body.constant_symbol
bank_account: body.bank_account ? String(body.bank_account) : null, ? String(body.constant_symbol)
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null, : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null, bank_name: body.bank_name ? String(body.bank_name) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null, bank_swift: body.bank_swift ? String(body.bank_swift) : null,
issued_by: body.issued_by ? String(body.issued_by) : null, bank_iban: body.bank_iban ? String(body.bank_iban) : null,
billing_text: body.billing_text ? String(body.billing_text) : null, bank_account: body.bank_account ? String(body.bank_account) : null,
language: body.language ? String(body.language) : "cs", issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
notes: body.notes ? String(body.notes) : null, due_date: body.due_date ? new Date(String(body.due_date)) : null,
internal_notes: body.internal_notes ? String(body.internal_notes) : 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",
if (Array.isArray(body.items)) { internal_notes: body.internal_notes
await prisma.invoice_items.createMany({ ? String(body.internal_notes)
data: body.items.map((item, i) => ({ : null,
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,
})),
}); });
}
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; 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,22 +672,39 @@ 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) => {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } }); if (replaceItems) {
await tx.invoice_items.createMany({ await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
data: body.items!.map((item, i) => ({ await tx.invoice_items.createMany({
invoice_id: id, data: body.items!.map((item, i) => ({
description: item.description ?? null, invoice_id: id,
item_description: item.item_description ?? null, description: item.description ?? null,
quantity: item.quantity ?? 1, item_description: item.item_description ?? null,
unit: item.unit ?? null, quantity: item.quantity ?? 1,
unit_price: item.unit_price ?? 0, unit: item.unit ?? null,
vat_rate: item.vat_rate ?? 21.0, unit_price: item.unit_price ?? 0,
position: item.position ?? i, 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,
})),
});
}
}); });
} }

View File

@@ -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,

View File

@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
/**
* 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,