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