diff --git a/prisma/migrations/20260610183000_invoice_sections_notes_internal/migration.sql b/prisma/migrations/20260610183000_invoice_sections_notes_internal/migration.sql new file mode 100644 index 0000000..a3ad0a1 --- /dev/null +++ b/prisma/migrations/20260610183000_invoice_sections_notes_internal/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7fe4bc7..6d97827 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,29 +7,29 @@ datasource db { } model attendance { - id Int @id @default(autoincrement()) - user_id Int - shift_date DateTime @db.Date - arrival_time DateTime? @db.DateTime(0) - arrival_lat Decimal? @db.Decimal(10, 8) - arrival_lng Decimal? @db.Decimal(11, 8) - arrival_accuracy Decimal? @db.Decimal(10, 2) - arrival_address String? @db.VarChar(500) - break_start DateTime? @db.DateTime(0) - break_end DateTime? @db.DateTime(0) - departure_time DateTime? @db.DateTime(0) - departure_lat Decimal? @db.Decimal(10, 8) - departure_lng Decimal? @db.Decimal(11, 8) - departure_accuracy Decimal? @db.Decimal(10, 2) - departure_address String? @db.VarChar(500) - notes String? @db.Text - project_id Int? - leave_type attendance_leave_type? @default(work) - leave_hours Decimal? @db.Decimal(4, 2) - created_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") - attendance_project_logs attendance_project_logs[] + id Int @id @default(autoincrement()) + user_id Int + shift_date DateTime @db.Date + arrival_time DateTime? @db.DateTime(0) + arrival_lat Decimal? @db.Decimal(10, 8) + arrival_lng Decimal? @db.Decimal(11, 8) + arrival_accuracy Decimal? @db.Decimal(10, 2) + arrival_address String? @db.VarChar(500) + break_start DateTime? @db.DateTime(0) + break_end DateTime? @db.DateTime(0) + departure_time DateTime? @db.DateTime(0) + departure_lat Decimal? @db.Decimal(10, 8) + departure_lng Decimal? @db.Decimal(11, 8) + departure_accuracy Decimal? @db.Decimal(10, 2) + departure_address String? @db.VarChar(500) + notes String? @db.Text + project_id Int? + leave_type attendance_leave_type? @default(work) + leave_hours Decimal? @db.Decimal(4, 2) + created_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") + attendance_project_logs attendance_project_logs[] @@index([user_id, shift_date], map: "idx_attendance_user_date") @@index([user_id, departure_time], map: "idx_attendance_user_departure") @@ -127,54 +127,54 @@ model bank_accounts { } model company_settings { - id Int @id @default(autoincrement()) - company_name String? @db.VarChar(255) - street String? @db.VarChar(255) - city String? @db.VarChar(255) - postal_code String? @db.VarChar(20) - country String? @db.VarChar(100) - company_id String? @db.VarChar(50) - vat_id String? @db.VarChar(50) - custom_fields String? @db.LongText - logo_data Bytes? - logo_data_dark Bytes? @db.MediumBlob - quotation_prefix String? @db.VarChar(20) - default_currency String? @default("CZK") @db.VarChar(10) - default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - uuid String? @unique @db.VarChar(36) - modified_at DateTime? @db.DateTime(0) - is_deleted Boolean? @default(false) - sync_version Int? @default(0) - order_type_code String? @db.VarChar(10) - invoice_type_code String? @db.VarChar(10) - require_2fa Boolean @default(false) - break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2) - break_duration_short Int? @default(15) - break_duration_long Int? @default(30) - clock_rounding_minutes Int? @default(15) - invoice_alert_email String? @db.VarChar(255) - leave_notify_email String? @db.VarChar(255) - max_login_attempts Int? @default(5) - lockout_minutes Int? @default(15) - max_requests_per_minute Int? @default(300) - available_vat_rates String? @db.LongText - available_currencies String? @db.LongText - smtp_from String? @db.VarChar(255) - smtp_from_name String? @db.VarChar(255) - offer_number_pattern String? @db.VarChar(100) - order_number_pattern String? @db.VarChar(100) - invoice_number_pattern String? @db.VarChar(100) - issued_order_number_pattern String? @db.VarChar(100) - issued_order_type_code String? @db.VarChar(10) - project_number_pattern String? @db.VarChar(100) - project_type_code String? @db.VarChar(10) - warehouse_receipt_prefix String? @db.VarChar(20) - warehouse_receipt_number_pattern String? @db.VarChar(100) - warehouse_issue_prefix String? @db.VarChar(20) - warehouse_issue_number_pattern String? @db.VarChar(100) - warehouse_inventory_prefix String? @db.VarChar(20) + id Int @id @default(autoincrement()) + company_name String? @db.VarChar(255) + street String? @db.VarChar(255) + city String? @db.VarChar(255) + postal_code String? @db.VarChar(20) + country String? @db.VarChar(100) + company_id String? @db.VarChar(50) + vat_id String? @db.VarChar(50) + custom_fields String? @db.LongText + logo_data Bytes? + logo_data_dark Bytes? @db.MediumBlob + quotation_prefix String? @db.VarChar(20) + default_currency String? @default("CZK") @db.VarChar(10) + default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) + uuid String? @unique @db.VarChar(36) + modified_at DateTime? @db.DateTime(0) + is_deleted Boolean? @default(false) + sync_version Int? @default(0) + order_type_code String? @db.VarChar(10) + invoice_type_code String? @db.VarChar(10) + require_2fa Boolean @default(false) + break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2) + break_duration_short Int? @default(15) + break_duration_long Int? @default(30) + clock_rounding_minutes Int? @default(15) + invoice_alert_email String? @db.VarChar(255) + leave_notify_email String? @db.VarChar(255) + max_login_attempts Int? @default(5) + lockout_minutes Int? @default(15) + max_requests_per_minute Int? @default(300) + available_vat_rates String? @db.LongText + available_currencies String? @db.LongText + smtp_from String? @db.VarChar(255) + smtp_from_name String? @db.VarChar(255) + offer_number_pattern String? @db.VarChar(100) + order_number_pattern String? @db.VarChar(100) + invoice_number_pattern String? @db.VarChar(100) + issued_order_number_pattern String? @db.VarChar(100) + issued_order_type_code String? @db.VarChar(10) + project_number_pattern String? @db.VarChar(100) + project_type_code String? @db.VarChar(10) + warehouse_receipt_prefix String? @db.VarChar(20) + warehouse_receipt_number_pattern String? @db.VarChar(100) + warehouse_issue_prefix String? @db.VarChar(20) + warehouse_issue_number_pattern String? @db.VarChar(100) + warehouse_inventory_prefix String? @db.VarChar(20) 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 { @@ -198,49 +198,49 @@ model customers { } model invoice_items { - id Int @id @default(autoincrement()) - invoice_id Int - description String? @db.VarChar(500) + id Int @id @default(autoincrement()) + invoice_id Int + description String? @db.VarChar(500) item_description String? @db.Text - quantity Decimal? @default(1.000) @db.Decimal(12, 3) - unit String? @db.VarChar(20) - unit_price Decimal? @default(0.00) @db.Decimal(12, 2) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - position Int? @default(0) - invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") + quantity Decimal? @default(1.000) @db.Decimal(12, 3) + unit String? @db.VarChar(20) + unit_price Decimal? @default(0.00) @db.Decimal(12, 2) + vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) + position Int? @default(0) + invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") @@index([invoice_id], map: "invoice_id") } model invoices { - id Int @id @default(autoincrement()) - invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) - order_id Int? - customer_id Int? - status String? @default("issued") @db.VarChar(30) - currency String? @default("CZK") @db.VarChar(10) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - apply_vat Boolean? @default(true) - payment_method String? @db.VarChar(50) - constant_symbol String? @db.VarChar(20) - bank_name String? @db.VarChar(255) - bank_swift String? @db.VarChar(20) - bank_iban String? @db.VarChar(50) - bank_account String? @db.VarChar(50) - issue_date DateTime? @db.Date - due_date DateTime? @db.Date - tax_date DateTime? @db.Date - paid_date DateTime? @db.Date - issued_by String? @db.VarChar(255) - billing_text String? @db.VarChar(500) - language String? @default("cs") @db.VarChar(5) - notes String? @db.Text - internal_notes String? @db.Text - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) - invoice_items invoice_items[] - 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") + id Int @id @default(autoincrement()) + invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) + order_id Int? + customer_id Int? + status String? @default("issued") @db.VarChar(30) + currency String? @default("CZK") @db.VarChar(10) + vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) + apply_vat Boolean? @default(true) + payment_method String? @db.VarChar(50) + constant_symbol String? @db.VarChar(20) + bank_name String? @db.VarChar(255) + bank_swift String? @db.VarChar(20) + bank_iban String? @db.VarChar(50) + bank_account String? @db.VarChar(50) + issue_date DateTime? @db.Date + due_date DateTime? @db.Date + tax_date DateTime? @db.Date + paid_date DateTime? @db.Date + issued_by String? @db.VarChar(255) + billing_text String? @db.VarChar(500) + language String? @default("cs") @db.VarChar(5) + internal_notes String? @db.Text + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) + invoice_items invoice_items[] + invoice_sections invoice_sections[] + 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") @@index([customer_id], map: "customer_id") @@index([due_date], map: "idx_invoices_due_date") @@ -249,6 +249,19 @@ model invoices { @@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 { id Int @id @default(autoincrement()) name String? @db.VarChar(255) @@ -365,24 +378,24 @@ model orders { } model issued_orders { - id Int @id @default(autoincrement()) - po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) - supplier_id Int? - status issued_orders_status @default(draft) - currency String? @default("CZK") @db.VarChar(10) - exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) - order_date DateTime? @db.Date - delivery_date DateTime? @db.Date - language String? @default("cs") @db.VarChar(5) - order_text String? @db.VarChar(500) - internal_notes String? @db.Text - locked_by Int? - locked_at DateTime? @db.DateTime(0) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) - issued_order_items issued_order_items[] + id Int @id @default(autoincrement()) + po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) + supplier_id Int? + status issued_orders_status @default(draft) + currency String? @default("CZK") @db.VarChar(10) + exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) + order_date DateTime? @db.Date + delivery_date DateTime? @db.Date + language String? @default("cs") @db.VarChar(5) + order_text String? @db.VarChar(500) + internal_notes String? @db.Text + locked_by Int? + locked_at DateTime? @db.DateTime(0) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) + issued_order_items issued_order_items[] 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([status, order_date], map: "idx_issued_orders_status_date") @@ -449,29 +462,29 @@ model project_notes { } model projects { - id Int @id @default(autoincrement()) - project_number String? @unique @db.VarChar(50) - name String? @db.VarChar(255) - customer_id Int? - responsible_user_id Int? - quotation_id Int? - order_id Int? - status String? @default("aktivni") @db.VarChar(30) - start_date DateTime? @db.Date - end_date DateTime? @db.Date - notes String? @db.Text - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + project_number String? @unique @db.VarChar(50) + name String? @db.VarChar(255) + customer_id Int? + responsible_user_id Int? + quotation_id Int? + order_id Int? + status String? @default("aktivni") @db.VarChar(30) + start_date DateTime? @db.Date + end_date DateTime? @db.Date + notes String? @db.Text + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) attendance_project_logs attendance_project_logs[] - project_notes project_notes[] - sklad_issues sklad_issues[] - sklad_reservations sklad_reservations[] - work_plan_entries work_plan_entries[] - work_plan_overrides work_plan_overrides[] - 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") - 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") + project_notes project_notes[] + sklad_issues sklad_issues[] + sklad_reservations sklad_reservations[] + work_plan_entries work_plan_entries[] + work_plan_overrides work_plan_overrides[] + 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") + 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") @@index([customer_id], map: "customer_id") @@index([responsible_user_id], map: "fk_projects_responsible_user") @@ -496,26 +509,26 @@ model quotation_items { } model quotations { - id Int @id @default(autoincrement()) - quotation_number String? @unique @db.VarChar(50) - project_code String? @db.VarChar(100) - customer_id Int? - created_at DateTime? @default(now()) @db.DateTime(0) - valid_until DateTime? @db.Date - currency String? @default("CZK") @db.VarChar(10) - language String? @default("cs") @db.VarChar(5) - order_id Int? - status String @default("active") @db.VarChar(20) - scope_title String? @db.VarChar(500) - scope_description String? @db.Text - locked_by Int? - locked_at DateTime? @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) - orders orders[] - projects projects[] - quotation_items quotation_items[] - customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1") - scope_sections scope_sections[] + id Int @id @default(autoincrement()) + quotation_number String? @unique @db.VarChar(50) + project_code String? @db.VarChar(100) + customer_id Int? + created_at DateTime? @default(now()) @db.DateTime(0) + valid_until DateTime? @db.Date + currency String? @default("CZK") @db.VarChar(10) + language String? @default("cs") @db.VarChar(5) + order_id Int? + status String @default("active") @db.VarChar(20) + scope_title String? @db.VarChar(500) + scope_description String? @db.Text + locked_by Int? + locked_at DateTime? @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) + orders orders[] + projects projects[] + quotation_items quotation_items[] + customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1") + scope_sections scope_sections[] @@index([customer_id], map: "customer_id") @@index([quotation_number], map: "idx_quotations_number") @@ -589,14 +602,14 @@ model roles { } model scope_sections { - id Int @id @default(autoincrement()) - quotation_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) - quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1") + id Int @id @default(autoincrement()) + quotation_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) + quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1") @@index([quotation_id], map: "quotation_id") } @@ -665,38 +678,38 @@ model trips { } model users { - id Int @id @default(autoincrement()) - username String @unique(map: "username") @db.VarChar(50) - email String @unique(map: "email") @db.VarChar(255) - password_hash String @db.VarChar(255) - first_name String @db.VarChar(50) - last_name String @db.VarChar(50) + id Int @id @default(autoincrement()) + username String @unique(map: "username") @db.VarChar(50) + email String @unique(map: "email") @db.VarChar(255) + password_hash String @db.VarChar(255) + first_name String @db.VarChar(50) + last_name String @db.VarChar(50) role_id Int? - is_active Boolean? @default(true) - last_login DateTime? @db.Timestamp(0) - failed_login_attempts Int? @default(0) - locked_until DateTime? @db.Timestamp(0) - password_changed_at DateTime? @default(now()) @db.Timestamp(0) - created_at DateTime? @default(now()) @db.Timestamp(0) - updated_at DateTime? @default(now()) @db.Timestamp(0) - totp_secret String? @db.VarChar(255) - totp_enabled Boolean @default(false) - totp_backup_codes String? @db.Text + is_active Boolean? @default(true) + last_login DateTime? @db.Timestamp(0) + failed_login_attempts Int? @default(0) + locked_until DateTime? @db.Timestamp(0) + password_changed_at DateTime? @default(now()) @db.Timestamp(0) + created_at DateTime? @default(now()) @db.Timestamp(0) + updated_at DateTime? @default(now()) @db.Timestamp(0) + totp_secret String? @db.VarChar(255) + totp_enabled Boolean @default(false) + totp_backup_codes String? @db.Text totp_last_used_counter Int? attendance attendance[] leave_balances leave_balances[] - 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_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") + leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers") projects projects[] trips trips[] - sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") - sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") - sklad_reservations sklad_reservations[] @relation("SkladReservedBy") - work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator") + sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") + sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") + sklad_reservations sklad_reservations[] @relation("SkladReservedBy") + work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator") work_plan_entries_owned work_plan_entries[] work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator") 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([role_id], map: "idx_users_role_id") @@ -795,7 +808,7 @@ model sklad_suppliers { name String @db.VarChar(255) ico 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) phone String? @db.VarChar(50) address String? @db.Text @@ -819,7 +832,7 @@ model sklad_locations { created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) - items sklad_item_locations[] + items sklad_item_locations[] receipt_lines sklad_receipt_lines[] issue_lines sklad_issue_lines[] inventory_lines sklad_inventory_lines[] @@ -828,19 +841,19 @@ model sklad_locations { } model sklad_items { - id Int @id @default(autoincrement()) - item_number String? @unique @db.VarChar(50) - name String @db.VarChar(255) - description String? @db.Text - category_id Int? - unit String @db.VarChar(20) - min_quantity Decimal? @db.Decimal(12, 3) - is_active Boolean @default(true) - notes String? @db.Text - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + item_number String? @unique @db.VarChar(50) + name String @db.VarChar(255) + description String? @db.Text + category_id Int? + unit String @db.VarChar(20) + min_quantity Decimal? @db.Decimal(12, 3) + is_active Boolean @default(true) + notes String? @db.Text + created_at DateTime? @default(now()) @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[] item_locations sklad_item_locations[] receipt_lines sklad_receipt_lines[] @@ -855,50 +868,50 @@ model sklad_items { model sklad_batches { id Int @id @default(autoincrement()) item_id Int - receipt_line_id Int @unique - quantity Decimal @db.Decimal(12, 3) + receipt_line_id Int @unique + quantity Decimal @db.Decimal(12, 3) original_qty Decimal @db.Decimal(12, 3) unit_price Decimal @db.Decimal(12, 2) received_at DateTime? @db.DateTime(0) is_consumed Boolean @default(false) - 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) - issue_lines sklad_issue_lines[] + 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) + issue_lines sklad_issue_lines[] @@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo") @@map("sklad_batches") } model sklad_item_locations { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) item_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) - location sklad_locations @relation(fields: [location_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) @@unique([item_id, location_id], map: "sklad_item_locations_unique") @@map("sklad_item_locations") } model sklad_receipts { - id Int @id @default(autoincrement()) - receipt_number String? @unique @db.VarChar(50) - supplier_id Int? - delivery_note_number String? @db.VarChar(100) - delivery_note_date DateTime? @db.DateTime(0) - received_by Int? - notes String? @db.Text - status sklad_receipt_status @default(DRAFT) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + receipt_number String? @unique @db.VarChar(50) + supplier_id Int? + delivery_note_number String? @db.VarChar(100) + delivery_note_date DateTime? @db.DateTime(0) + received_by Int? + notes String? @db.Text + status sklad_receipt_status @default(DRAFT) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) - supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) - received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull) - items sklad_receipt_lines[] - attachments sklad_receipt_attachments[] + supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) + received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull) + items sklad_receipt_lines[] + attachments sklad_receipt_attachments[] @@index([supplier_id], map: "sklad_receipts_supplier_id") @@index([received_by], map: "sklad_receipts_received_by") @@ -906,50 +919,50 @@ model sklad_receipts { } model sklad_receipt_lines { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) receipt_id Int item_id Int - quantity Decimal @db.Decimal(12, 3) - unit_price Decimal @db.Decimal(12, 2) + quantity Decimal @db.Decimal(12, 3) + unit_price Decimal @db.Decimal(12, 2) location_id Int? - notes String? @db.VarChar(255) + notes String? @db.VarChar(255) - receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) - item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) - location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) - batch sklad_batches? + receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) + item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) + location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) + batch sklad_batches? @@index([receipt_id], map: "sklad_receipt_lines_receipt_id") @@map("sklad_receipt_lines") } model sklad_receipt_attachments { - id Int @id @default(autoincrement()) - receipt_id Int - file_name String @db.VarChar(255) - file_mime String @db.VarChar(100) - file_size Int - file_path String @db.VarChar(500) - created_at DateTime? @default(now()) @db.DateTime(0) + id Int @id @default(autoincrement()) + receipt_id Int + file_name String @db.VarChar(255) + file_mime String @db.VarChar(100) + file_size Int + file_path String @db.VarChar(500) + 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") } model sklad_issues { - id Int @id @default(autoincrement()) - issue_number String? @unique @db.VarChar(50) - project_id Int - issued_by Int? - notes String? @db.Text - status sklad_issue_status @default(DRAFT) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + issue_number String? @unique @db.VarChar(50) + project_id Int + issued_by Int? + notes String? @db.Text + status sklad_issue_status @default(DRAFT) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) - project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) - issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull) - items sklad_issue_lines[] + project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) + issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull) + items sklad_issue_lines[] @@index([project_id], map: "sklad_issues_project_id") @@index([issued_by], map: "sklad_issues_issued_by") @@ -957,72 +970,72 @@ model sklad_issues { } model sklad_issue_lines { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) issue_id Int item_id Int batch_id Int - quantity Decimal @db.Decimal(12, 3) + quantity Decimal @db.Decimal(12, 3) location_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) - item sklad_items @relation(fields: [item_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) - reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull) + issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade) + item sklad_items @relation(fields: [item_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) + reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull) @@index([issue_id], map: "sklad_issue_lines_issue_id") @@map("sklad_issue_lines") } model sklad_reservations { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) item_id Int project_id Int - quantity Decimal @db.Decimal(12, 3) - remaining_qty Decimal @db.Decimal(12, 3) + quantity Decimal @db.Decimal(12, 3) + remaining_qty Decimal @db.Decimal(12, 3) reserved_by Int? - notes String? @db.Text + notes String? @db.Text status sklad_reservation_status @default(ACTIVE) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) - item sklad_items @relation(fields: [item_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) - issue_lines sklad_issue_lines[] + item sklad_items @relation(fields: [item_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) + issue_lines sklad_issue_lines[] @@index([item_id, status], map: "sklad_reservations_item_status") @@map("sklad_reservations") } model sklad_inventory_sessions { - id Int @id @default(autoincrement()) - session_number String? @unique @db.VarChar(50) - notes String? @db.Text - status sklad_inventory_status @default(DRAFT) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + session_number String? @unique @db.VarChar(50) + notes String? @db.Text + status sklad_inventory_status @default(DRAFT) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) - items sklad_inventory_lines[] + items sklad_inventory_lines[] @@map("sklad_inventory_sessions") } model sklad_inventory_lines { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) session_id Int item_id Int location_id Int? - system_qty Decimal @db.Decimal(12, 3) - actual_qty Decimal @db.Decimal(12, 3) - difference Decimal @db.Decimal(12, 3) - notes String? @db.VarChar(255) + system_qty Decimal @db.Decimal(12, 3) + actual_qty Decimal @db.Decimal(12, 3) + difference Decimal @db.Decimal(12, 3) + notes String? @db.VarChar(255) - session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade) - item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) - location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) + session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade) + item sklad_items @relation(fields: [item_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") @@map("sklad_inventory_lines") @@ -1040,20 +1053,20 @@ model plan_categories { } model work_plan_entries { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) user_id Int - date_from DateTime @db.Date - date_to DateTime @db.Date + date_from DateTime @db.Date + date_to DateTime @db.Date project_id Int? - category String @default("work") @db.VarChar(50) - note String @db.VarChar(500) + category String @default("work") @db.VarChar(50) + note String @db.VarChar(500) created_by Int - created_at DateTime? @default(now()) @db.Timestamp(0) - updated_at DateTime? @default(now()) @db.Timestamp(0) - is_deleted Boolean? @default(false) - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, 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) + created_at DateTime? @default(now()) @db.Timestamp(0) + updated_at DateTime? @default(now()) @db.Timestamp(0) + is_deleted Boolean? @default(false) + users users @relation(fields: [user_id], references: [id], onDelete: Cascade, 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) @@index([user_id, date_from], map: "idx_wpe_user_from") @@index([user_id, date_to], map: "idx_wpe_user_to") @@ -1062,19 +1075,19 @@ model work_plan_entries { } model work_plan_overrides { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) user_id Int - shift_date DateTime @db.Date + shift_date DateTime @db.Date project_id Int? - category String @db.VarChar(50) - note String @db.VarChar(500) + category String @db.VarChar(50) + note String @db.VarChar(500) created_by Int - created_at DateTime? @default(now()) @db.Timestamp(0) - updated_at DateTime? @default(now()) @db.Timestamp(0) - is_deleted Boolean? @default(false) - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, 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) + created_at DateTime? @default(now()) @db.Timestamp(0) + updated_at DateTime? @default(now()) @db.Timestamp(0) + is_deleted Boolean? @default(false) + users users @relation(fields: [user_id], references: [id], onDelete: Cascade, 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) @@index([user_id, shift_date], map: "idx_wpo_user_date") @@index([shift_date], map: "idx_wpo_date") diff --git a/src/__tests__/invoices.service.test.ts b/src/__tests__/invoices.service.test.ts index a253f3d..2c21a98 100644 --- a/src/__tests__/invoices.service.test.ts +++ b/src/__tests__/invoices.service.test.ts @@ -5,10 +5,14 @@ import { invoiceTotalWithVat, createInvoice, updateInvoice, + getInvoice, listInvoices, getInvoiceListTotals, } from "../services/invoices.service"; -import { UpdateInvoiceSchema } from "../schemas/invoices.schema"; +import { + CreateInvoiceSchema, + UpdateInvoiceSchema, +} from "../schemas/invoices.schema"; // `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using @@ -168,7 +172,10 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema invoiceIds.push(draft.id); // Absent from the body -> updateInvoice's `!== undefined` guard skips it. - await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" })); + await updateInvoice( + draft.id, + UpdateInvoiceSchema.parse({ internal_notes: "x" }), + ); let row = await prisma.invoices.findUnique({ where: { id: draft.id } }); expect(row?.billing_text).toBe("Text na PDF"); @@ -182,6 +189,94 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema }); }); +/* -------------------------------------------------------------------------- */ +/* "Obsah" sections + internal-only notes (mirrors issued orders) */ +/* -------------------------------------------------------------------------- */ + +describe("invoice sections round-trip; dropped `notes` is stripped", () => { + const invoiceIds: number[] = []; + + afterEach(async () => { + for (const id of invoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + invoiceIds.length = 0; + }); + + it("creates, returns and replaces CZ/EN sections in position order", async () => { + const parsed = CreateInvoiceSchema.parse({ + billing_text: "Sekce test", + sections: [ + { title: "Scope", title_cz: "Rozsah", content: "

obsah 1

" }, + { title: "", title_cz: "Podmínky", content: "

obsah 2

" }, + ], + }); + 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: "

nový obsah

" }, + ], + }); + 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("

nový obsah

"); + }); + + it("sections survive an items-only update untouched (absent = keep)", async () => { + const draft = await createInvoice( + CreateInvoiceSchema.parse({ + sections: [{ title: "", title_cz: "Drží", content: "

x

" }], + }), + ); + 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: "

stará poznámka

", + internal_notes: "

interní

", + }); + 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("

interní

"); + + 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 */ /* -------------------------------------------------------------------------- */ diff --git a/src/admin/lib/queries/invoices.ts b/src/admin/lib/queries/invoices.ts index ac8899d..00fafc1 100644 --- a/src/admin/lib/queries/invoices.ts +++ b/src/admin/lib/queries/invoices.ts @@ -43,6 +43,14 @@ export interface InvoiceItem { position?: number; } +export interface InvoiceSection { + id?: number; + title: string | null; + title_cz: string | null; + content: string | null; + position?: number | null; +} + export interface InvoiceDetail { id: number; invoice_number: string; @@ -62,7 +70,7 @@ export interface InvoiceDetail { vat_rate: number; apply_vat: boolean; exchange_rate: string; - notes?: string; + internal_notes?: string | null; payment_method?: string; variable_symbol?: string; constant_symbol?: string; @@ -75,6 +83,7 @@ export interface InvoiceDetail { bank_account?: string; bank_account_id?: number | null; items?: InvoiceItem[]; + sections?: InvoiceSection[]; subtotal: number; vat_amount: number; total: number; diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index e9fc43e..a25c1bd 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -24,6 +24,9 @@ import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; +import SectionsEditor, { + type DocumentSection, +} from "../components/document/SectionsEditor"; import { DndContext, closestCenter, @@ -198,7 +201,7 @@ interface InvoiceForm { constant_symbol: string; issued_by: string; billing_text: string; - notes: string; + internal_notes: string; language: string; bank_account_id: number | string; bank_name: string; @@ -540,7 +543,7 @@ export default function InvoiceDetail() { constant_symbol: "0308", issued_by: user?.fullName || "", billing_text: "", - notes: "", + internal_notes: "", language: "cs", bank_account_id: "", bank_name: "", @@ -550,6 +553,9 @@ export default function InvoiceDetail() { })); const [dueDays, setDueDays] = useState(14); + // Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF — + // shared editor with offers/issued orders). + const [sections, setSections] = useState([]); const [items, setItems] = useState(() => [ { _key: "inv-1", @@ -626,7 +632,6 @@ export default function InvoiceDetail() { }); // ─── Edit mode state ─── - const [notes, setNotes] = useState(""); const [statusChanging, setStatusChanging] = useState(null); const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; @@ -696,7 +701,7 @@ export default function InvoiceDetail() { constant_symbol: inv.constant_symbol || "0308", issued_by: inv.issued_by || "", billing_text: inv.billing_text || "", - notes: inv.notes || "", + internal_notes: inv.internal_notes || "", language: inv.language || "cs", bank_account_id: matchedBankId, bank_name: inv.bank_name || "", @@ -705,7 +710,6 @@ export default function InvoiceDetail() { bank_account: inv.bank_account || "", }; setForm(formData); - setNotes(inv.notes || ""); setInvoiceNumber(inv.invoice_number || ""); // Calculate dueDays from existing dates @@ -741,10 +745,22 @@ export default function InvoiceDetail() { setItems(mappedItems); } + // Populate sections from existing invoice + const mappedSections = + Array.isArray(inv.sections) && inv.sections.length > 0 + ? inv.sections.map((s) => ({ + title: s.title || "", + title_cz: s.title_cz || "", + content: s.content || "", + })) + : []; + setSections(mappedSections); + // Capture initial snapshot for dirty-checking initialSnapshotRef.current = JSON.stringify({ form: formData, items: mappedItems, + sections: mappedSections, }); setDataReady(true); @@ -841,13 +857,15 @@ export default function InvoiceDetail() { // Edit mode: captured inside the sync effect from raw query data. // Create mode: captured on the first render after sync effects populate the form. if (dataReady && !initialSnapshotRef.current) { - initialSnapshotRef.current = JSON.stringify({ form, items }); + initialSnapshotRef.current = JSON.stringify({ form, items, sections }); } const isDirty = useMemo(() => { if (!initialSnapshotRef.current) return false; - return JSON.stringify({ form, items }) !== initialSnapshotRef.current; - }, [form, items]); + return ( + JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current + ); + }, [form, items, sections]); useEffect(() => { if (!isDirty) return; @@ -1005,6 +1023,12 @@ export default function InvoiceDetail() { unit_price: Number(item.unit_price) || 0, position: i, })), + sections: sections.map((s, i) => ({ + title: s.title, + title_cz: s.title_cz, + content: s.content, + position: i, + })), }; // Only set status when a target is given (create-as-draft / create-as-live // / finalize). Editing a live invoice sends no status — the backend keeps @@ -1017,7 +1041,7 @@ export default function InvoiceDetail() { const data = await saveMutation.mutateAsync(payload); alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); - initialSnapshotRef.current = JSON.stringify({ form, items }); + initialSnapshotRef.current = JSON.stringify({ form, items, sections }); if (!isEdit) { navigate(`/invoices/${data.invoice_id}`); } @@ -1148,7 +1172,13 @@ export default function InvoiceDetail() { }} > - Faktura {documentNumberLabel(invoice.invoice_number)} + Faktura + + {documentNumberLabel(invoice.invoice_number)} + {invoice.invoice_number} - - - {invoice.issued_by || "—"} - - {invoice.paid_date && ( @@ -1597,14 +1622,55 @@ export default function InvoiceDetail() { - {/* 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(), + ) && ( + + + Obsah + + + {(invoice.sections ?? []).map((s, idx) => ( + + {((invoice.language === "cs" + ? s.title_cz || s.title + : s.title) || + "") && ( + + {invoice.language === "cs" + ? s.title_cz || s.title + : s.title} + + )} + {(s.content || "").replace(/<[^>]*>/g, "").trim() && ( + + )} + + ))} + + + )} + + {/* Internal notes (read-only, never printed) */} - Veřejné poznámky na faktuře + Interní poznámky - {notes && notes.trim() && notes !== "


" ? ( + {invoice.internal_notes && + invoice.internal_notes.trim() && + invoice.internal_notes !== "


" ? ( ) : ( @@ -1659,43 +1725,44 @@ export default function InvoiceDetail() { Zpět - {isEdit && invoice ? ( - - - Faktura {documentNumberLabel(invoice.invoice_number)} - + + + {isEdit ? "Faktura" : "Nová faktura"} + {/* Edit mode: a draft has no number yet → show "Koncept". + Create mode: show the previewed next-number when available. */} + {(isEdit || invoiceNumber) && ( + + {isEdit + ? documentNumberLabel(invoice?.invoice_number) + : invoiceNumber} + + )} + + {isEdit && invoice && ( - - ) : ( - <> - - Nová faktura{" "} - {invoiceNumber && ( - - ({invoiceNumber}) - - )} - - {fromOrderId && ( - - Z objednávky - - )} - + )} + + {!isEdit && fromOrderId && ( + + Z objednávky + )} @@ -1866,37 +1933,19 @@ export default function InvoiceDetail() { Základní údaje - - - - - - - - - - - + {/* Číslo faktury and Vystavil are not form fields anymore: the + number lives in the page header (deferred numbering — "Koncept" + until finalize) and issued_by is auto-filled from the logged-in + user and still submitted with the payload (the PDF prints it). */} + + +
- {/* Notes */} + {/* Rich-text PDF sections — printed after the items (like orders) */} + + + {/* Internal notes */} - - Veřejné poznámky na faktuře - - setForm((prev) => ({ ...prev, notes: val }))} - placeholder="Poznámky zobrazené na faktuře..." - /> + + + setForm((prev) => ({ ...prev, internal_notes: val })) + } + placeholder="Interní poznámky..." + /> + diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 1bfada7..bc4c0df 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -13,6 +13,8 @@ import { escapeHtml, cleanQuillHtml, buildQuillIndentCss, + logoDataUriFromSettings, + buildPdfHeaderTemplate, } from "../../utils/pdf-shared"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; @@ -232,6 +234,47 @@ const translations: Record> = { }, }; +/** Tag-stripped emptiness check — "


" (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: " + * 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 `
+
${escapeHtml(t.issued_by)} ${escapeHtml(issuerLine)}
+
${pageWord} ${ofWord}
+
+
`; +} + +/** + * Repeating per-page header for the invoice PDF — the unified red-accent + * family header (shared with issued orders): logo left, red + * "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page. + * The body's own header is print-hidden so page 1 doesn't show it twice. + */ +export function buildInvoicePdfHeader( + lang: string, + logoDataUri: string, + invoiceNumber: string, +): string { + const t = translations[lang] || translations.cs; + return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`); +} + /* ── Route ───────────────────────────────────────────────────────── */ export default async function invoicesPdfRoutes( @@ -264,6 +307,11 @@ export default async function invoicesPdfRoutes( where: { invoice_id: id }, orderBy: { position: "asc" }, }); + // id tiebreak: position can repeat, keep the order deterministic. + const sections = await prisma.invoice_sections.findMany({ + where: { invoice_id: id }, + orderBy: [{ position: "asc" }, { id: "asc" }], + }); let customer: Record | null = null; if (invoice.customer_id) { @@ -300,16 +348,12 @@ export default async function invoicesPdfRoutes( } } - let logoImg = ""; - if (settings?.logo_data) { - const buf = Buffer.from(settings.logo_data as Buffer); - let mime = "image/png"; - if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; - else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; - else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; - const b64 = buf.toString("base64"); - logoImg = ``; - } + // Logo as a data URI — used by the body header (screen/HTML view) AND + // the Puppeteer headerTemplate (templates can only load data: URLs). + const logoDataUri = logoDataUriFromSettings(settings); + const logoImg = logoDataUri + ? `` + : ""; const currency = invoice.currency || "CZK"; const applyVat = !!invoice.apply_vat; @@ -472,17 +516,41 @@ export default async function invoicesPdfRoutes( } } - const notesRaw = invoice.notes ?? ""; - const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); - const notesHtml = notesStripped - ? ` - -
-
${escapeHtml(t.notes)}
-
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
-
- ` - : ""; + // ── Sections ("Obsah") — flow INLINE right after the items/totals + // block (mirrors issued orders): no separate page, long content + // paginates naturally and the Puppeteer footer template stamps every + // page. Suppressed entirely when every section is empty (tag-stripped + // check, so an empty-Quill "


" doesn't count). cs prefers + // the Czech title when present; en (or a missing Czech title) falls + // back to the EN title. + const resolveSectionTitle = (s: { + title: string | null; + title_cz: string | null; + }): string => + lang === "cs" && (s.title_cz || "").trim() + ? s.title_cz || "" + : s.title || ""; + // Visibility must use the SAME per-language title rule as the render + // loop — otherwise a section with only the other language's title + // would count as content and produce an empty sections block. + const visibleSections = sections.filter( + (s) => resolveSectionTitle(s).trim() || stripTags(s.content), + ); + let sectionsHtml = ""; + if (visibleSections.length > 0) { + sectionsHtml += '
'; + for (const section of visibleSections) { + const title = resolveSectionTitle(section); + const content = (section.content || "").trim(); + sectionsHtml += '
'; + if (title.trim()) + sectionsHtml += `
${escapeHtml(title)}
`; + if (content) + sectionsHtml += `
${cleanQuillHtml(DOMPurify.sanitize(content))}
`; + sectionsHtml += "
"; + } + sectionsHtml += "
"; + } // Quill indent CSS const indentCSS = buildQuillIndentCss(); @@ -496,7 +564,11 @@ export default async function invoicesPdfRoutes(