Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f268907848 | ||
|
|
dd8c699420 | ||
|
|
268a947c85 | ||
|
|
75dc97516e | ||
|
|
d1533ffc4d | ||
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 | ||
|
|
396a1d37ec | ||
|
|
ca1f07671f |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.4",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.4",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
|
||||
ADD COLUMN `locked_by` INTEGER NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_order_sections` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issued_order_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 `issued_order_sections_order_id`(`issued_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||
DROP COLUMN `issued_by`,
|
||||
DROP COLUMN `notes`,
|
||||
DROP COLUMN `payment_terms`;
|
||||
|
||||
@@ -347,8 +347,6 @@ model orders {
|
||||
status String? @default("prijata") @db.VarChar(30)
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||
scope_title String? @db.VarChar(500)
|
||||
scope_description String? @db.Text
|
||||
@@ -372,26 +370,40 @@ model issued_orders {
|
||||
supplier_id Int?
|
||||
status issued_orders_status @default(draft)
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
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)
|
||||
delivery_terms String? @db.VarChar(500)
|
||||
payment_terms String? @db.VarChar(500)
|
||||
issued_by String? @db.VarChar(255)
|
||||
notes String? @db.Text
|
||||
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")
|
||||
|
||||
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||
}
|
||||
|
||||
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
|
||||
// rendered on their own PDF page. Kept as a separate table (not shared) so the
|
||||
// FK cascade and per-document ordering stay simple.
|
||||
model issued_order_sections {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_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)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
|
||||
|
||||
@@index([issued_order_id], map: "issued_order_sections_order_id")
|
||||
}
|
||||
|
||||
model issued_order_items {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
@@ -400,7 +412,6 @@ model issued_order_items {
|
||||
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)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
||||
|
||||
@@ -493,8 +504,6 @@ model quotations {
|
||||
valid_until DateTime? @db.Date
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
order_id Int?
|
||||
status String @default("active") @db.VarChar(20)
|
||||
scope_title String? @db.VarChar(500)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import {
|
||||
previewIssuedOrderNumber,
|
||||
previewInvoiceNumber,
|
||||
@@ -19,23 +22,36 @@ import {
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
invalidateOffer,
|
||||
getOffer,
|
||||
listOffers,
|
||||
} from "../services/offers.service";
|
||||
import quotationsRoutes from "../routes/admin/quotations";
|
||||
|
||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||
const issuedOrderIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
const customerIds: number[] = [];
|
||||
const linkedOrderIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of issuedOrderIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
|
||||
for (const id of linkedOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of customerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
issuedOrderIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
linkedOrderIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
customerIds.length = 0;
|
||||
// Reset every sequence this suite may have touched so preview values are
|
||||
// stable across tests and we don't leak consumed numbers into other files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
@@ -43,6 +59,14 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** createOffer + narrow the token union + track cleanup. */
|
||||
async function mkOffer(body: Record<string, unknown> = {}) {
|
||||
const res = await createOffer(body);
|
||||
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
|
||||
offerIds.push(res.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issued orders */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -283,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — status transition table (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer status transitions (service)", () => {
|
||||
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await updateOffer(draft.id, { status: "ordered" });
|
||||
expect("error" in res && res.error).toBe("invalid_transition");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("allows active -> invalidated via update", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
|
||||
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const rejected = await invalidateOffer(draft.id);
|
||||
expect("error" in rejected && rejected.error).toBe("invalid_transition");
|
||||
|
||||
const ordered = await mkOffer({ status: "ordered" });
|
||||
const ok = await invalidateOffer(ordered.id);
|
||||
expect("error" in ok).toBe(false);
|
||||
|
||||
// invalidated is terminal — a second invalidate is rejected too.
|
||||
const again = await invalidateOffer(ordered.id);
|
||||
expect("error" in again && again.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("getOffer exposes valid_transitions computed from the current status", async () => {
|
||||
const draft = await mkOffer({});
|
||||
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
|
||||
|
||||
const active = await mkOffer({ status: "active" });
|
||||
expect((await getOffer(active.id))?.valid_transitions).toEqual([
|
||||
"ordered",
|
||||
"invalidated",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — editable-state guard (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer editable-state guard (service)", () => {
|
||||
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
|
||||
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
|
||||
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.project_code).toBe("PŮVODNÍ");
|
||||
});
|
||||
|
||||
it("rejects a field edit on an invalidated offer", async () => {
|
||||
const offer = await mkOffer({ status: "invalidated" });
|
||||
const res = await updateOffer(offer.id, { scope_title: "X" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
});
|
||||
|
||||
it("still allows the status-only ordered -> invalidated payload", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — customer FK handling (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer customer FK handling (service)", () => {
|
||||
async function mkCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "drafts_test_zákazník" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
|
||||
const res = await createOffer({ customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("update rejects a nonexistent customer with a clean token", async () => {
|
||||
const offer = await mkOffer({});
|
||||
const res = await updateOffer(offer.id, { customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
|
||||
const customer = await mkCustomer();
|
||||
const offer = await mkOffer({ customer_id: customer.id });
|
||||
expect(offer.customer_id).toBe(customer.id);
|
||||
|
||||
const res = await updateOffer(offer.id, { customer_id: null });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.customer_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — deterministic list ordering */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("listOffers deterministic ordering", () => {
|
||||
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
|
||||
const MARK = `TIEBREAK-${Date.now()}`;
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const o = await mkOffer({ project_code: MARK });
|
||||
ids.push(o.id);
|
||||
}
|
||||
// created_at is second-precision; pin all three to the SAME timestamp so
|
||||
// only the id tiebreak can order them.
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { created_at: new Date("2026-01-15T10:00:00") },
|
||||
});
|
||||
|
||||
const base = { page: 1, limit: 10, skip: 0, search: MARK };
|
||||
const desc = await listOffers({
|
||||
...base,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
});
|
||||
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => b - a),
|
||||
);
|
||||
|
||||
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
|
||||
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — route-level hardening (Czech messages + status codes) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("offers routes — hardening (HTTP)", () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||
|
||||
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "ZMĚNA" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("PUT rejects draft -> ordered with the transition message", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { status: "ordered" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe(
|
||||
'Neplatný přechod stavu z "draft" na "ordered"',
|
||||
);
|
||||
});
|
||||
|
||||
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { items: [{ description: "x".repeat(501) }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { customer_id: 99999999 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Zákazník nenalezen");
|
||||
});
|
||||
|
||||
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: `DRAFTSLINK-${Date.now()}`,
|
||||
quotation_id: offer.id,
|
||||
},
|
||||
});
|
||||
linkedOrderIds.push(order.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().error).toBe(
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
);
|
||||
// The offer survived the rejected delete.
|
||||
expect(
|
||||
await prisma.quotations.findUnique({ where: { id: offer.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
|
||||
const draft = await mkOffer({ project_code: "PŘED" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "PO" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: {
|
||||
entity_type: "quotation",
|
||||
entity_id: draft.id,
|
||||
action: "update",
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit!.old_values).toBeTruthy();
|
||||
expect(audit!.new_values).toBeTruthy();
|
||||
expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
|
||||
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
|
||||
// Unnumbered drafts read "koncept", never "null".
|
||||
expect(audit!.description).toContain("koncept");
|
||||
expect(audit!.description).not.toContain("null");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,6 @@ const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
@@ -146,6 +142,46 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
expect(project?.project_number).not.toBe(order?.order_number);
|
||||
expect(project?.order_id).toBe(orderId);
|
||||
});
|
||||
|
||||
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
||||
const draft = await prisma.quotations.create({
|
||||
data: { status: "draft", currency: "CZK", language: "cs" },
|
||||
});
|
||||
createdQuotationIds.push(draft.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "draft"');
|
||||
}
|
||||
// The offer is untouched — still a numberless draft.
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
expect(row!.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
||||
const invalidated = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-INVAL-${Date.now()}`,
|
||||
status: "invalidated",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(invalidated.id);
|
||||
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: invalidated.id,
|
||||
});
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "invalidated"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder releases the project number (release regression)", () => {
|
||||
|
||||
@@ -53,4 +53,29 @@ describe("NasDocumentManager issued-PDF round-trip", () => {
|
||||
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("re-saving the same number OVERWRITES in place — never a _1 duplicate", () => {
|
||||
const first = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v1"));
|
||||
const second = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v2"));
|
||||
|
||||
// Same canonical path both times, newest content wins.
|
||||
expect(second).toBe(first);
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
expect(fs.readdirSync(dir)).toEqual(["25P0004.pdf"]);
|
||||
expect(nas.readIssued(second!)!.data.toString()).toBe("v2");
|
||||
});
|
||||
|
||||
it("cleanIssued also sweeps stale _N duplicates left by the old racy save", () => {
|
||||
nas.saveIssuedPdf("25P0005", 2026, 6, Buffer.from("canonical"));
|
||||
// Simulate duplicates the pre-fix uniquePath save created during races.
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_1.pdf"), "stale");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_2.pdf"), "stale");
|
||||
// A DIFFERENT number must not be touched by the suffix match.
|
||||
nas.saveIssuedPdf("25P0006", 2026, 6, Buffer.from("other"));
|
||||
|
||||
nas.cleanIssued("25P0005");
|
||||
|
||||
expect(fs.readdirSync(dir).sort()).toEqual(["25P0006.pdf"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,18 +54,16 @@ function amountFor(
|
||||
}
|
||||
|
||||
describe("getOfferTotals per-currency aggregation", () => {
|
||||
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
|
||||
// (enrichQuotation math).
|
||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
||||
it("sums offer NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
|
||||
// not tax documents, no VAT anywhere).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOffer({
|
||||
status: "draft", // draft so no offer number is consumed
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a status filter narrows the result", async () => {
|
||||
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
const res = await createOffer({
|
||||
status,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
await mk("draft");
|
||||
await mk("invalidated");
|
||||
|
||||
// Marker only: all three count -> 3 x 1210 = 3630.
|
||||
// Marker only: all three count -> 3 x 1000 = 3000 (net).
|
||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3630);
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3000);
|
||||
|
||||
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
|
||||
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
|
||||
const onlyDraft = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "draft",
|
||||
});
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Invalidated alone -> 1210.
|
||||
// Invalidated alone -> 1000.
|
||||
const onlyInvalid = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "invalidated",
|
||||
});
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
|
||||
// Per-currency total aggregation for both order lists. These hit the real
|
||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
|
||||
// set, and that the where (month/year + status) is respected.
|
||||
// endpoints sum order NET total per currency across the WHOLE filtered set
|
||||
// (orders are not tax documents — no VAT anywhere), and that the where
|
||||
// (month/year + status) is respected.
|
||||
//
|
||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||
@@ -46,19 +47,17 @@ function amountFor(
|
||||
}
|
||||
|
||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
||||
// applied on the whole subtotal (enrichOrder math).
|
||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
||||
// (enrichOrder math — orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOrder({
|
||||
status: "prijata",
|
||||
currency,
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
@@ -122,48 +119,43 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
||||
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
||||
const onlyPrijata = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "prijata",
|
||||
});
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1210.
|
||||
// Next month sees only the one 'prijata' there -> 1000.
|
||||
const nextMonth = await getOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
||||
// order here the result matches the on-the-whole subtotal math.
|
||||
// CZK #1: 2 x 1000 @21% = 2420
|
||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. NET only
|
||||
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const o = await createIssuedOrder({
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: dateStr,
|
||||
items: [
|
||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||
createdIssuedIds.push(o.id);
|
||||
@@ -178,23 +170,19 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one already-sent (both 1210). Next month: one.
|
||||
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
||||
const draft = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
@@ -203,12 +191,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
const sent = await createIssuedOrder({
|
||||
status: "sent",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in sent)
|
||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||
@@ -216,37 +200,33 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
|
||||
const next = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: nextMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in next)
|
||||
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||
createdIssuedIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
||||
const onlySent = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "sent",
|
||||
});
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one order there -> 1210.
|
||||
// Next month sees only the one order there -> 1000.
|
||||
const nextStats = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,6 @@ const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
|
||||
247
src/__tests__/pdf-no-vat.test.ts
Normal file
247
src/__tests__/pdf-no-vat.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
|
||||
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||||
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||||
// is covered in issued-orders.test.ts.)
|
||||
|
||||
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||||
// total label alone carries the information. Pin its absence too.
|
||||
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||||
const EN_NOTE = "Prices are exclusive of VAT";
|
||||
|
||||
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||
const order = {
|
||||
order_number: "26730042",
|
||||
customer_order_number: "PO-XYZ",
|
||||
created_at: new Date("2026-06-09T12:00:00"),
|
||||
currency: "CZK",
|
||||
notes: null,
|
||||
customers: null,
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
description: "Materiál",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 100,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
];
|
||||
|
||||
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain(">DPH<");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||||
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
});
|
||||
|
||||
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||||
expect(html).not.toContain("VAT%");
|
||||
expect(html).toContain("Total excl. VAT");
|
||||
expect(html).not.toContain(EN_NOTE);
|
||||
});
|
||||
|
||||
it("excludes not-included lines from the NET total", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
...items,
|
||||
{
|
||||
description: "Mimo cenu",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 999,
|
||||
is_included_in_total: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||
expect(html).toContain("200,00 CZK");
|
||||
});
|
||||
|
||||
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
{
|
||||
description: "Velká položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||||
expect(html).toContain("2 000,00 CZK");
|
||||
expect(html).not.toContain("1 199,00 CZK");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offer PDF route (returns the HTML for the print view) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
const createdQuotationIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||||
// Direct prisma insert (explicit number → no sequence consumed).
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
expect(html).not.toContain("Celkem k úhradě");
|
||||
});
|
||||
|
||||
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Půldenní sazba",
|
||||
quantity: 1.5,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||||
// decimals elsewhere (covered by the previous test's qty 2).
|
||||
expect(html).toContain("1,50 / ks");
|
||||
expect(html).not.toContain(">2 / ks<");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||||
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||||
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||||
const invoice = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
items: [
|
||||
{
|
||||
description: "PDF-SHARED-MARKER",
|
||||
quantity: 2,
|
||||
unit_price: 1000,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
createdInvoiceIds.push(invoice.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
|
||||
expect(html).toContain("PDF-SHARED-MARKER");
|
||||
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||||
expect(html).toContain("2 000,00");
|
||||
expect(html).toContain("Celkem k úhradě");
|
||||
});
|
||||
});
|
||||
@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
it("rejects NaN string in top-level vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "bad",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: 21,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING vat_rate to a number", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "21",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.vat_rate).toBe(21);
|
||||
expect(typeof result.data.vat_rate).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects NaN string in item quantity", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
@@ -300,8 +272,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
||||
expect(b.success).toBe(true);
|
||||
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||
// Genuinely malformed dates are still rejected.
|
||||
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles";
|
||||
import { Modal, Button, TextField, Field } from "../ui";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
|
||||
// string while editing (so a field can be cleared — empty renders fine in a
|
||||
// Editable line-item. quantity/unit_price are held as the raw typed string
|
||||
// while editing (so a field can be cleared — empty renders fine in a
|
||||
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
||||
// being handed to onGenerate (the parent posts them verbatim).
|
||||
interface ConfirmationItem {
|
||||
@@ -17,7 +17,6 @@ interface ConfirmationItem {
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: string | number;
|
||||
}
|
||||
|
||||
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
||||
@@ -28,21 +27,14 @@ interface GeneratedItem {
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
interface OrderConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
items?: GeneratedItem[],
|
||||
) => Promise<void>;
|
||||
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
|
||||
initialItems: ConfirmationItem[];
|
||||
orderNumber: string;
|
||||
defaultVatRate: number;
|
||||
applyVat: boolean;
|
||||
}
|
||||
|
||||
export default function OrderConfirmationModal({
|
||||
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
|
||||
onGenerate,
|
||||
initialItems,
|
||||
orderNumber,
|
||||
defaultVatRate,
|
||||
applyVat,
|
||||
}: OrderConfirmationModalProps) {
|
||||
const alert = useAlert();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||
const [lang, setLang] = useState<string>("cs");
|
||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
|
||||
if (!isOpen) return;
|
||||
setStep("choose");
|
||||
setLang("cs");
|
||||
setApplyVatState(applyVat);
|
||||
setItems(initialItems);
|
||||
// initialItems/applyVat are captured at open time; intentionally not in the
|
||||
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||
// initialItems are captured at open time; intentionally not in the dep
|
||||
// array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleUseExisting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, undefined);
|
||||
await onGenerate(lang, undefined);
|
||||
// Only close on success — a generation error must keep the modal open.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
|
||||
unit: it.unit,
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: it.is_included_in_total,
|
||||
vat_rate: Number(it.vat_rate) || 0,
|
||||
}));
|
||||
await onGenerate(lang, applyVatState, coercedItems);
|
||||
await onGenerate(lang, coercedItems);
|
||||
// Only close on success — on error keep the user's edited items intact.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
vat_rate: defaultVatRate,
|
||||
},
|
||||
]);
|
||||
}, [defaultVatRate]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={applyVatState ? "contained" : "outlined"}
|
||||
color={applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(true)}
|
||||
>
|
||||
S DPH
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={!applyVatState ? "contained" : "outlined"}
|
||||
color={!applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(false)}
|
||||
>
|
||||
Bez DPH
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="Obsah potvrzení">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
restrictToVerticalAxis,
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button, Card, TextField } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
/**
|
||||
* One sortable line item of a document (offer / issued order). The numeric
|
||||
* fields are held as the raw typed string while editing so the input can be
|
||||
* cleared (empty renders fine in a type="number" input); coerce via
|
||||
* `Number(x) || 0` only where used (live totals + save payload).
|
||||
*/
|
||||
export interface DocumentItem {
|
||||
_key: string;
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Offers only — undefined (issued orders) counts as included. */
|
||||
is_included_in_total?: boolean;
|
||||
}
|
||||
|
||||
let documentItemKeyCounter = 0;
|
||||
|
||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||
export const nextDocumentItemKey = (): string =>
|
||||
`item-${++documentItemKeyCounter}`;
|
||||
|
||||
export const emptyDocumentItem = (): DocumentItem => ({
|
||||
_key: nextDocumentItemKey(),
|
||||
description: "",
|
||||
item_description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
/** NET total over the included items (documents here carry no VAT). */
|
||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||
items.reduce(
|
||||
(sum, it) =>
|
||||
it.is_included_in_total === false
|
||||
? sum
|
||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const DragIcon = (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="5" r="1.5" />
|
||||
<circle cx="15" cy="5" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" />
|
||||
<circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="19" r="1.5" />
|
||||
<circle cx="15" cy="19" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function SortableItemRow({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
readOnly,
|
||||
canDelete,
|
||||
showIncludedInTotal,
|
||||
itemDescriptionMaxLength,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DocumentItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
canDelete: boolean;
|
||||
showIncludedInTotal: boolean;
|
||||
itemDescriptionMaxLength: number;
|
||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item._key, disabled: readOnly });
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
background: isDragging ? "var(--mui-palette-action-hover)" : undefined,
|
||||
position: "relative" as const,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.25,
|
||||
bgcolor: "background.paper",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{ cursor: "grab", color: "text.secondary" }}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {index + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate("description", e.target.value)}
|
||||
placeholder="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Podrobný popis"
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jedn. cena"
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: showIncludedInTotal ? "space-between" : "flex-end",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
pt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{showIncludedInTotal && (
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
cursor: readOnly ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) =>
|
||||
onUpdate("is_included_in_total", e.target.checked)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Typography variant="body2">V ceně</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Celkem
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} sx={style}>
|
||||
{/* Stable column layout: every cell renders in read-only mode too —
|
||||
only the controls inside are hidden. */}
|
||||
<TableCell sx={{ width: "2rem", px: 0.5 }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{ cursor: "grab", color: "text.secondary" }}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="center"
|
||||
sx={{ color: "text.secondary", fontWeight: 500 }}
|
||||
>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: "top" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate("description", e.target.value)}
|
||||
placeholder="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
sx={{ "& input": { fontWeight: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
opacity: 0.8,
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentItemsEditorProps {
|
||||
items: DocumentItem[];
|
||||
onChange: (items: DocumentItem[]) => void;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
/** Validation error shown under the section title. */
|
||||
error?: string;
|
||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||
showIncludedInTotal?: boolean;
|
||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||
itemDescriptionMaxLength?: number;
|
||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable line-items table shared by the document detail pages (offers,
|
||||
* issued orders): drag-and-drop reorder, mobile card layout, live NET totals
|
||||
* ("Celkem bez DPH" — these documents are not tax documents).
|
||||
*/
|
||||
export default function DocumentItemsEditor({
|
||||
items,
|
||||
onChange,
|
||||
currency,
|
||||
readOnly,
|
||||
error,
|
||||
showIncludedInTotal = false,
|
||||
itemDescriptionMaxLength = 5000,
|
||||
templatesSlot,
|
||||
}: DocumentItemsEditorProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 200, tolerance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const updateItem = (
|
||||
index: number,
|
||||
field: keyof DocumentItem,
|
||||
value: string | boolean,
|
||||
) =>
|
||||
onChange(
|
||||
items.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
|
||||
);
|
||||
|
||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length <= 1) return;
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = items.findIndex((i) => i._key === String(active.id));
|
||||
const newIndex = items.findIndex((i) => i._key === String(over.id));
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
onChange(arrayMove(items, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const total = documentItemsTotal(items);
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DndContext
|
||||
sensors={dndSensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((i) => i._key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
currency={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer sx={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 720,
|
||||
"& td, & th": { borderColor: "divider" },
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: "2rem" }} />
|
||||
<TableCell sx={{ width: "2rem" }} align="center">
|
||||
#
|
||||
</TableCell>
|
||||
<TableCell>Popis</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Množství
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
Jednotka
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
Celkem
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
currency={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) =>
|
||||
updateItem(index, field, value)
|
||||
}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Totals (NET only — offers/issued orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
ml: "auto",
|
||||
maxWidth: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
pt: 0.5,
|
||||
mt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(total, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/admin/components/document/LockBanner.tsx
Normal file
55
src/admin/components/document/LockBanner.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import type { DocumentLockInfo } from "../../hooks/useDocumentLock";
|
||||
|
||||
/**
|
||||
* Warning banner shown when another user holds the document edit lock
|
||||
* (extracted from OfferDetail; shared by the document detail pages).
|
||||
* Renders nothing when the lock is free / held by the current user.
|
||||
*
|
||||
* `entityWord` is the Czech accusative for the document ("Nabídku" /
|
||||
* "Objednávku") — both are feminine, so the rest of the sentence is shared.
|
||||
*/
|
||||
export default function LockBanner({
|
||||
lockedBy,
|
||||
entityWord,
|
||||
}: {
|
||||
lockedBy: DocumentLockInfo | null;
|
||||
entityWord: string;
|
||||
}) {
|
||||
if (!lockedBy) return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor:
|
||||
"color-mix(in srgb, var(--mui-palette-warning-main) 15%, transparent)",
|
||||
border: 1,
|
||||
borderColor: "warning.main",
|
||||
borderRadius: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
fontSize: "0.875rem",
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
{entityWord} právě upravuje <strong>{lockedBy.full_name}</strong>.
|
||||
Můžete ji pouze prohlížet.
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
src/admin/components/document/SectionsEditor.tsx
Normal file
315
src/admin/components/document/SectionsEditor.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import RichEditor from "../RichEditor";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
TextField,
|
||||
Field,
|
||||
StatusChip,
|
||||
EmptyState,
|
||||
} from "../../ui";
|
||||
|
||||
/** One bilingual rich-text PDF section (offers' scope ≡ issued orders' sections). */
|
||||
export interface DocumentSection {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const emptyDocumentSection = (): DocumentSection => ({
|
||||
title: "",
|
||||
title_cz: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SectionsEditorProps {
|
||||
sections: DocumentSection[];
|
||||
onChange: (sections: DocumentSection[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Card heading. */
|
||||
title?: string;
|
||||
/**
|
||||
* Document language — "CZ"/"cs" prefers the Czech section title in the
|
||||
* "Sekce N — …" header line, anything else the English one.
|
||||
*/
|
||||
language?: string;
|
||||
/** Optional page-specific control (e.g. scope-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilingual rich-text sections editor shared by the document detail pages
|
||||
* (lifted from OfferDetail's "Rozsah projektu" card): bordered section boxes
|
||||
* with EN/CZ title fields (StatusChip badges), Quill content, up/down
|
||||
* reorder and add/remove. Offers pass their template Select via
|
||||
* `templatesSlot`; issued orders don't (no templates on POs).
|
||||
*/
|
||||
export default function SectionsEditor({
|
||||
sections,
|
||||
onChange,
|
||||
readOnly,
|
||||
title = "Rozsah projektu",
|
||||
language,
|
||||
templatesSlot,
|
||||
}: SectionsEditorProps) {
|
||||
const preferCz =
|
||||
(language ?? "").toLowerCase().startsWith("cs") ||
|
||||
(language ?? "").toUpperCase() === "CZ";
|
||||
|
||||
const updateSection = (
|
||||
index: number,
|
||||
field: keyof DocumentSection,
|
||||
value: string,
|
||||
) =>
|
||||
onChange(
|
||||
sections.map((s, i) => (i === index ? { ...s, [field]: value } : s)),
|
||||
);
|
||||
|
||||
const moveSection = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= sections.length) return;
|
||||
const arr = [...sections];
|
||||
[arr[index], arr[target]] = [arr[target], arr[index]];
|
||||
onChange(arr);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onChange([...sections, emptyDocumentSection()])}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat sekci
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={
|
||||
templatesSlot
|
||||
? 'Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.'
|
||||
: 'Žádné sekce rozsahu. Klikněte na "Přidat sekci".'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
{sections.map((section, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Sekce {idx + 1}
|
||||
{(preferCz ? section.title_cz : section.title) && (
|
||||
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
|
||||
—{" "}
|
||||
{preferCz
|
||||
? section.title_cz || section.title
|
||||
: section.title}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 0.25 }}>
|
||||
{idx > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, -1)}
|
||||
title="Posunout nahoru"
|
||||
aria-label="Posunout nahoru"
|
||||
>
|
||||
{UpIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{idx < sections.length - 1 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, 1)}
|
||||
title="Posunout dolů"
|
||||
aria-label="Posunout dolů"
|
||||
>
|
||||
{DownIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() =>
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
title="Odebrat sekci"
|
||||
aria-label="Odebrat sekci"
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<StatusChip label="EN" color="info" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title}
|
||||
onChange={(e) =>
|
||||
updateSection(idx, "title", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (anglicky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<StatusChip label="CZ" color="success" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title_cz}
|
||||
onChange={(e) =>
|
||||
updateSection(idx, "title_cz", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (česky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Field label="Obsah">
|
||||
<RichEditor
|
||||
value={section.content}
|
||||
onChange={(val) => updateSection(idx, "content", val)}
|
||||
placeholder="Obsah sekce..."
|
||||
minHeight="120px"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
81
src/admin/hooks/useDocumentLock.ts
Normal file
81
src/admin/hooks/useDocumentLock.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
/** Lock holder as enriched by the detail endpoints (offers, issued orders). */
|
||||
export interface DocumentLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side of the document edit lock shared by the offer / issued-order
|
||||
* detail pages (extracted from OfferDetail). The server exposes the same
|
||||
* trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`,
|
||||
* `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed
|
||||
* 10 s beats, so background-tab interval throttling can't hand the lock away).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - `lockedBy` state — the page hydrates it from the detail response via
|
||||
* `setLockedBy(detail.locked_by ?? null)`.
|
||||
* - `acquire()` — fire-and-forget lock acquisition; the page calls it from
|
||||
* its one-shot hydration effect when the doc is editable, the user holds
|
||||
* the edit permission and nobody else holds the lock.
|
||||
* - 10 s heartbeat while `enabled` (and not locked by another user), with
|
||||
* hard-401 self-stop: a 401 here means the access token expired AND the
|
||||
* refresh failed (apiFetch retries recoverable 401s itself), so we stop
|
||||
* the interval instead of spamming a dead session every 10 s.
|
||||
* - unlock on unmount/disable, with an AbortController so a stale in-flight
|
||||
* unlock can be cancelled by the next one.
|
||||
*/
|
||||
export function useDocumentLock({
|
||||
baseUrl,
|
||||
enabled,
|
||||
}: {
|
||||
/** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */
|
||||
baseUrl: string | null;
|
||||
/**
|
||||
* External conditions for holding the lock: edit mode + editable status +
|
||||
* edit permission. The hook adds `!isLockedByOther` itself.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [lockedBy, setLockedBy] = useState<DocumentLockInfo | null>(null);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlockAbortRef = useRef<AbortController | null>(null);
|
||||
const isLockedByOther = lockedBy !== null;
|
||||
|
||||
const acquire = useCallback(() => {
|
||||
if (!baseUrl) return;
|
||||
apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {});
|
||||
}, [baseUrl]);
|
||||
|
||||
// Heartbeat to keep the lock alive + release on unmount/disable.
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !enabled || isLockedByOther) return;
|
||||
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
apiFetch(`${baseUrl}/heartbeat`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.status === 401 && heartbeatRef.current) {
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10 * 1000); // every 10 seconds
|
||||
|
||||
return () => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
if (unlockAbortRef.current) unlockAbortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
unlockAbortRef.current = controller;
|
||||
apiFetch(`${baseUrl}/unlock`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [baseUrl, enabled, isLockedByOther]);
|
||||
|
||||
return { lockedBy, isLockedByOther, setLockedBy, acquire };
|
||||
}
|
||||
86
src/admin/hooks/useDocumentPdf.ts
Normal file
86
src/admin/hooks/useDocumentPdf.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
/**
|
||||
* Shared "Zobrazit PDF" handlers for the document pages (offers, issued
|
||||
* orders) — both call their GET `…/:id/file` endpoint (archived NAS PDF with
|
||||
* live-render fallback). Hardened flow extracted from OfferDetail:
|
||||
* - pre-opens the tab synchronously (popup blockers only allow it inside the
|
||||
* click's call stack), then streams the blob URL into it;
|
||||
* - double-click guard via a ref (state updates are async, a ref is not);
|
||||
* - 401 → silently close the tab (apiFetch already flagged the dead session);
|
||||
* - blob URLs are revoked after 60 s; pending revoke timeouts are cleared on
|
||||
* unmount so they can't fire into a dead page.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List variant — ONE hook instance serves every row. `openPdf(id, url)`
|
||||
* fetches that row's PDF; `pdfLoadingId` reports which row is in flight (use
|
||||
* it for the per-row spinner + disabled state).
|
||||
*/
|
||||
export function useDocumentListPdf() {
|
||||
const alert = useAlert();
|
||||
const [pdfLoadingId, setPdfLoadingId] = useState<number | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// The array object is stable (we only push to it, never reassign), so
|
||||
// capturing it here keeps the cleanup in sync with the ref's content.
|
||||
const timeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPdf = useCallback(
|
||||
async (id: number, url: string) => {
|
||||
if (loadingRef.current) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
loadingRef.current = true;
|
||||
setPdfLoadingId(id);
|
||||
try {
|
||||
const response = await apiFetch(url);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrl;
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setPdfLoadingId(null);
|
||||
}
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { openPdf, pdfLoadingId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail variant — a single fixed URL, `openPdf()` takes no arguments.
|
||||
* Implemented on top of the list variant so the hardened flow lives once.
|
||||
*/
|
||||
export function useDocumentPdf(url: string | null) {
|
||||
const { openPdf: openListPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const openPdf = useCallback(async () => {
|
||||
if (!url) return;
|
||||
await openListPdf(0, url);
|
||||
}, [url, openListPdf]);
|
||||
|
||||
return { openPdf, pdfLoading: pdfLoadingId !== null };
|
||||
}
|
||||
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Dirty-form guard shared by the document detail pages (extracted from
|
||||
* OfferDetail). Serializes `snapshot` (typically `{ form, items, sections }`)
|
||||
* every render, captures a baseline on the first `ready` render — or
|
||||
* explicitly via `markClean` — and registers a `beforeunload` warning while
|
||||
* the current snapshot differs from the baseline.
|
||||
*
|
||||
* `markClean(value?)`:
|
||||
* - with a value: baseline = that value (use from a hydration effect, where
|
||||
* the freshly-mapped objects are ahead of the not-yet-rendered state);
|
||||
* - without: baseline = the last RENDERED snapshot (use after a successful
|
||||
* save, where the handler's closure state equals the rendered state).
|
||||
*/
|
||||
export function useUnsavedChangesGuard(snapshot: unknown, ready: boolean) {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const serializedRef = useRef(serialized);
|
||||
serializedRef.current = serialized;
|
||||
const baselineRef = useRef<string | null>(null);
|
||||
if (ready && baselineRef.current === null) baselineRef.current = serialized;
|
||||
const isDirty =
|
||||
baselineRef.current !== null && serialized !== baselineRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const markClean = useCallback((value?: unknown) => {
|
||||
baselineRef.current =
|
||||
value !== undefined ? JSON.stringify(value) : serializedRef.current;
|
||||
}, []);
|
||||
|
||||
return { isDirty, markClean };
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
// Single shared definition (type-only import, erased at compile time) — the
|
||||
// canonical CurrencyAmount lives in offers.ts; re-exported here so existing
|
||||
// importers keep working.
|
||||
import type { CurrencyAmount } from "./offers";
|
||||
|
||||
export type { CurrencyAmount };
|
||||
|
||||
export interface IssuedOrder {
|
||||
id: number;
|
||||
@@ -9,8 +15,7 @@ export interface IssuedOrder {
|
||||
status: string;
|
||||
currency: string | null;
|
||||
order_date: string | null;
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
// NET total — issued orders carry no VAT (not tax documents).
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -32,24 +37,34 @@ export interface IssuedOrderItem {
|
||||
quantity: number | string | null;
|
||||
unit: string | null;
|
||||
unit_price: number | string | null;
|
||||
vat_rate: number | string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** Rich-text CZ/EN PDF section (mirrors offers' scope sections). */
|
||||
export interface IssuedOrderSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface IssuedOrderDetail extends IssuedOrder {
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: number | string | null;
|
||||
exchange_rate: number | string | null;
|
||||
delivery_date: string | null;
|
||||
language: string | null;
|
||||
delivery_terms: string | null;
|
||||
payment_terms: string | null;
|
||||
issued_by: string | null;
|
||||
notes: string | null;
|
||||
order_text: string | null;
|
||||
internal_notes: string | null;
|
||||
items: IssuedOrderItem[];
|
||||
sections: IssuedOrderSection[];
|
||||
supplier: Record<string, unknown> | null;
|
||||
valid_transitions: string[];
|
||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||
locked_by: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
|
||||
@@ -68,6 +83,7 @@ export const issuedOrderListOptions = (filters: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -81,6 +97,8 @@ export const issuedOrderListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -90,14 +108,10 @@ export const issuedOrderListOptions = (filters: {
|
||||
},
|
||||
});
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const issuedOrderStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -107,6 +121,8 @@ export const issuedOrderStatsOptions = (filters: {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -123,4 +139,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
|
||||
queryFn: () =>
|
||||
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
|
||||
enabled: !!id,
|
||||
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
||||
// GETs and keeps the detail page on an infinite spinner.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const issuedOrderNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/issued-orders/next-number",
|
||||
).then((d) => d?.next_number || d?.number || ""),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,32 @@ export interface ApiError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full success envelope (`data` + server `message`) — the result shape of a
|
||||
* mutation created with `envelope: true`. Used by the document detail pages
|
||||
* (offers, issued orders) to pass the server's Czech success message through
|
||||
* to the toast instead of a hardcoded client string.
|
||||
*/
|
||||
export interface ApiEnvelope<T> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czech-safe error message for toasts: returns the server-provided message
|
||||
* from an `ApiError` (those carry `.status` and a Czech `error` string), and
|
||||
* falls back when the message is a client-generated English string — network
|
||||
* failures ("Failed to fetch" has no status), "Invalid JSON response",
|
||||
* "Unauthorized" (401) or the generic "Request failed (NNN)".
|
||||
*/
|
||||
export function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const status = (err as ApiError | null | undefined)?.status;
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
if (!message || status === undefined || status === 401) return fallback;
|
||||
if (/^Request failed \(\d+\)$/.test(message)) return fallback;
|
||||
return message;
|
||||
}
|
||||
|
||||
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface ApiResponseBody<T> {
|
||||
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
body?: TIn;
|
||||
envelope?: boolean;
|
||||
}): Promise<TOut> {
|
||||
const url =
|
||||
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
|
||||
@@ -59,6 +86,9 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.envelope) {
|
||||
return { data: result.data, message: result.message } as TOut;
|
||||
}
|
||||
return result.data as TOut;
|
||||
}
|
||||
|
||||
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||
invalidate?: readonly string[];
|
||||
/**
|
||||
* When true, the mutation resolves with the full `ApiEnvelope` (`{ data,
|
||||
* message }`) instead of the bare `data` — declare `TOut` as
|
||||
* `ApiEnvelope<...>` accordingly.
|
||||
*/
|
||||
envelope?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +125,12 @@ export function useApiMutation<TIn, TOut>(
|
||||
opts: ApiMutationOptions<TIn, TOut> &
|
||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||
) {
|
||||
const { url, method, invalidate, onSuccess, ...rest } = opts;
|
||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TOut, ApiError, TIn, unknown>({
|
||||
mutationFn: (input: TIn) =>
|
||||
performMutation<TIn, TOut>({ url, method, body: input }),
|
||||
performMutation<TIn, TOut>({ url, method, body: input, envelope }),
|
||||
...rest,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (invalidate) {
|
||||
|
||||
@@ -52,7 +52,9 @@ export interface Customer {
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
// Under the broad ["offers"] domain so offer-domain invalidations
|
||||
// (customer CRUD, offer mutations) refresh the picker automatically.
|
||||
queryKey: ["offers", "customers"],
|
||||
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
|
||||
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||
});
|
||||
|
||||
/** Offer list row (the shape returned by GET /api/admin/offers). */
|
||||
export interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
export const offerListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<Quotation>(
|
||||
`/api/admin/offers${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,13 +176,13 @@ export interface OfferDetailData {
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
items?: OfferItemData[];
|
||||
sections?: OfferSectionData[];
|
||||
status: string;
|
||||
order: OfferOrderInfo | null;
|
||||
locked_by: OfferLockInfo | null;
|
||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||
valid_transitions: string[];
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
|
||||
@@ -43,8 +43,6 @@ export interface OrderData {
|
||||
status: string;
|
||||
notes: string;
|
||||
attachment_name?: string;
|
||||
apply_vat: number | boolean;
|
||||
vat_rate: number;
|
||||
language?: string;
|
||||
items: OrderItem[];
|
||||
sections: OrderSection[];
|
||||
|
||||
@@ -787,13 +787,12 @@ export default function InvoiceDetail() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Pre-fill from order
|
||||
// Pre-fill from order. Orders no longer carry VAT (not tax documents) —
|
||||
// the invoice decides its own VAT: default rate from company settings,
|
||||
// apply_vat on.
|
||||
if (fromOrderId && orderDataQuery.data) {
|
||||
const order = orderDataQuery.data;
|
||||
const vatRate = numberOr(
|
||||
order.vat_rate,
|
||||
companySettings?.default_vat_rate ?? 21,
|
||||
);
|
||||
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: order.customer_id as number,
|
||||
@@ -803,7 +802,7 @@ export default function InvoiceDetail() {
|
||||
(order.currency as string) ||
|
||||
companySettings?.default_currency ||
|
||||
"CZK",
|
||||
apply_vat: Number(order.apply_vat) || 0,
|
||||
apply_vat: 1,
|
||||
vat_rate: vatRate,
|
||||
}));
|
||||
const orderItems = order.items as Record<string, unknown>[] | undefined;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -10,18 +12,21 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
czechPlural,
|
||||
} from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
issuedOrderListOptions,
|
||||
issuedOrderStatsOptions,
|
||||
issuedOrderSuppliersOptions,
|
||||
type IssuedOrder,
|
||||
} from "../lib/queries/issued-orders";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
@@ -44,9 +49,15 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
// DELIBERATE deviation from the Offers status-Tabs row: this page is embedded
|
||||
// under the parent Orders page, which already renders the identical centered
|
||||
// kit <Tabs> (Vydané/Přijaté) directly above this component — a second
|
||||
// same-styled tab row would visually fight it (and the sibling "Přijaté" tab
|
||||
// filters via a Select too). So the status filter stays a Select, with the
|
||||
// "all" entry relabeled to match the offers tabs ("Všechny").
|
||||
const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny stavy",
|
||||
label: "Všechny",
|
||||
});
|
||||
|
||||
const ViewIcon = (
|
||||
@@ -62,6 +73,19 @@ const ViewIcon = (
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const PdfIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [status, setStatus] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
// Track first successful load so later refetches (filter/status/page change)
|
||||
// keep the table visible instead of flashing the full-page skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const { data: suppliers } = useQuery(issuedOrderSuppliersOptions());
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the Offers list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
order: IssuedOrder | null;
|
||||
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage: 20,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
issuedOrderStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -176,30 +208,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async (o: IssuedOrder) => {
|
||||
// Authenticated open: apiFetch attaches the access token, then we hand the
|
||||
// resulting PDF blob to a window. A raw window.open(url) would be an
|
||||
// unauthenticated top-level navigation and 401 on the token-guarded route.
|
||||
const newWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba při generování PDF");
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/status/page change) keep the table visible (the Card
|
||||
// dims via isFetching) so it doesn't flash.
|
||||
@@ -234,6 +242,14 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
width: "24%",
|
||||
render: (o) => o.supplier_name || "—",
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
@@ -246,14 +262,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
@@ -268,39 +276,52 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
render: (o) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/orders/issued/${o.id}`)}
|
||||
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
>
|
||||
{ViewIcon}
|
||||
</IconButton>
|
||||
{hasPermission("orders.view") && (
|
||||
render: (o) => {
|
||||
// Offers' semantics: read-only rows (completed/cancelled) get the
|
||||
// view icon, editable rows get the edit icon.
|
||||
const readOnly = o.status === "completed" || o.status === "cancelled";
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleExportPdf(o)}
|
||||
aria-label="PDF"
|
||||
title="PDF"
|
||||
onClick={() => navigate(`/orders/issued/${o.id}`)}
|
||||
aria-label={readOnly ? "Zobrazit" : "Upravit"}
|
||||
title={readOnly ? "Zobrazit" : "Upravit"}
|
||||
>
|
||||
{PdfIcon}
|
||||
{readOnly ? ViewIcon : EditIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("orders.view") && o.po_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
|
||||
}
|
||||
aria-label="Zobrazit objednávku"
|
||||
title="Zobrazit objednávku"
|
||||
disabled={pdfLoadingId === o.id}
|
||||
>
|
||||
{pdfLoadingId === o.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -326,12 +347,25 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
return {};
|
||||
};
|
||||
|
||||
// Search/status filter is active → an empty list means "nothing matches"
|
||||
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
|
||||
const isFiltered = !!debouncedSearch || !!status;
|
||||
// Search/status/supplier filter is active → an empty list means "nothing
|
||||
// matches" (no create CTA); a genuinely empty list keeps the create-CTA
|
||||
// empty state.
|
||||
const isFiltered = !!debouncedSearch || !!status || !!supplierFilter;
|
||||
|
||||
const total = pagination?.total ?? orders.length;
|
||||
const countLine = `${total} ${czechPlural(
|
||||
total,
|
||||
"objednávka",
|
||||
"objednávky",
|
||||
"objednávek",
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
{countLine}
|
||||
</Typography>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={supplierFilter === "" ? "" : String(supplierFilter)}
|
||||
onChange={(value) => {
|
||||
setSupplierFilter(value ? Number(value) : "");
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všichni dodavatelé" },
|
||||
...(suppliers ?? []).map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
isFiltered ? (
|
||||
<EmptyState title="Žádné objednávky neodpovídají filtru." />
|
||||
<EmptyState
|
||||
title="Žádné objednávky neodpovídají filtru"
|
||||
description="Zkuste změnit filtr nebo hledaný výraz."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku."
|
||||
: undefined
|
||||
action={
|
||||
hasPermission("orders.create") ? (
|
||||
<Button component={RouterLink} to="/orders/issued/new">
|
||||
Vytvořit objednávku
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -392,7 +447,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
deleteConfirm.order
|
||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
|
||||
? `Opravdu chcete smazat objednávku „${documentNumberLabel(deleteConfirm.order.po_number)}“? Budou smazány i všechny položky. Tato akce je nevratná.`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
type Quotation,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||
label: "Všechny",
|
||||
}).filter((o) => o.value !== "expired");
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
const TemplatesIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -336,25 +324,17 @@ export default function Offers() {
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [invalidating, setInvalidating] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the issued list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
const [orderModal, setOrderModal] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
@@ -490,13 +470,10 @@ export default function Offers() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.quotation) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,37 +489,6 @@ export default function Offers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (quotation: Quotation) => {
|
||||
if (pdfLoading) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
setPdfLoading(quotation.id);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/offers/${quotation.id}/file`,
|
||||
);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setPdfLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||
// Card dims via isFetching) so it doesn't flash.
|
||||
@@ -573,6 +519,7 @@ export default function Offers() {
|
||||
header: "Číslo",
|
||||
width: "13%",
|
||||
sortKey: "quotation_number",
|
||||
mono: true,
|
||||
render: (q) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
@@ -597,7 +544,7 @@ export default function Offers() {
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Zákazník",
|
||||
width: "13%",
|
||||
width: "16%",
|
||||
render: (q) => q.customer_name || "—",
|
||||
},
|
||||
{
|
||||
@@ -637,17 +584,10 @@ export default function Offers() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
header: "Měna",
|
||||
width: "7%",
|
||||
sortKey: "currency",
|
||||
render: (q) => <StatusChip label={q.currency} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "11%",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
@@ -693,7 +633,9 @@ export default function Offers() {
|
||||
{OrderViewIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
!readOnly &&
|
||||
// Only active offers can spawn an order — the server transition
|
||||
// guard rejects drafts/invalidated with a 400.
|
||||
q.status === "active" &&
|
||||
hasPermission("orders.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -714,15 +656,20 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("offers.view") && (
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("offers.view") && q.quotation_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
|
||||
aria-label="Zobrazit nabídku"
|
||||
title="Zobrazit nabídku"
|
||||
disabled={pdfLoading === q.id}
|
||||
disabled={pdfLoadingId === q.id}
|
||||
>
|
||||
{pdfLoading === q.id ? <CircularProgress size={16} /> : PdfIcon}
|
||||
{pdfLoadingId === q.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("offers.delete") && (
|
||||
@@ -906,7 +853,7 @@ export default function Offers() {
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -927,10 +874,10 @@ export default function Offers() {
|
||||
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat nabídku"
|
||||
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
message={`Opravdu chcete smazat nabídku „${documentNumberLabel(deleteConfirm.quotation?.quotation_number)}“? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
loading={deleteOfferMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -938,7 +885,7 @@ export default function Offers() {
|
||||
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleInvalidate}
|
||||
title="Zneplatnit nabídku"
|
||||
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
message={`Opravdu chcete zneplatnit nabídku „${documentNumberLabel(invalidateConfirm.quotation?.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
confirmText="Zneplatnit"
|
||||
confirmVariant="danger"
|
||||
loading={invalidating}
|
||||
|
||||
@@ -198,7 +198,8 @@ export default function OffersCustomers() {
|
||||
? `${API_BASE}/customers/${editingCustomer.id}`
|
||||
: `${API_BASE}/customers`,
|
||||
method: () => (editingCustomer ? "PUT" : "POST"),
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
closeModal();
|
||||
setTimeout(
|
||||
@@ -214,7 +215,8 @@ export default function OffersCustomers() {
|
||||
>({
|
||||
url: (id) => `${API_BASE}/customers/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
setDeleteConfirm({ show: false, customer: null });
|
||||
alert.success(data?.message || "Zákazník byl smazán");
|
||||
|
||||
@@ -144,9 +144,10 @@ export default function OrderDetail() {
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
// NET only — received orders are not tax documents (no VAT on them).
|
||||
const totals = useMemo(() => {
|
||||
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
||||
const subtotal = order.items.reduce((sum, item) => {
|
||||
if (!order?.items) return { total: 0 };
|
||||
const total = order.items.reduce((sum, item) => {
|
||||
if (Number(item.is_included_in_total)) {
|
||||
return (
|
||||
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||
@@ -154,10 +155,7 @@ export default function OrderDetail() {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const vatAmount = Number(order.apply_vat)
|
||||
? subtotal * ((Number(order.vat_rate) || 0) / 100)
|
||||
: 0;
|
||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
||||
return { total };
|
||||
}, [order]);
|
||||
|
||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||
@@ -236,14 +234,12 @@ export default function OrderDetail() {
|
||||
|
||||
const handleGenerateConfirmation = async (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
customItems?: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}>,
|
||||
) => {
|
||||
setConfirmationLoading(true);
|
||||
@@ -253,7 +249,7 @@ export default function OrderDetail() {
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lang, applyVat, items: customItems }),
|
||||
body: JSON.stringify({ lang, items: customItems }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -625,7 +621,7 @@ export default function OrderDetail() {
|
||||
empty={<EmptyState title="Žádné položky." />}
|
||||
/>
|
||||
|
||||
{/* Totals */}
|
||||
{/* Totals (NET only — orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
@@ -636,30 +632,6 @@ export default function OrderDetail() {
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mezisoučet:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(totals.subtotal, order.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{Number(order.apply_vat) > 0 && (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
DPH ({order.vat_rate}%):
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(totals.vatAmount, order.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -671,7 +643,7 @@ export default function OrderDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem k úhradě:
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -829,11 +801,8 @@ export default function OrderDetail() {
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: Number(it.is_included_in_total) !== 0,
|
||||
vat_rate: Number(order.vat_rate) || 21,
|
||||
}))}
|
||||
orderNumber={order.order_number}
|
||||
defaultVatRate={Number(order.vat_rate) || 21}
|
||||
applyVat={!!order.apply_vat}
|
||||
/>
|
||||
)}
|
||||
</PageEnter>
|
||||
|
||||
@@ -184,8 +184,6 @@ export default function OrdersReceived({
|
||||
customer_id: "",
|
||||
customer_order_number: "",
|
||||
currency: "CZK",
|
||||
vat_rate: "21",
|
||||
apply_vat: true,
|
||||
scope_title: "",
|
||||
scope_description: "",
|
||||
notes: "",
|
||||
@@ -219,8 +217,6 @@ export default function OrdersReceived({
|
||||
customer_id: "",
|
||||
customer_order_number: "",
|
||||
currency: "CZK",
|
||||
vat_rate: "21",
|
||||
apply_vat: true,
|
||||
scope_title: "",
|
||||
scope_description: "",
|
||||
notes: "",
|
||||
@@ -255,8 +251,6 @@ export default function OrdersReceived({
|
||||
fd.append("customer_id", createForm.customer_id);
|
||||
fd.append("customer_order_number", createForm.customer_order_number);
|
||||
fd.append("currency", createForm.currency);
|
||||
fd.append("vat_rate", createForm.vat_rate);
|
||||
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
|
||||
fd.append("scope_title", createForm.scope_title);
|
||||
fd.append("scope_description", createForm.scope_description);
|
||||
fd.append("notes", createForm.notes);
|
||||
@@ -274,8 +268,6 @@ export default function OrdersReceived({
|
||||
: null,
|
||||
customer_order_number: createForm.customer_order_number,
|
||||
currency: createForm.currency,
|
||||
vat_rate: createForm.vat_rate,
|
||||
apply_vat: createForm.apply_vat,
|
||||
scope_title: createForm.scope_title,
|
||||
scope_description: createForm.scope_description,
|
||||
notes: createForm.notes,
|
||||
@@ -571,7 +563,7 @@ export default function OrdersReceived({
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -669,19 +661,6 @@ export default function OrdersReceived({
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Sazba DPH (%)">
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={createForm.vat_rate}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, vat_rate: e.target.value })
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
@@ -757,12 +736,6 @@ export default function OrdersReceived({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<CheckboxField
|
||||
label="Účtovat DPH"
|
||||
checked={createForm.apply_vat}
|
||||
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label="Vytvořit propojený projekt"
|
||||
checked={createForm.create_project}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { getRate } from "../../services/exchange-rates";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
@@ -16,62 +22,6 @@ const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -535,13 +485,7 @@ export default async function invoicesPdfRoutes(
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
|
||||
@@ -2,65 +2,23 @@ import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
/* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
@@ -195,23 +153,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
buyer: "Odběratel",
|
||||
issue_date: "Datum vystavení:",
|
||||
delivery_date: "Požadované dodání:",
|
||||
billing: "Objednáváme u Vás:",
|
||||
billing: "Objednáváme si u Vás:",
|
||||
col_no: "Č.",
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
delivery_terms: "Dodací podmínky:",
|
||||
payment_terms: "Platební podmínky:",
|
||||
issued_by: "Vystavil:",
|
||||
ico: "IČ: ",
|
||||
dic: "DIČ: ",
|
||||
@@ -228,18 +176,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
delivery_terms: "Delivery terms:",
|
||||
payment_terms: "Payment terms:",
|
||||
issued_by: "Issued by:",
|
||||
ico: "Reg. No.: ",
|
||||
dic: "Tax ID: ",
|
||||
@@ -251,12 +189,8 @@ interface IssuedOrderPdfData {
|
||||
order_date: Date | null;
|
||||
delivery_date: Date | null;
|
||||
currency: string | null;
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: unknown;
|
||||
notes: string | null;
|
||||
delivery_terms: string | null;
|
||||
payment_terms: string | null;
|
||||
issued_by: string | null;
|
||||
// Editable heading above the items table; null → t.billing default.
|
||||
order_text?: string | null;
|
||||
}
|
||||
|
||||
interface IssuedOrderPdfItem {
|
||||
@@ -265,7 +199,38 @@ interface IssuedOrderPdfItem {
|
||||
quantity: unknown;
|
||||
unit: string | null;
|
||||
unit_price: unknown;
|
||||
vat_rate: unknown;
|
||||
}
|
||||
|
||||
export interface IssuedOrderPdfSection {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
/** 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 issued-order PDF, rendered by Puppeteer
|
||||
* in the bottom page margin (the only true repeating footer Chromium
|
||||
* supports): "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 buildIssuedOrderPdfFooter(
|
||||
lang: Lang,
|
||||
issuerName: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderIssuedOrderHtml(
|
||||
@@ -274,12 +239,14 @@ export function renderIssuedOrderHtml(
|
||||
supplier: Record<string, unknown> | null,
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: Lang,
|
||||
issuer: { name: string },
|
||||
// Kept for signature stability across the many call sites: the issuer no
|
||||
// longer renders in the BODY — it prints in the Puppeteer footer template
|
||||
// (buildIssuedOrderPdfFooter) on every page.
|
||||
_issuer: { name: string },
|
||||
sections: IssuedOrderPdfSection[] = [],
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const applyVat = order.apply_vat !== false;
|
||||
const currency = order.currency || "CZK";
|
||||
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
|
||||
const poNumber = escapeHtml(order.po_number || "");
|
||||
|
||||
// Logo embedding (same logic as the confirmation template).
|
||||
@@ -306,31 +273,15 @@ export function renderIssuedOrderHtml(
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Items — NET + per-line VAT-on-top, rounded per line (same math the
|
||||
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
// Items — NET only. A purchase order is not a tax document: no VAT columns,
|
||||
// no VAT math (same as the service computeIssuedOrderTotals).
|
||||
let total = 0;
|
||||
const itemsHtml = items
|
||||
.map((it, i) => {
|
||||
const qty = Number(it.quantity) || 0;
|
||||
const unitPrice = Number(it.unit_price) || 0;
|
||||
const rate =
|
||||
it.vat_rate != null && it.vat_rate !== ""
|
||||
? Number(it.vat_rate)
|
||||
: docRate;
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const lineVat = applyVat
|
||||
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
|
||||
: 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
|
||||
subtotal += lineSubtotal;
|
||||
totalVat += lineVat;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
vatSummary[key].vat += lineVat;
|
||||
const lineTotal = qty * unitPrice;
|
||||
total += lineTotal;
|
||||
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const descHtml = `${escapeHtml(it.description)}${
|
||||
@@ -338,72 +289,57 @@ export function renderIssuedOrderHtml(
|
||||
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
||||
: ""
|
||||
}`;
|
||||
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
|
||||
// header does the same) instead of printing meaningless 0% / 0.00.
|
||||
const vatCells = applyVat
|
||||
? `
|
||||
<td class="center">${Math.floor(rate)}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${descHtml}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(unitPrice)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
<td class="right">${formatCurrency(unitPrice, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
subtotal = Math.round(subtotal * 100) / 100;
|
||||
totalVat = Math.round(totalVat * 100) / 100;
|
||||
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
total = Math.round(total * 100) / 100;
|
||||
|
||||
const issueDateStr = formatDate(order.order_date);
|
||||
const deliveryDateStr = formatDate(order.delivery_date);
|
||||
|
||||
// Order-specific terms (purchase-order addition; not on invoices).
|
||||
let termsHtml = "";
|
||||
if (order.delivery_terms) {
|
||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
|
||||
}
|
||||
if (order.payment_terms) {
|
||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
|
||||
}
|
||||
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
// ── Sections ("Obsah") — flow INLINE after the items/totals block (user
|
||||
// decision: no separate page, no repeated header). Each section keeps
|
||||
// break-inside: avoid; long content paginates naturally and the Puppeteer
|
||||
// footer template stamps every page. Suppressed entirely when every
|
||||
// section is empty (tag-stripped check, so an empty-Quill "<p><br></p>"
|
||||
// doesn't count). cs prefers the Czech title when present; en (or a
|
||||
// missing Czech title) falls back to the EN title (same rule as offers).
|
||||
const resolveSectionTitle = (s: IssuedOrderPdfSection): 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),
|
||||
);
|
||||
const hasSectionContent = visibleSections.length > 0;
|
||||
|
||||
let scopeHtml = "";
|
||||
if (hasSectionContent) {
|
||||
scopeHtml += '<div class="scope-sections">';
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title.trim())
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -424,15 +360,9 @@ export function renderIssuedOrderHtml(
|
||||
width: 186mm;
|
||||
}
|
||||
|
||||
.invoice-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(297mm - 27mm);
|
||||
}
|
||||
.invoice-content { flex: 1 1 auto; }
|
||||
.invoice-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
|
||||
footerTemplate (bottom page margin), NOT in the body — the old flex
|
||||
min-height pinning is gone with it. */
|
||||
|
||||
.accent { color: #de3a3a; }
|
||||
|
||||
@@ -637,34 +567,6 @@ export function renderIssuedOrderHtml(
|
||||
border-bottom: 2.5pt solid #de3a3a;
|
||||
padding-bottom: 1mm;
|
||||
}
|
||||
.totals .currency-note {
|
||||
text-align: right;
|
||||
font-size: 8pt;
|
||||
color: #1a1a1a;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
/* Dodaci / platebni podminky (PO-specificke) */
|
||||
.terms {
|
||||
margin-top: 4mm;
|
||||
font-size: 9pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.terms-row { margin-bottom: 1mm; }
|
||||
.terms-row .lbl {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Vystavil */
|
||||
.issued-by {
|
||||
font-size: 9pt;
|
||||
margin: 2mm 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.issued-by .lbl { font-weight: 600; }
|
||||
|
||||
/* Upozorneni */
|
||||
.notice {
|
||||
font-size: 8pt;
|
||||
@@ -673,32 +575,42 @@ export function renderIssuedOrderHtml(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Poznamky */
|
||||
.invoice-notes {
|
||||
margin-top: 4mm;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||
stranu. */
|
||||
.scope-sections {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
.invoice-notes-label {
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
.scope-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 3mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.scope-section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
.section-content {
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
.invoice-notes-content h2 { font-size: 18px; }
|
||||
.invoice-notes-content h3 { font-size: 16px; }
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
.section-content h2 { font-size: 18px; }
|
||||
.section-content h3 { font-size: 16px; }
|
||||
.section-content h4 { font-size: 15px; }
|
||||
.section-content p { margin: 0 0 0.4em 0; }
|
||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
@@ -720,6 +632,7 @@ ${indentCSS}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -771,22 +684,15 @@ ${indentCSS}
|
||||
</table>
|
||||
|
||||
<!-- Polozky -->
|
||||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
||||
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||
applyVat
|
||||
? `
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||
: ""
|
||||
}
|
||||
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -794,38 +700,21 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval;
|
||||
mena je soucasti castky pres formatCurrency, takze zadna "Částky jsou
|
||||
uvedeny v …" poznamka) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">${
|
||||
applyVat
|
||||
? `
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="totals">
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatCurrency(total, currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${notesHtml}
|
||||
|
||||
${termsBlock}
|
||||
${scopeHtml}
|
||||
|
||||
</div><!-- /.invoice-content -->
|
||||
<div class="invoice-footer">
|
||||
|
||||
<!-- Vystavil (jmeno prihlaseneho uzivatele) -->
|
||||
<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div>
|
||||
|
||||
</div><!-- /.invoice-footer -->
|
||||
</div><!-- /.invoice-page -->
|
||||
|
||||
</body>
|
||||
@@ -844,7 +733,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang: Lang = query.lang === "en" ? "en" : "cs";
|
||||
|
||||
try {
|
||||
const order = await prisma.issued_orders.findUnique({ where: { id } });
|
||||
@@ -854,10 +742,23 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
// Language comes from the document's own column; ?lang= is an
|
||||
// explicit override only (mirrors the /:id/file fallback semantics).
|
||||
const lang: Lang =
|
||||
query.lang === "en" || query.lang === "cs"
|
||||
? query.lang
|
||||
: order.language === "en"
|
||||
? "en"
|
||||
: "cs";
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
const sections = await prisma.issued_order_sections.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const supplier = order.supplier_id
|
||||
? ((await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: order.supplier_id },
|
||||
@@ -882,6 +783,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
sections,
|
||||
);
|
||||
|
||||
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
||||
@@ -892,7 +794,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
? new Date(order.order_date)
|
||||
: new Date();
|
||||
nasOrdersManager.cleanIssued(order.po_number);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const pdfBuffer = await htmlToPdf(html, {
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
nasOrdersManager.saveIssuedPdf(
|
||||
order.po_number,
|
||||
baseDate.getFullYear(),
|
||||
@@ -902,7 +806,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
return success(reply, null, 200, "PDF uloženo");
|
||||
}
|
||||
|
||||
const pdf = await htmlToPdf(html);
|
||||
const pdf = await htmlToPdf(html, {
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
|
||||
@@ -18,6 +18,18 @@ import {
|
||||
deleteIssuedOrder,
|
||||
getNextIssuedOrderNumberPreview,
|
||||
} from "../../services/issued-orders.service";
|
||||
import {
|
||||
renderIssuedOrderHtml,
|
||||
buildIssuedOrderPdfFooter,
|
||||
} from "./issued-orders-pdf";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
|
||||
// 3× the client's 10s heartbeat: a single missed beat (background-tab
|
||||
// throttling, transient network) must not silently hand the lock to a second
|
||||
// editor. Keep in sync with quotations.ts and useDocumentLock.
|
||||
const LOCK_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/admin/issued-orders
|
||||
@@ -26,12 +38,12 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
const result = await listIssuedOrders({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ""),
|
||||
sort,
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
@@ -56,9 +68,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed
|
||||
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the
|
||||
// literal "stats" path isn't captured as an order id.
|
||||
// GET /api/admin/issued-orders/stats — per-currency NET total summed over
|
||||
// the WHOLE filtered set (not one page). Issued orders are not tax documents
|
||||
// — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
|
||||
// isn't captured as an order id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
@@ -118,7 +131,217 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
if (id === null) return;
|
||||
const order = await getIssuedOrder(id);
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, order);
|
||||
|
||||
// `getIssuedOrder` already loaded locked_by/locked_at — enrich locked_by
|
||||
// to {user_id, username, full_name} ONLY when the lock is fresh and held
|
||||
// by ANOTHER user (same shape as offers so the frontend lock hook can be
|
||||
// shared); otherwise null.
|
||||
let lockedBy: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null = null;
|
||||
if (order.locked_by && order.locked_at) {
|
||||
const lockAge = Date.now() - new Date(order.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
order.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: order.locked_by },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
});
|
||||
if (lockUser) {
|
||||
lockedBy = {
|
||||
user_id: lockUser.id,
|
||||
username: lockUser.username,
|
||||
full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success(reply, { ...order, locked_by: lockedBy });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/lock — acquire edit lock (mirrors offers)
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/lock",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const order = await prisma.issued_orders.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
|
||||
// Check if locked by someone else and lock is fresh
|
||||
if (order.locked_by && order.locked_at) {
|
||||
const lockAge = Date.now() - new Date(order.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
order.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: order.locked_by },
|
||||
select: { first_name: true, last_name: true },
|
||||
});
|
||||
return error(
|
||||
reply,
|
||||
`Objednávku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
|
||||
423,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.issued_orders.update({
|
||||
where: { id },
|
||||
data: { locked_by: request.authData!.userId, locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, "Zámek nastaven");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/heartbeat — keep lock alive
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/heartbeat",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.issued_orders.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/unlock — release lock
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/unlock",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.issued_orders.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_by: null, locked_at: null },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/:id/file — serve the archived PDF from the
|
||||
// NAS; on an archive miss, fall back to a LIVE render from current data,
|
||||
// re-archive the fresh PDF, and serve it (archived-file model with live
|
||||
// fallback — same model as offers' /:id/file).
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/file",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const order = await prisma.issued_orders.findUnique({ where: { id } });
|
||||
// A draft has no po_number → nothing was ever archived; 404 like offers.
|
||||
if (!order?.po_number) return error(reply, "Objednávka nenalezena", 404);
|
||||
|
||||
const fileName = `Objednavka-${order.po_number}.pdf`;
|
||||
|
||||
// Archive hit: the by-number sweep finds the PDF in whichever
|
||||
// Vydané/YYYY/MM folder it was saved to (order_date can move after
|
||||
// archiving, so a fixed expected path would miss).
|
||||
const archived = nasOrdersManager.readIssuedByNumber(order.po_number);
|
||||
if (archived) {
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", contentDisposition("inline", fileName))
|
||||
.send(archived.data);
|
||||
}
|
||||
|
||||
// Archive miss → live render (same data path as issued-orders-pdf),
|
||||
// then archive the fresh PDF so the next request is a plain hit.
|
||||
try {
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const sections = await prisma.issued_order_sections.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const supplier = order.supplier_id
|
||||
? ((await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: order.supplier_id },
|
||||
})) as Record<string, unknown> | null)
|
||||
: null;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const lang =
|
||||
order.language === "en" ? ("en" as const) : ("cs" as const);
|
||||
const issuer = {
|
||||
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
|
||||
};
|
||||
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
supplier,
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
sections,
|
||||
);
|
||||
const pdf = await htmlToPdf(html, {
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
|
||||
if (nasOrdersManager.isConfigured()) {
|
||||
const baseDate = order.order_date
|
||||
? new Date(order.order_date)
|
||||
: new Date();
|
||||
nasOrdersManager.cleanIssued(order.po_number);
|
||||
const saved = nasOrdersManager.saveIssuedPdf(
|
||||
order.po_number,
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth() + 1,
|
||||
pdf,
|
||||
);
|
||||
if (!saved) {
|
||||
request.log.error(
|
||||
`Archivace PDF objednávky ${order.po_number} na NAS při /file fallbacku selhala`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", contentDisposition("inline", fileName))
|
||||
.send(pdf);
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
err,
|
||||
"Issued order /file live-render fallback failed",
|
||||
);
|
||||
return error(reply, "Chyba při generování PDF", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -133,6 +356,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
if ("error" in order) {
|
||||
if (order.error === "supplier_not_found")
|
||||
return error(reply, "Dodavatel nenalezen", 400);
|
||||
if (order.error === "po_number_taken")
|
||||
return error(reply, "Číslo objednávky je již použito", 409);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
await logAudit({
|
||||
@@ -165,6 +390,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Objednávka nenalezena", 404);
|
||||
if (result.error === "not_editable")
|
||||
return error(reply, "Objednávku v tomto stavu nelze upravovat", 400);
|
||||
if (result.error === "supplier_not_found")
|
||||
return error(reply, "Dodavatel nenalezen", 400);
|
||||
if (result.error === "invalid_transition")
|
||||
@@ -181,9 +408,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
action: "update",
|
||||
entityType: "issued_order",
|
||||
entityId: id,
|
||||
description: `Upravena vydaná objednávka ${result.po_number}`,
|
||||
description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`,
|
||||
oldValues: result.oldValues,
|
||||
newValues: result.newValues,
|
||||
});
|
||||
return success(reply, { id }, 200, "Objednávka byla aktualizována");
|
||||
// po_number in the response so a draft->sent finalize immediately shows
|
||||
// the assigned number (POST already returns it).
|
||||
return success(
|
||||
reply,
|
||||
{ id, po_number: result.po_number },
|
||||
200,
|
||||
"Objednávka byla aktualizována",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -202,7 +438,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
action: "delete",
|
||||
entityType: "issued_order",
|
||||
entityId: id,
|
||||
description: `Smazána vydaná objednávka ${existing.po_number}`,
|
||||
description: `Smazána vydaná objednávka ${existing.po_number ?? "koncept"}`,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
});
|
||||
return success(reply, null, 200, "Objednávka smazána");
|
||||
},
|
||||
|
||||
@@ -1,102 +1,24 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import type { Prisma, company_settings } from "@prisma/client";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
/** Format number with comma decimal separator and non-breaking space thousands separator */
|
||||
function formatNum(n: number, decimals: number): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currency: string): string {
|
||||
const n = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case "EUR":
|
||||
return `${formatNum(n, 2)} \u20AC`;
|
||||
case "USD":
|
||||
return `$${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
case "CZK":
|
||||
return `${formatNum(n, 2)} K\u010D`;
|
||||
case "GBP":
|
||||
return `\u00A3${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
default:
|
||||
return `${formatNum(n, 2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
const allowedTags =
|
||||
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
|
||||
let s = html;
|
||||
// Remove dangerous tags with content
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
// Strip event handlers
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
// Strip javascript:/data:/vbscript: in href and src (defense-in-depth,
|
||||
// matching the invoices/orders copies — DOMPurify runs first).
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -203,144 +125,124 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||
total: { EN: "Total", CZ: "Celkem" },
|
||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||
vat: { EN: "VAT", CZ: "DPH" },
|
||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
||||
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||
page: { EN: "Page", CZ: "Strana" },
|
||||
of: { EN: "of", CZ: "z" },
|
||||
};
|
||||
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
/** Quotation row with the relations the PDF template renders. */
|
||||
export type OfferForPdf = Prisma.quotationsGetPayload<{
|
||||
include: {
|
||||
customers: true;
|
||||
quotation_items: true;
|
||||
scope_sections: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Build the full offer PDF/preview HTML. Extracted from the route handler so
|
||||
* the offers /:id/file live-render fallback (quotations.ts) can reuse the
|
||||
* exact same template without going through HTTP.
|
||||
*/
|
||||
export function renderOfferHtml(
|
||||
quotation: OfferForPdf,
|
||||
settings: company_settings | null,
|
||||
): string {
|
||||
const isCzech = (quotation.language ?? "EN") !== "EN";
|
||||
const langKey = isCzech ? "CZ" : "EN";
|
||||
const currency = quotation.currency || "EUR";
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
|
||||
if (!quotation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
|
||||
}
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
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";
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const isCzech = (quotation.language ?? "EN") !== "EN";
|
||||
const langKey = isCzech ? "CZ" : "EN";
|
||||
const currency = quotation.currency || "EUR";
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
// Offers are NOT tax documents — the total is NET only (no VAT math).
|
||||
const items = quotation.quotation_items;
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
// Visibility uses the SAME per-language title rule as the render loop plus
|
||||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||||
// — otherwise a section with only the other language's title would count as
|
||||
// content and produce a scope page holding nothing but the header. Mirrors
|
||||
// issued-orders-pdf.
|
||||
const stripSectionTags = (html: string | null | undefined): string =>
|
||||
(html || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
const resolveSectionTitle = (s: {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
}): string =>
|
||||
isCzech && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || "";
|
||||
const visibleSections = quotation.scope_sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripSectionTags(s.content),
|
||||
);
|
||||
const hasScopeContent = visibleSections.length > 0;
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
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";
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||
}
|
||||
const cust = buildAddressLines(
|
||||
quotation.customers as unknown as Record<string, unknown>,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(
|
||||
settings as unknown as Record<string, unknown>,
|
||||
true,
|
||||
t,
|
||||
);
|
||||
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const cust = buildAddressLines(
|
||||
quotation.customers as unknown as Record<string, unknown>,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(
|
||||
settings as unknown as Record<string, unknown>,
|
||||
true,
|
||||
t,
|
||||
);
|
||||
// Indentation CSS for Quill
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Indentation CSS for Quill
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const qty = Number(item.quantity) || 0;
|
||||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
||||
// Integers render without decimals; fractional quantities keep 2 decimals
|
||||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
});
|
||||
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="value">${formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||
// No Mezisoučet/VAT rows — they would only duplicate the net total.
|
||||
const totalsHtml = `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
|
||||
<span class="value">${formatCurrency(total, currency)}</span>
|
||||
</div>`;
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
|
||||
let scopeHtml = "";
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
let scopeHtml = "";
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">${escapeHtml(t("title"))}</div>
|
||||
<div class="quotation-number">${quotationNumber}</div>
|
||||
@@ -351,27 +253,23 @@ export default async function offersPdfRoutes(
|
||||
</div>
|
||||
<hr class="separator" />`;
|
||||
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title =
|
||||
isCzech && (section.title_cz || "").trim()
|
||||
? section.title_cz
|
||||
: section.title || "";
|
||||
const content = (section.content || "").trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title)
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title.trim())
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
const pageLabel = escapeHtml(t("page"));
|
||||
const ofLabel = escapeHtml(t("of"));
|
||||
const pageLabel = escapeHtml(t("page"));
|
||||
const ofLabel = escapeHtml(t("of"));
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? "cs" : "en"}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -749,6 +647,40 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!quotation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Nabídka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const html = renderOfferHtml(quotation, settings);
|
||||
|
||||
const saveMode = query.save === "1";
|
||||
|
||||
// Save PDF to NAS
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, error } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { z } from "zod";
|
||||
import createDOMPurify from "dompurify";
|
||||
@@ -20,69 +26,18 @@ const OrderPdfItemSchema = z.object({
|
||||
unit: z.string().max(255),
|
||||
unit_price: z.number().min(0).finite(),
|
||||
is_included_in_total: z.boolean().optional(),
|
||||
vat_rate: z.number().min(0).max(100).finite(),
|
||||
});
|
||||
|
||||
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
||||
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
|
||||
// handler no longer reads them as untyped passthrough keys.
|
||||
// `items` is strictly validated; `lang` is typed explicitly so the handler no
|
||||
// longer reads it as an untyped passthrough key.
|
||||
const OrderPdfBodySchema = z.looseObject({
|
||||
items: z.array(OrderPdfItemSchema).optional(),
|
||||
lang: z.string().max(10).optional(),
|
||||
applyVat: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -193,13 +148,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
@@ -222,13 +171,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
@@ -240,212 +183,112 @@ const translations: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
/* ── Template ────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
export interface OrderConfirmationPdfItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
interface OrderConfirmationPdfData {
|
||||
order_number: string | null;
|
||||
customer_order_number: string | null;
|
||||
created_at: Date | string | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
// Not a real orders column today — read defensively (PHP-era data carried it).
|
||||
payment_method?: string | null;
|
||||
customers?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
|
||||
* are NET only: no VAT columns or VAT summary; the grand total is labeled
|
||||
* "Celkem bez DPH". Exported for tests.
|
||||
*/
|
||||
export function renderOrderConfirmationHtml(
|
||||
order: OrderConfirmationPdfData,
|
||||
items: OrderConfirmationPdfItem[],
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: "cs" | "en",
|
||||
userName: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const currency = order.currency || "CZK";
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// NET-only total over the included lines — no VAT math anywhere.
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) total += item.quantity * item.unit_price;
|
||||
}
|
||||
total = Math.round(total * 100) / 100;
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
// Without "Uplatnit DPH" the VAT columns are dropped entirely
|
||||
// (the header does the same) instead of printing 0% / 0.00.
|
||||
const vatCells = applyVat
|
||||
? `
|
||||
<td class="center">${Math.floor(item.vat_rate)}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>`
|
||||
: "";
|
||||
return `<tr>
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(item.unit_price)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod =
|
||||
String((order as Record<string, unknown>).payment_method || "") ||
|
||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||
const paymentMethod =
|
||||
String(order.payment_method || "") ||
|
||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -693,47 +536,6 @@ export default async function ordersPdfRoutes(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* DPH rekapitulace + QR */
|
||||
.recap-section {
|
||||
display: flex;
|
||||
gap: 5mm;
|
||||
align-items: flex-start;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.recap-section .qr {
|
||||
flex-shrink: 0;
|
||||
width: 28mm;
|
||||
}
|
||||
.recap-section .qr img,
|
||||
.recap-section .qr svg { width: 28mm; height: 28mm; }
|
||||
|
||||
.recap-section table {
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
flex: 1;
|
||||
}
|
||||
.recap-section table th {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #ccc;
|
||||
}
|
||||
.recap-section table td {
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #eee;
|
||||
}
|
||||
.recap-section table td.center { text-align: center; }
|
||||
.recap-section table td.cnb-rate {
|
||||
font-size: 8pt;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
border-bottom: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Prevzal / razitko */
|
||||
.footer-row {
|
||||
display: flex;
|
||||
@@ -855,17 +657,10 @@ ${indentCSS}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||
applyVat
|
||||
? `
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||
: ""
|
||||
}
|
||||
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -873,22 +668,12 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">${
|
||||
applyVat
|
||||
? `
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="totals">
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
</div>
|
||||
@@ -915,9 +700,96 @@ ${indentCSS}
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
|
||||
let items: OrderConfirmationPdfItem[];
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
}));
|
||||
}
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
items,
|
||||
settings,
|
||||
lang,
|
||||
userName,
|
||||
);
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
CreateQuotationSchema,
|
||||
@@ -21,8 +22,13 @@ import {
|
||||
getNextOfferNumber,
|
||||
} from "../../services/offers.service";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { renderOfferHtml } from "./offers-pdf";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat
|
||||
// 3× the client's 10s heartbeat: a single missed beat (background-tab
|
||||
// throttling, transient network) must not silently hand the lock to a second
|
||||
// editor. Keep in sync with issued-orders.ts and useDocumentLock.
|
||||
const LOCK_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
export default async function quotationsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -53,9 +59,10 @@ export default async function quotationsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the
|
||||
// WHOLE filtered set (not one page). Offers have NO month/year filter; the
|
||||
// list filters by search + status + customer_id, so the totals use the same.
|
||||
// GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
|
||||
// filtered set (not one page). Offers are not tax documents — no VAT
|
||||
// anywhere. Offers have NO month/year filter; the list filters by
|
||||
// search + status + customer_id, so the totals use the same.
|
||||
// Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
@@ -117,8 +124,19 @@ export default async function quotationsRoutes(
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await invalidateOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
const result = await invalidateOffer(id);
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||
400,
|
||||
);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
const existing = result.data;
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -126,7 +144,9 @@ export default async function quotationsRoutes(
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Zneplatněna nabídka ${existing.quotation_number}`,
|
||||
description: `Zneplatněna nabídka ${existing.quotation_number ?? "koncept"}`,
|
||||
oldValues: { status: existing.status },
|
||||
newValues: { status: "invalidated" },
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka zneplatněna");
|
||||
},
|
||||
@@ -262,8 +282,13 @@ export default async function quotationsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const quotation = await createOffer(parsed.data);
|
||||
if ("error" in quotation)
|
||||
return error(reply, quotation.error!, quotation.status!);
|
||||
if ("error" in quotation) {
|
||||
if (quotation.error === "customer_not_found")
|
||||
return error(reply, "Zákazník nenalezen", 400);
|
||||
if (quotation.error === "number_taken")
|
||||
return error(reply, "Číslo nabídky je již použito", 409);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -295,13 +320,17 @@ export default async function quotationsRoutes(
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "invalidated")
|
||||
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
|
||||
return error(
|
||||
reply,
|
||||
result.error || "Neznámá chyba",
|
||||
"status" in result ? result.status : 400,
|
||||
);
|
||||
if (result.error === "not_editable")
|
||||
return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||
400,
|
||||
);
|
||||
if (result.error === "customer_not_found")
|
||||
return error(reply, "Zákazník nenalezen", 400);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
|
||||
// Keep lock — user stays on the page after save
|
||||
@@ -312,7 +341,9 @@ export default async function quotationsRoutes(
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Upravena nabídka ${result.quotation_number}`,
|
||||
description: `Upravena nabídka ${result.quotation_number ?? "koncept"}`,
|
||||
oldValues: result.oldValues,
|
||||
newValues: result.newValues,
|
||||
});
|
||||
return success(reply, { id }, 200, "Nabídka byla uložena");
|
||||
},
|
||||
@@ -325,25 +356,23 @@ export default async function quotationsRoutes(
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await deleteOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.quotation_number && existing.created_at) {
|
||||
const yr = new Date(existing.created_at).getFullYear();
|
||||
try {
|
||||
await nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
const result = await deleteOffer(id);
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "linked")
|
||||
return error(
|
||||
reply,
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
409,
|
||||
);
|
||||
} catch (e) {
|
||||
// Non-fatal: NAS delete may fail if the file does not exist — but
|
||||
// never swallow silently (project never-swallow rule).
|
||||
request.log.warn(
|
||||
e,
|
||||
`Smazání PDF nabídky ${existing.quotation_number} z NAS selhalo`,
|
||||
);
|
||||
}
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
const existing = result.data;
|
||||
|
||||
// NAS PDF cleanup is owned by deleteOffer (single owner) — the route
|
||||
// used to repeat it here, which always failed the second time and logged
|
||||
// a spurious error.
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -351,7 +380,8 @@ export default async function quotationsRoutes(
|
||||
action: "delete",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Smazána nabídka ${existing.quotation_number}`,
|
||||
description: `Smazána nabídka ${existing.quotation_number ?? "koncept"}`,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka smazána");
|
||||
},
|
||||
@@ -379,15 +409,71 @@ export default async function quotationsRoutes(
|
||||
year,
|
||||
);
|
||||
const file = nasOffersManager.readOfferPdf(relPath);
|
||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||
if (file) {
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
// Quotation numbers contain "/" segments — not header- or
|
||||
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
|
||||
contentDisposition(
|
||||
"inline",
|
||||
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
|
||||
),
|
||||
)
|
||||
.send(file.data);
|
||||
}
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${offer.quotation_number}.pdf"`,
|
||||
)
|
||||
.send(file.data);
|
||||
// Archive miss → live-render fallback (same model as issued orders'
|
||||
// /:id/file): rebuild the PDF from current data with the exact PDF-route
|
||||
// template, re-archive it so the next request is a plain hit, and serve
|
||||
// the fresh bytes with identical response semantics.
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const html = renderOfferHtml(quotation, settings);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
|
||||
if (nasOffersManager.isConfigured()) {
|
||||
// saveOfferPdf logs internally and returns null on failure —
|
||||
// archiving is best-effort here, serving the PDF must not fail.
|
||||
const saved = nasOffersManager.saveOfferPdf(
|
||||
offer.quotation_number,
|
||||
year,
|
||||
pdfBuffer,
|
||||
);
|
||||
if (!saved) {
|
||||
request.log.error(
|
||||
`Archivace PDF nabídky ${offer.quotation_number} na NAS při /file fallbacku selhala`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
// Quotation numbers contain "/" segments — not header- or
|
||||
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
|
||||
contentDisposition(
|
||||
"inline",
|
||||
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
|
||||
),
|
||||
)
|
||||
.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
request.log.error(err, "Offer /file live-render fallback failed");
|
||||
return error(reply, "Chyba při generování PDF", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
|
||||
message: "Číslo musí být kladné",
|
||||
});
|
||||
|
||||
/** number|string → non-negative integer (e.g. list positions). */
|
||||
export const nonNegativeIntFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
.transform(coerceNum)
|
||||
.refine((n) => Number.isInteger(n) && n >= 0, {
|
||||
message: "Číslo musí být nezáporné celé číslo",
|
||||
});
|
||||
|
||||
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
|
||||
export const intIdFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
@@ -154,3 +162,17 @@ export const emailOrEmpty = z.union([
|
||||
z.literal(""),
|
||||
z.string().max(255).email("Neplatný email"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shared rich-text document section — offers' scope_sections and issued
|
||||
* orders' issued_order_sections are 1:1 mirror tables, so both schemas use
|
||||
* this. Limits are DB-aligned: title/title_cz are VarChar(500) (the old
|
||||
* offers-local copy under-shot at 255), content is Text with the app-level
|
||||
* 8000 cap used by the other rich-text fields.
|
||||
*/
|
||||
export const DocumentSectionSchema = z.object({
|
||||
title: z.string().max(500).nullish(),
|
||||
title_cz: z.string().max(500).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
export const IssuedOrderItemSchema = z.object({
|
||||
@@ -14,7 +13,6 @@ export const IssuedOrderItemSchema = z.object({
|
||||
quantity: positiveNumberFromForm.optional(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
position: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
@@ -31,18 +29,23 @@ export const CreateIssuedOrderSchema = z.object({
|
||||
supplier_id: nullableIntIdFromForm.nullish(),
|
||||
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
||||
currency: z.string().max(10).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
exchange_rate: nonNegativeNumberFromForm.optional(),
|
||||
order_date: isoDateString.nullish(),
|
||||
delivery_date: isoDateString.nullish(),
|
||||
language: z.string().max(5).optional(),
|
||||
delivery_terms: z.string().max(500).nullish(),
|
||||
payment_terms: z.string().max(500).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
notes: z.string().nullish(),
|
||||
// Editable heading above the PDF items table; empty/null falls back to the
|
||||
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||
order_text: z.string().max(500).nullish(),
|
||||
internal_notes: z.string().nullish(),
|
||||
items: z.array(IssuedOrderItemSchema).optional(),
|
||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial();
|
||||
// Update = partial create MINUS the number: PO numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service (which never reads it on update anyway).
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial().omit({
|
||||
po_number: true,
|
||||
});
|
||||
|
||||
@@ -1,59 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
nonNegativeIntFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
// Limits are DB-aligned: description VarChar(500), unit VarChar(20);
|
||||
// item_description is Text with the app-level 8000 cap used elsewhere.
|
||||
const QuotationItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
// Int column — a float position would 500 at Prisma.
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
const ScopeSectionSchema = z.object({
|
||||
title: z.string().max(255).nullish(),
|
||||
title_cz: z.string().max(255).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
// Shared with issued orders (scope_sections ≡ issued_order_sections).
|
||||
const ScopeSectionSchema = DocumentSectionSchema;
|
||||
|
||||
// NOTE: no top-level `.default()`s here — `UpdateQuotationSchema` derives from
|
||||
// this schema via `.partial()`, and Zod 4 still applies a field default when
|
||||
// the key is absent, which would silently inject status/currency/language
|
||||
// resets into every update payload. Create-time defaults live in
|
||||
// `createOffer` (service) instead.
|
||||
export const CreateQuotationSchema = z.object({
|
||||
quotation_number: z.string().max(255).nullish(),
|
||||
project_code: z.string().max(255).nullish(),
|
||||
// DB: quotations.quotation_number VarChar(50).
|
||||
quotation_number: z.string().max(50).nullish(),
|
||||
// DB column is VarChar(100) — a longer value would 500 at Prisma.
|
||||
project_code: z.string().max(100).nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
created_at: z.string().max(255).nullish(),
|
||||
valid_until: z.string().max(255).nullish(),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
status: z
|
||||
.enum(["draft", "active", "ordered", "invalidated"])
|
||||
.optional()
|
||||
.default("draft"),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateQuotationSchema = z.object({
|
||||
project_code: z.string().max(255).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
created_at: z.union([z.string().max(255), z.null()]).optional(),
|
||||
valid_until: z.union([z.string().max(255), z.null()]).optional(),
|
||||
created_at: isoDateString.nullish(),
|
||||
valid_until: isoDateString.nullish(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -61,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
// Update = partial create MINUS the number: quotation numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service.
|
||||
export const UpdateQuotationSchema = CreateQuotationSchema.partial().omit({
|
||||
quotation_number: true,
|
||||
});
|
||||
|
||||
export type CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
|
||||
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
intIdFromForm,
|
||||
@@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({
|
||||
.default("prijata"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
items: z.array(OrderItemSchema).optional(),
|
||||
sections: z.array(OrderSectionSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { $Enums } from "@prisma/client";
|
||||
import { Prisma, type $Enums } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||
import {
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
previewIssuedOrderNumber,
|
||||
releaseIssuedOrderNumber,
|
||||
assignIssuedOrderNumber,
|
||||
isIssuedOrderNumberTaken,
|
||||
} from "./numbering.service";
|
||||
import { nasOrdersManager } from "./nas-financials-manager";
|
||||
|
||||
export interface IssuedOrderItemInput {
|
||||
description?: string | null;
|
||||
@@ -14,7 +16,13 @@ export interface IssuedOrderItemInput {
|
||||
quantity?: number | string | null;
|
||||
unit?: string | null;
|
||||
unit_price?: number | string | null;
|
||||
vat_rate?: number | string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface IssuedOrderSectionInput {
|
||||
title?: string | null;
|
||||
title_cz?: string | null;
|
||||
content?: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
@@ -23,18 +31,14 @@ export interface IssuedOrderInput {
|
||||
supplier_id?: number | string | null;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
vat_rate?: number | string | null;
|
||||
apply_vat?: boolean | number | string;
|
||||
exchange_rate?: number | string | null;
|
||||
order_date?: string | null;
|
||||
delivery_date?: string | null;
|
||||
language?: string;
|
||||
delivery_terms?: string | null;
|
||||
payment_terms?: string | null;
|
||||
issued_by?: string | null;
|
||||
notes?: string | null;
|
||||
order_text?: string | null;
|
||||
internal_notes?: string | null;
|
||||
items?: IssuedOrderItemInput[];
|
||||
sections?: IssuedOrderSectionInput[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -102,35 +106,41 @@ const ALLOWED_SORT_FIELDS = [
|
||||
"po_number",
|
||||
"status",
|
||||
"order_date",
|
||||
"created_at",
|
||||
"currency",
|
||||
];
|
||||
|
||||
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
|
||||
/**
|
||||
* Non-status update fields — used by the editable-state guard so a field edit
|
||||
* on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to
|
||||
* be silently dropped). `po_number` is stripped by the schema and the service
|
||||
* never reads it on update.
|
||||
*/
|
||||
const NON_STATUS_UPDATE_FIELDS = [
|
||||
"supplier_id",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"order_date",
|
||||
"delivery_date",
|
||||
"language",
|
||||
"order_text",
|
||||
"internal_notes",
|
||||
"items",
|
||||
"sections",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* NET-only total: issued orders (PO) are not tax documents — VAT never appears
|
||||
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
|
||||
*/
|
||||
export function computeIssuedOrderTotals(
|
||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
||||
applyVat: boolean | null,
|
||||
defaultVatRate: unknown,
|
||||
items: Array<{ quantity: unknown; unit_price: unknown }>,
|
||||
) {
|
||||
let subtotal = 0;
|
||||
let vat = 0;
|
||||
let total = 0;
|
||||
for (const it of items) {
|
||||
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
subtotal += base;
|
||||
if (applyVat) {
|
||||
const rate =
|
||||
it.vat_rate != null && it.vat_rate !== ""
|
||||
? Number(it.vat_rate)
|
||||
: defaultVatRate != null
|
||||
? Number(defaultVatRate)
|
||||
: 21;
|
||||
vat += Math.round(base * (rate / 100) * 100) / 100;
|
||||
}
|
||||
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
}
|
||||
return {
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vat * 100) / 100,
|
||||
total: Math.round((subtotal + vat) * 100) / 100,
|
||||
};
|
||||
return { total: Math.round(total * 100) / 100 };
|
||||
}
|
||||
|
||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
@@ -162,11 +172,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
]);
|
||||
|
||||
const enriched = rows.map((o) => {
|
||||
const totals = computeIssuedOrderTotals(
|
||||
o.issued_order_items,
|
||||
o.apply_vat,
|
||||
o.vat_rate,
|
||||
);
|
||||
const totals = computeIssuedOrderTotals(o.issued_order_items);
|
||||
const { issued_order_items, ...rest } = o;
|
||||
return {
|
||||
...rest,
|
||||
@@ -180,7 +186,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set
|
||||
* Sum issued-order NET total per currency across the WHOLE filtered set
|
||||
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
|
||||
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
|
||||
* Currency defaults to "CZK" when the column is null. Returns one entry per
|
||||
@@ -198,11 +204,7 @@ export async function getIssuedOrderTotals(
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const o of rows) {
|
||||
const { total } = computeIssuedOrderTotals(
|
||||
o.issued_order_items,
|
||||
o.apply_vat,
|
||||
o.vat_rate,
|
||||
);
|
||||
const { total } = computeIssuedOrderTotals(o.issued_order_items);
|
||||
const cur = o.currency || "CZK";
|
||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
||||
}
|
||||
@@ -235,13 +237,18 @@ export async function getIssuedOrder(id: number) {
|
||||
},
|
||||
},
|
||||
issued_order_items: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
issued_order_sections: {
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!order) return null;
|
||||
const { issued_order_items, ...rest } = order;
|
||||
const { issued_order_items, issued_order_sections, ...rest } = order;
|
||||
return {
|
||||
...rest,
|
||||
items: issued_order_items,
|
||||
sections: issued_order_sections,
|
||||
supplier: order.suppliers,
|
||||
supplier_name: order.suppliers?.name || null,
|
||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||
@@ -249,85 +256,111 @@ export async function getIssuedOrder(id: number) {
|
||||
}
|
||||
|
||||
export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const status = body.status ? String(body.status) : "draft";
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const status = body.status ? String(body.status) : "draft";
|
||||
|
||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||
if (supplierId) {
|
||||
const supplier = await tx.sklad_suppliers.findUnique({
|
||||
where: { id: supplierId },
|
||||
select: { id: true },
|
||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||
if (supplierId) {
|
||||
const supplier = await tx.sklad_suppliers.findUnique({
|
||||
where: { id: supplierId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!supplier) return { error: "supplier_not_found" as const };
|
||||
}
|
||||
|
||||
// Deferred numbering: a draft carries NO po_number (the column is
|
||||
// nullable-unique so many drafts coexist). The official number is consumed
|
||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||
// A caller-supplied number, or a doc created already-finalized, numbers now.
|
||||
const explicit =
|
||||
body.po_number !== undefined &&
|
||||
body.po_number !== null &&
|
||||
body.po_number !== ""
|
||||
? String(body.po_number)
|
||||
: null;
|
||||
|
||||
// Re-check caller-supplied uniqueness INSIDE the transaction so the
|
||||
// check-then-insert window can't race a concurrent create (mirrors
|
||||
// createOffer). generateIssuedOrderNumber verifies its own numbers,
|
||||
// so this guards only the explicit path.
|
||||
if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) {
|
||||
return { error: "po_number_taken" as const };
|
||||
}
|
||||
|
||||
const poNumber =
|
||||
explicit ??
|
||||
(status === "draft"
|
||||
? null
|
||||
: (await generateIssuedOrderNumber(undefined, tx)).number);
|
||||
|
||||
const order = await tx.issued_orders.create({
|
||||
data: {
|
||||
po_number: poNumber,
|
||||
supplier_id: supplierId,
|
||||
status: status as $Enums.issued_orders_status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
exchange_rate:
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||
// yesterday during the 00:00–02:00 Prague window.
|
||||
order_date: body.order_date
|
||||
? new Date(String(body.order_date))
|
||||
: utcMidnightOfLocalDay(),
|
||||
delivery_date: body.delivery_date
|
||||
? new Date(String(body.delivery_date))
|
||||
: null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
order_text: body.order_text ? String(body.order_text) : null,
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
if (!supplier) return { error: "supplier_not_found" as const };
|
||||
}
|
||||
|
||||
// Deferred numbering: a draft carries NO po_number (the column is
|
||||
// nullable-unique so many drafts coexist). The official number is consumed
|
||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||
// A caller-supplied number, or a doc created already-finalized, numbers now.
|
||||
const explicit =
|
||||
body.po_number !== undefined &&
|
||||
body.po_number !== null &&
|
||||
body.po_number !== ""
|
||||
? String(body.po_number)
|
||||
: null;
|
||||
const poNumber =
|
||||
explicit ??
|
||||
(status === "draft"
|
||||
? null
|
||||
: (await generateIssuedOrderNumber(undefined, tx)).number);
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
issued_order_id: order.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,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const order = await tx.issued_orders.create({
|
||||
data: {
|
||||
po_number: poNumber,
|
||||
supplier_id: supplierId,
|
||||
status: status as $Enums.issued_orders_status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate:
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||
// yesterday during the 00:00–02:00 Prague window.
|
||||
order_date: body.order_date
|
||||
? new Date(String(body.order_date))
|
||||
: utcMidnightOfLocalDay(),
|
||||
delivery_date: body.delivery_date
|
||||
? new Date(String(body.delivery_date))
|
||||
: null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
delivery_terms: body.delivery_terms
|
||||
? String(body.delivery_terms)
|
||||
: null,
|
||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
},
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.issued_order_sections.createMany({
|
||||
data: body.sections.map((s, i) => ({
|
||||
issued_order_id: order.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
issued_order_id: order.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,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
// P2002 on the po_number unique index = a concurrent insert won the race
|
||||
// between the in-tx check and our insert — surface the same clean
|
||||
// conflict token instead of a 500 (mirrors offers).
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return { error: "po_number_taken" as const };
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
@@ -344,17 +377,22 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// Editable-state guard: non-status fields may change only in draft/sent.
|
||||
// In confirmed/completed/cancelled a field edit is REJECTED explicitly
|
||||
// (offers parity — it used to be silently ignored). Status-only transition
|
||||
// payloads (e.g. confirmed -> completed) still work.
|
||||
const editable = currentStatus === "draft" || currentStatus === "sent";
|
||||
if (
|
||||
!editable &&
|
||||
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
|
||||
) {
|
||||
return { error: "not_editable" as const, currentStatus };
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
|
||||
if (editable) {
|
||||
const strFields = [
|
||||
"currency",
|
||||
"language",
|
||||
"delivery_terms",
|
||||
"payment_terms",
|
||||
"issued_by",
|
||||
];
|
||||
const strFields = ["currency", "language", "order_text"];
|
||||
for (const f of strFields) {
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
}
|
||||
@@ -370,12 +408,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
}
|
||||
data.supplier_id = supplierId;
|
||||
}
|
||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||
if (body.apply_vat !== undefined)
|
||||
data.apply_vat =
|
||||
body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1";
|
||||
if (body.exchange_rate !== undefined)
|
||||
data.exchange_rate =
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : null;
|
||||
@@ -387,8 +419,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
data.delivery_date = body.delivery_date
|
||||
? new Date(String(body.delivery_date))
|
||||
: null;
|
||||
if (body.notes !== undefined)
|
||||
data.notes = body.notes ? String(body.notes) : null;
|
||||
if (body.internal_notes !== undefined)
|
||||
data.internal_notes = body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
@@ -406,47 +436,67 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
body.status !== undefined &&
|
||||
String(body.status) === "sent";
|
||||
|
||||
if (finalizing) {
|
||||
// ONE transaction for the header write, the finalize numbering AND the
|
||||
// items/sections full-replace. The items replace used to run in a SECOND
|
||||
// transaction after the header tx — a failure between the two left a torn
|
||||
// write (new header, old items). Now either everything lands or nothing.
|
||||
const replaceItems = editable && Array.isArray(body.items);
|
||||
const replaceSections = editable && Array.isArray(body.sections);
|
||||
|
||||
let assignedNumber: string | null = null;
|
||||
if (finalizing || replaceItems || replaceSections) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.issued_orders.update({ where: { id }, data });
|
||||
await assignIssuedOrderNumber(id, tx);
|
||||
if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx);
|
||||
if (replaceItems) {
|
||||
await tx.issued_order_items.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
issued_order_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (replaceSections) {
|
||||
await tx.issued_order_sections.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_sections.createMany({
|
||||
data: body.sections!.map((s, i) => ({
|
||||
issued_order_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.issued_orders.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
if (editable && Array.isArray(body.items)) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.issued_order_items.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
issued_order_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
// newValues for the audit diff: the header fields actually written plus the
|
||||
// replaced collections (JSON.stringify drops the undefined = untouched keys).
|
||||
const newValues: Record<string, unknown> = { ...data };
|
||||
if (replaceItems) newValues.items = body.items;
|
||||
if (replaceSections) newValues.sections = body.sections;
|
||||
|
||||
// Reflect the number the finalize transition just assigned (existing was read
|
||||
// before the assign, so its po_number is still the pre-finalize null).
|
||||
const poNumber = finalizing
|
||||
? ((
|
||||
await prisma.issued_orders.findUnique({
|
||||
where: { id },
|
||||
select: { po_number: true },
|
||||
})
|
||||
)?.po_number ?? existing.po_number)
|
||||
: existing.po_number;
|
||||
|
||||
return { id, po_number: poNumber };
|
||||
return {
|
||||
id,
|
||||
po_number: assignedNumber ?? existing.po_number,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
newValues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteIssuedOrder(id: number) {
|
||||
@@ -460,6 +510,23 @@ export async function deleteIssuedOrder(id: number) {
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);
|
||||
|
||||
// Remove the archived PDF from the NAS so the DB delete doesn't orphan it
|
||||
// (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so
|
||||
// an order whose order_date moved after archiving is still found.
|
||||
// Best-effort: log, never throw.
|
||||
if (existing.po_number && nasOrdersManager.isConfigured()) {
|
||||
try {
|
||||
nasOrdersManager.cleanIssued(existing.po_number);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[issued-orders.service] NAS issued-order PDF delete failed for",
|
||||
existing.po_number,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,20 @@ export class NasDocumentManager {
|
||||
|
||||
// ── Created (issued) documents ───────────────────────────────────
|
||||
|
||||
/** Remove any existing PDF for this document number across all year/month folders */
|
||||
/**
|
||||
* Remove any existing PDF for this document number across all year/month
|
||||
* folders — including stale `<number>_N.pdf` duplicates that the old
|
||||
* uniquePath-based save left behind when two archive calls raced (the
|
||||
* save-after-edit fire-and-forget vs. the /file fallback). Document
|
||||
* numbers are digit-only and never contain "_", so the suffix match
|
||||
* cannot hit a different document.
|
||||
*/
|
||||
cleanIssued(documentNumber: string): void {
|
||||
if (!this.basePath) return;
|
||||
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||
const safeBase = this.sanitizeFilename(documentNumber);
|
||||
const stalePattern = new RegExp(
|
||||
`^${safeBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(_\\d+)?\\.pdf$`,
|
||||
);
|
||||
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
||||
try {
|
||||
if (!fs.existsSync(issuedDir)) return;
|
||||
@@ -74,9 +84,10 @@ export class NasDocumentManager {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(monthPath, safeName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
for (const file of fs.readdirSync(monthPath)) {
|
||||
if (stalePattern.test(file)) {
|
||||
fs.unlinkSync(path.join(monthPath, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,12 +114,17 @@ export class NasDocumentManager {
|
||||
const dir = this.ensureDir(DIR_ISSUED, year, month);
|
||||
if (!dir) return null;
|
||||
|
||||
// Deterministic path, OVERWRITE in place (same model as the offers
|
||||
// manager): the archive of a numbered document has exactly one canonical
|
||||
// file. uniquePath here used to spawn `<number>_1.pdf` duplicates
|
||||
// whenever two archive calls raced — those are user-visible junk on the
|
||||
// NAS and invisible to the reader/cleaner, which match the exact name.
|
||||
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||
const fullPath = this.uniquePath(dir, safeName);
|
||||
const fullPath = path.join(dir, safeName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, pdfBuffer);
|
||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
|
||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nas-financials-manager] write failed for issued document:",
|
||||
@@ -119,6 +135,54 @@ export class NasDocumentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and read the archived PDF for an issued document by its number
|
||||
* alone, sweeping every Vydané/YYYY/MM folder (the same walk cleanIssued
|
||||
* uses). Needed because the archive folder is derived from the document
|
||||
* date at save time and the date can be edited afterwards — a fixed
|
||||
* expected-path read would miss a file that does exist in another month
|
||||
* folder. Returns null when not found (callers fall back to a live render).
|
||||
*/
|
||||
readIssuedByNumber(
|
||||
documentNumber: string,
|
||||
): { data: Buffer; fileName: string } | null {
|
||||
if (!this.basePath) return null;
|
||||
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
||||
try {
|
||||
if (!fs.existsSync(issuedDir)) return null;
|
||||
for (const yearDir of fs.readdirSync(issuedDir)) {
|
||||
const yearPath = path.join(issuedDir, yearDir);
|
||||
if (!fs.statSync(yearPath).isDirectory()) continue;
|
||||
for (const monthDir of fs.readdirSync(yearPath)) {
|
||||
const monthPath = path.join(yearPath, monthDir);
|
||||
try {
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nas-financials-manager] stat failed for monthPath:",
|
||||
monthPath,
|
||||
err,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(monthPath, safeName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return { data: fs.readFileSync(filePath), fileName: safeName };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nas-financials-manager] readIssuedByNumber failed for:",
|
||||
documentNumber,
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
readIssued(relativePath: string): { data: Buffer; fileName: string } | null {
|
||||
const fullPath = this.resolveSafePath(relativePath);
|
||||
if (!fullPath) return null;
|
||||
|
||||
@@ -228,18 +228,30 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an issued-order (PO) number is not already used. */
|
||||
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.issued_orders.findFirst({
|
||||
/** Verify an issued-order (PO) number is not already used. Pass the tx client
|
||||
* when the check must be part of a transaction (otherwise it runs on a
|
||||
* separate connection and is only a best-effort pre-check). */
|
||||
export async function isIssuedOrderNumberTaken(
|
||||
number: string,
|
||||
client: Prisma.TransactionClient | PrismaClient = prisma,
|
||||
): Promise<boolean> {
|
||||
const existing = await client.issued_orders.findFirst({
|
||||
where: { po_number: number },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an offer/quotation number is not already used. */
|
||||
export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.quotations.findFirst({
|
||||
/** Verify an offer/quotation number is not already used. Pass the tx client
|
||||
* when the check must be part of a transaction (otherwise it runs on a
|
||||
* separate connection and is only a best-effort pre-check). */
|
||||
export async function isOfferNumberTaken(
|
||||
number: string,
|
||||
client: Prisma.TransactionClient | PrismaClient = prisma,
|
||||
): Promise<boolean> {
|
||||
const existing = await client.quotations.findFirst({
|
||||
where: { quotation_number: number },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
@@ -440,7 +452,18 @@ export async function previewInvoiceNumber(
|
||||
const code = settings?.invoice_type_code || "81";
|
||||
const year = _year || new Date().getFullYear();
|
||||
|
||||
const seq = await previewNextSequence("invoice", year);
|
||||
// Mirror generateInvoiceNumber's collision check (without consuming the
|
||||
// sequence). The number_sequences counter can lag behind real data, so the
|
||||
// raw next sequence may already be taken — advance past any number already
|
||||
// used by an invoice so the preview matches what generateInvoiceNumber
|
||||
// would actually assign instead of showing a stale, already-used number.
|
||||
let seq = await previewNextSequence("invoice", year);
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
if (!(await isInvoiceNumberTaken(number)))
|
||||
return { number, next_number: number };
|
||||
seq++;
|
||||
}
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
return { number, next_number: number };
|
||||
}
|
||||
@@ -482,7 +505,18 @@ export async function previewIssuedOrderNumber(
|
||||
const code = settings?.issued_order_type_code || "72";
|
||||
const year = _year || new Date().getFullYear();
|
||||
|
||||
const seq = await previewNextSequence("issued_order", year);
|
||||
// Mirror generateIssuedOrderNumber's collision check (without consuming the
|
||||
// sequence). The number_sequences counter can lag behind real data, so the
|
||||
// raw next sequence may already be taken — advance past any number already
|
||||
// used by an issued order so the preview matches what
|
||||
// generateIssuedOrderNumber would actually assign.
|
||||
let seq = await previewNextSequence("issued_order", year);
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
if (!(await isIssuedOrderNumberTaken(number)))
|
||||
return { number, next_number: number };
|
||||
seq++;
|
||||
}
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
return { number, next_number: number };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
generateOfferNumber,
|
||||
previewOfferNumber,
|
||||
releaseOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
assignOfferNumber,
|
||||
@@ -27,6 +27,44 @@ interface ScopeSectionInput {
|
||||
// Re-export for convenience
|
||||
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
|
||||
|
||||
/**
|
||||
* Offer status lifecycle (mirrors issued orders' table): a draft must be
|
||||
* finalized to `active` first (that's when the number is assigned), an active
|
||||
* offer can be ordered or invalidated, an ordered one only invalidated, and
|
||||
* `invalidated` is terminal. The create-order flow (orders.service
|
||||
* createOrderFromQuotation) consults this table too — an offer can only become
|
||||
* `ordered` from a status that allows the transition.
|
||||
*/
|
||||
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["active"],
|
||||
active: ["ordered", "invalidated"],
|
||||
ordered: ["invalidated"],
|
||||
invalidated: [],
|
||||
};
|
||||
|
||||
/** Fields (non-status) are editable only while the offer is draft/active. */
|
||||
function isEditableStatus(status: string): boolean {
|
||||
return status === "draft" || status === "active";
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-status update fields — used by the editable-state guard so a field edit
|
||||
* on an ordered/invalidated offer is rejected EXPLICITLY (not silently
|
||||
* dropped). `quotation_number` is stripped by the schema and is not listed.
|
||||
*/
|
||||
const NON_STATUS_UPDATE_FIELDS = [
|
||||
"project_code",
|
||||
"customer_id",
|
||||
"created_at",
|
||||
"valid_until",
|
||||
"currency",
|
||||
"language",
|
||||
"scope_title",
|
||||
"scope_description",
|
||||
"items",
|
||||
"sections",
|
||||
] as const;
|
||||
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"quotation_number",
|
||||
@@ -77,28 +115,22 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
|
||||
return where;
|
||||
}
|
||||
|
||||
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||
function enrichQuotation(q: any) {
|
||||
const subtotal = q.quotation_items
|
||||
const total = q.quotation_items
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vatAmount = q.apply_vat
|
||||
? subtotal *
|
||||
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
|
||||
100)
|
||||
: 0;
|
||||
const { quotation_items, scope_sections, ...rest } = q;
|
||||
return {
|
||||
...rest,
|
||||
items: quotation_items,
|
||||
sections: scope_sections,
|
||||
customer_name: q.customers?.name || null,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
total: Math.round((subtotal + vatAmount) * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,16 +140,24 @@ export async function listOffers(params: ListOffersParams) {
|
||||
|
||||
const where = buildOfferWhere(params);
|
||||
|
||||
// Tiebreak on id: created_at is second-precision, so same-second rows would
|
||||
// otherwise paginate non-deterministically (project ordering rule).
|
||||
const orderBy: Record<string, "asc" | "desc">[] = [
|
||||
{ [sortField]: order },
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
const [quotations, total] = await Promise.all([
|
||||
prisma.quotations.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
orderBy,
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
}),
|
||||
prisma.quotations.count({ where }),
|
||||
@@ -148,7 +188,7 @@ export async function listOffers(params: ListOffersParams) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
|
||||
* Sum offer NET total per currency across the WHOLE filtered set (not a
|
||||
* single page). Reuses `buildOfferWhere` so the filters track the list, and
|
||||
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
|
||||
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
||||
@@ -158,12 +198,12 @@ export async function getOfferTotals(
|
||||
): Promise<{ totals: CurrencyAmount[] }> {
|
||||
const where = buildOfferWhere(params);
|
||||
|
||||
// Only quotation_items — the per-currency NET math needs nothing else
|
||||
// (customers/scope_sections would just inflate the full-set scan).
|
||||
const quotations = await prisma.quotations.findMany({
|
||||
where,
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
quotation_items: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,7 +230,8 @@ export async function getOffer(id: number) {
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!quotation) return null;
|
||||
@@ -213,6 +254,9 @@ export async function getOffer(id: number) {
|
||||
customer: quotation.customers,
|
||||
customer_name: quotation.customers?.name || null,
|
||||
order: orderInfo,
|
||||
// Computed server-side so the frontend renders only legal status buttons
|
||||
// (same contract as getIssuedOrder).
|
||||
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,6 +269,18 @@ export async function createOffer(body: Record<string, any>) {
|
||||
? String(body.quotation_number)
|
||||
: null;
|
||||
|
||||
// Validate the referenced customer exists BEFORE the insert — a dangling
|
||||
// FK would otherwise surface as a P2003 500 instead of a clean 400
|
||||
// (mirrors createIssuedOrder's supplier check).
|
||||
const customerId = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (customerId) {
|
||||
const customer = await tx.customers.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) return { error: "customer_not_found" as const };
|
||||
}
|
||||
|
||||
// Deferred numbering: a draft carries NO quotation_number (the column is
|
||||
// nullable-unique so many drafts coexist). The official number is consumed
|
||||
// only when the draft is finalized (assignOfferNumber on draft->active).
|
||||
@@ -238,19 +294,15 @@ export async function createOffer(body: Record<string, any>) {
|
||||
// generateOfferNumber already verifies its own generated numbers, so this
|
||||
// guards the caller-supplied path.
|
||||
if (explicitNumber !== null) {
|
||||
const taken = await isOfferNumberTaken(explicitNumber);
|
||||
if (taken) {
|
||||
throw Object.assign(new Error("Číslo nabídky je již použito"), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const taken = await isOfferNumberTaken(explicitNumber, tx);
|
||||
if (taken) return { error: "number_taken" as const };
|
||||
}
|
||||
|
||||
const quotation = await tx.quotations.create({
|
||||
data: {
|
||||
quotation_number: quotationNumber,
|
||||
project_code: body.project_code ? String(body.project_code) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
customer_id: customerId,
|
||||
created_at: body.created_at
|
||||
? new Date(String(body.created_at))
|
||||
: undefined,
|
||||
@@ -259,8 +311,6 @@ export async function createOffer(body: Record<string, any>) {
|
||||
: null,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
status,
|
||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||
scope_description: body.scope_description
|
||||
@@ -299,11 +349,14 @@ export async function createOffer(body: Record<string, any>) {
|
||||
return quotation;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "status" in err) {
|
||||
return {
|
||||
error: err.message,
|
||||
status: (err as Error & { status: number }).status,
|
||||
} as const;
|
||||
// P2002 on the quotation_number unique index = a concurrent insert won the
|
||||
// race between the in-tx check and our insert — surface the same clean
|
||||
// conflict token instead of a 500 (mirrors createIssuedOrder).
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return { error: "number_taken" as const };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -312,31 +365,53 @@ export async function createOffer(body: Record<string, any>) {
|
||||
export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
if (existing.status === "invalidated")
|
||||
return { error: "invalidated" as const };
|
||||
|
||||
// A finalized offer's number is immutable. A draft has no number yet
|
||||
// (quotation_number === null) — it is assigned on finalize, not editable here,
|
||||
// so an empty/absent submitted value on a draft is not a "change" and must not
|
||||
// block the draft->active finalize.
|
||||
const currentStatus = existing.status;
|
||||
|
||||
// Status changes must follow the transition table (issued-orders parity).
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const newStatus = String(body.status);
|
||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
return { error: "invalid_transition" as const, currentStatus, newStatus };
|
||||
}
|
||||
}
|
||||
|
||||
// Editable-state guard: non-status fields may change only in draft/active.
|
||||
// In ordered/invalidated a field edit is REJECTED explicitly (it used to be
|
||||
// possible to rewrite everything in any status except invalidated).
|
||||
// Status-only transition payloads (e.g. ordered -> invalidated) still work.
|
||||
const editable = isEditableStatus(currentStatus);
|
||||
if (
|
||||
existing.quotation_number !== null &&
|
||||
body.quotation_number !== undefined &&
|
||||
String(body.quotation_number) !== existing.quotation_number
|
||||
!editable &&
|
||||
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
|
||||
) {
|
||||
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
|
||||
return { error: "not_editable" as const, currentStatus };
|
||||
}
|
||||
|
||||
// Explicit null CLEARS the customer; a set id must exist (dangling FK would
|
||||
// surface as a P2003 500). The old `Number(null)` coerced null to 0.
|
||||
let customerId: number | null | undefined = undefined;
|
||||
if (body.customer_id !== undefined) {
|
||||
customerId = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (customerId) {
|
||||
const customer = await prisma.customers.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) return { error: "customer_not_found" as const };
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize transition draft -> active: assign the official number ATOMICALLY
|
||||
// with the status change. assignOfferNumber is idempotent.
|
||||
const finalizing =
|
||||
existing.status === "draft" &&
|
||||
currentStatus === "draft" &&
|
||||
body.status !== undefined &&
|
||||
String(body.status) === "active";
|
||||
|
||||
const data = {
|
||||
customer_id:
|
||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
customer_id: customerId,
|
||||
created_at:
|
||||
body.created_at !== undefined
|
||||
? body.created_at
|
||||
@@ -351,13 +426,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
: undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
language: body.language !== undefined ? String(body.language) : undefined,
|
||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
apply_vat:
|
||||
body.apply_vat !== undefined
|
||||
? body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1"
|
||||
: undefined,
|
||||
status: body.status !== undefined ? String(body.status) : undefined,
|
||||
project_code:
|
||||
body.project_code !== undefined
|
||||
@@ -417,19 +485,39 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
await prisma.quotations.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
// newValues for the audit diff: the header fields actually written plus the
|
||||
// replaced collections (JSON.stringify drops the undefined = untouched keys).
|
||||
const newValues: Record<string, unknown> = { ...data };
|
||||
if (Array.isArray(body.items)) newValues.items = body.items;
|
||||
if (Array.isArray(body.sections)) newValues.sections = body.sections;
|
||||
|
||||
// Reflect the number the finalize transition just assigned (existing was read
|
||||
// before the assign, so its quotation_number is still the pre-finalize null).
|
||||
return {
|
||||
id,
|
||||
quotation_number: assignedNumber ?? existing.quotation_number,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
newValues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteOffer(id: number) {
|
||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
await prisma.quotations.delete({ where: { id } });
|
||||
try {
|
||||
await prisma.quotations.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
// P2003 = the offer is referenced by an order/project via a Restrict FK —
|
||||
// surface a clean conflict token instead of a 500.
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2003"
|
||||
) {
|
||||
return { error: "linked" as const };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
@@ -437,6 +525,8 @@ export async function deleteOffer(id: number) {
|
||||
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
|
||||
|
||||
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
|
||||
// The service is the SINGLE owner of this cleanup (the route used to repeat
|
||||
// it and always logged a spurious second-delete failure).
|
||||
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
|
||||
// file is already gone, so a NAS outage / missing file never throws here.
|
||||
if (existing.quotation_number && nasOffersManager.isConfigured()) {
|
||||
@@ -455,7 +545,7 @@ export async function deleteOffer(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
return { data: existing };
|
||||
}
|
||||
|
||||
export async function duplicateOffer(id: number) {
|
||||
@@ -463,7 +553,8 @@ export async function duplicateOffer(id: number) {
|
||||
where: { id },
|
||||
include: {
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!original) return null;
|
||||
@@ -479,8 +570,6 @@ export async function duplicateOffer(id: number) {
|
||||
valid_until: null,
|
||||
currency: original.currency,
|
||||
language: original.language,
|
||||
vat_rate: original.vat_rate,
|
||||
apply_vat: original.apply_vat,
|
||||
status: "active",
|
||||
scope_title: original.scope_title,
|
||||
scope_description: original.scope_description,
|
||||
@@ -520,11 +609,23 @@ export async function duplicateOffer(id: number) {
|
||||
|
||||
export async function invalidateOffer(id: number) {
|
||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
// The dedicated endpoint must respect the same transition table as PUT:
|
||||
// only active/ordered offers can be invalidated (a draft is deleted instead,
|
||||
// invalidated is terminal).
|
||||
const allowed = VALID_TRANSITIONS[existing.status] || [];
|
||||
if (!allowed.includes("invalidated")) {
|
||||
return {
|
||||
error: "invalid_transition" as const,
|
||||
currentStatus: existing.status,
|
||||
newStatus: "invalidated",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: { status: "invalidated", modified_at: new Date() },
|
||||
});
|
||||
return existing;
|
||||
return { data: existing };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
releaseProjectNumber,
|
||||
} from "./numbering.service";
|
||||
import { NasFileManager } from "./nas-file-manager";
|
||||
import { VALID_TRANSITIONS as OFFER_VALID_TRANSITIONS } from "./offers.service";
|
||||
|
||||
// Best-effort NAS folder creation for order-spawned projects, mirroring
|
||||
// projects.service. Stateless singleton; reads NAS_PATH at construction.
|
||||
@@ -72,23 +73,20 @@ async function syncProjectStatus(
|
||||
});
|
||||
}
|
||||
|
||||
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat,
|
||||
// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW
|
||||
// order/item field here, add it to that select — a field missing from the
|
||||
// select is silently 0 in the per-currency totals.
|
||||
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
|
||||
// quantity/unit_price/is_included_in_total). If you read a NEW order/item
|
||||
// field here, add it to that select — a field missing from the select is
|
||||
// silently 0 in the per-currency totals.
|
||||
//
|
||||
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||
function enrichOrder(o: any) {
|
||||
const subtotal = o.order_items
|
||||
const total = o.order_items
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vatAmount = o.apply_vat
|
||||
? subtotal *
|
||||
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
|
||||
100)
|
||||
: 0;
|
||||
const { order_items, order_sections, ...rest } = o;
|
||||
const invoice = o.invoices?.[0] || null;
|
||||
return {
|
||||
@@ -100,9 +98,7 @@ function enrichOrder(o: any) {
|
||||
project_code: o.quotations?.project_code || null,
|
||||
invoice_id: invoice?.id || null,
|
||||
invoice_number: invoice?.invoice_number || null,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
total: Math.round((subtotal + vatAmount) * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,7 +188,7 @@ export interface CurrencyAmount {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum order TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
|
||||
* Sum order NET total per currency across the WHOLE filtered set (not a
|
||||
* single page). Reuses `buildOrderWhere` so the filters track the list, and
|
||||
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
|
||||
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
||||
@@ -202,15 +198,13 @@ export async function getOrderTotals(
|
||||
): Promise<{ totals: CurrencyAmount[] }> {
|
||||
const where = buildOrderWhere(params);
|
||||
|
||||
// Select ONLY what the math needs (currency + VAT flags + item numbers).
|
||||
// Select ONLY what the math needs (currency + item numbers).
|
||||
// This runs over the WHOLE filtered set, so pulling attachment blobs,
|
||||
// sections HTML or relations here would multiply the query size for nothing.
|
||||
const orders = await prisma.orders.findMany({
|
||||
where,
|
||||
select: {
|
||||
currency: true,
|
||||
apply_vat: true,
|
||||
vat_rate: true,
|
||||
order_items: {
|
||||
select: {
|
||||
quantity: true,
|
||||
@@ -308,7 +302,7 @@ export async function createOrderFromQuotation(
|
||||
where: { id: quotationId },
|
||||
include: {
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -319,6 +313,19 @@ export async function createOrderFromQuotation(
|
||||
status: 400,
|
||||
} as const;
|
||||
|
||||
// The flow sets the offer's status to `ordered`, so it must respect the
|
||||
// offers transition table: only an offer whose current status allows the
|
||||
// `ordered` transition qualifies. A draft must be finalized first (it has no
|
||||
// number yet — an ordered offer without a number must never exist) and an
|
||||
// invalidated offer is terminal.
|
||||
const allowedFromStatus = OFFER_VALID_TRANSITIONS[quotation.status] || [];
|
||||
if (!allowedFromStatus.includes("ordered")) {
|
||||
return {
|
||||
error: `Objednávku nelze vytvořit z nabídky ve stavu "${quotation.status}"`,
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const orderNumber = await generateSharedNumber(tx);
|
||||
// The order keeps the shared (order) sequence; the auto-created project
|
||||
@@ -335,8 +342,6 @@ export async function createOrderFromQuotation(
|
||||
status: "prijata",
|
||||
currency: quotation.currency || "CZK",
|
||||
language: quotation.language || "cs",
|
||||
vat_rate: quotation.vat_rate ?? 21.0,
|
||||
apply_vat: quotation.apply_vat ?? true,
|
||||
scope_title: quotation.scope_title,
|
||||
scope_description: quotation.scope_description,
|
||||
attachment_data: attachmentBuffer
|
||||
@@ -428,8 +433,6 @@ interface CreateOrderData {
|
||||
status: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat?: boolean;
|
||||
exchange_rate?: number;
|
||||
scope_title?: string | null;
|
||||
scope_description?: string | null;
|
||||
@@ -469,8 +472,6 @@ export async function createOrder(
|
||||
status: body.status,
|
||||
currency: body.currency,
|
||||
language: body.language,
|
||||
vat_rate: body.vat_rate,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate,
|
||||
scope_title: body.scope_title ?? null,
|
||||
scope_description: body.scope_description ?? null,
|
||||
@@ -629,10 +630,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
}
|
||||
if (body.customer_id !== undefined)
|
||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||
if (body.apply_vat !== undefined)
|
||||
data.apply_vat =
|
||||
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
|
||||
|
||||
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
||||
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
||||
|
||||
@@ -63,7 +63,23 @@ async function getBrowser(): Promise<Browser> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function htmlToPdf(html: string): Promise<Buffer> {
|
||||
export interface HtmlToPdfOptions {
|
||||
/**
|
||||
* Chromium footerTemplate HTML rendered in the bottom margin of EVERY page
|
||||
* — the only true repeating footer Puppeteer supports (CSS @page margin
|
||||
* boxes are not implemented in Chromium). Styles must be inline and the
|
||||
* font-size explicit (Chromium defaults it to 0); `.pageNumber` /
|
||||
* `.totalPages` spans are substituted by Chromium. When set, the bottom
|
||||
* margin grows to make room and an empty headerTemplate suppresses
|
||||
* Chromium's default date/title header.
|
||||
*/
|
||||
footerTemplate?: string;
|
||||
}
|
||||
|
||||
export async function htmlToPdf(
|
||||
html: string,
|
||||
options: HtmlToPdfOptions = {},
|
||||
): Promise<Buffer> {
|
||||
const b = await getBrowser();
|
||||
const page = await b.newPage();
|
||||
// Per-request timeouts so one stuck render (e.g. image tag pointing at a
|
||||
@@ -78,7 +94,19 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "10mm", bottom: "10mm", left: "10mm", right: "10mm" },
|
||||
margin: {
|
||||
top: "10mm",
|
||||
bottom: options.footerTemplate ? "18mm" : "10mm",
|
||||
left: "10mm",
|
||||
right: "10mm",
|
||||
},
|
||||
...(options.footerTemplate
|
||||
? {
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: "<span></span>",
|
||||
footerTemplate: options.footerTemplate,
|
||||
}
|
||||
: {}),
|
||||
timeout: 15_000,
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
|
||||
116
src/utils/pdf-shared.ts
Normal file
116
src/utils/pdf-shared.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { localDateCzStr } from "./date";
|
||||
|
||||
/**
|
||||
* Shared helpers for the four PDF routes (offers-pdf, issued-orders-pdf,
|
||||
* invoices-pdf, orders-pdf). These used to be near-verbatim copies in each
|
||||
* route file; the canonical variants live here:
|
||||
*
|
||||
* - `cleanQuillHtml` is the STRICT variant (strips javascript:/data:/vbscript:
|
||||
* in BOTH href and src — defense-in-depth, DOMPurify runs first).
|
||||
* - `formatNum` uses the NBSP (U+00A0) thousands separator so amounts never
|
||||
* line-wrap inside the number.
|
||||
* - `formatCurrency` is the per-currency-symbol variant from offers
|
||||
* (€ / $ / Kč / £, anything else falls back to "<num> <code>").
|
||||
*/
|
||||
|
||||
/** dd.MM.yyyy in Europe/Prague; invalid input echoes back as-is. */
|
||||
export function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
/** Format number with comma decimal separator and NBSP thousands separator. */
|
||||
export function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
/** Per-currency symbol formatting (offers variant). */
|
||||
export function formatCurrency(amount: number, currency: string): string {
|
||||
const n = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case "EUR":
|
||||
return `${formatNum(n, 2)} €`;
|
||||
case "USD":
|
||||
return `$${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
case "CZK":
|
||||
return `${formatNum(n, 2)} Kč`;
|
||||
case "GBP":
|
||||
return `£${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
default:
|
||||
return `${formatNum(n, 2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
|
||||
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
|
||||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||||
*/
|
||||
export function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
// Remove dangerous tags with content
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
// Strip event handlers
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
// Strip javascript:/data:/vbscript: in href and src
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** CSS rules for Quill's .ql-indent-1..9 classes (identical in all templates). */
|
||||
export function buildQuillIndentCss(): string {
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
return indentCSS;
|
||||
}
|
||||
Reference in New Issue
Block a user