feat(documents)!: unify offers and issued orders end to end

One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).

Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
  the PO template's red style (shared DocumentSectionSchema, one-transaction
  create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
  3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
  readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
  /file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
  number previews (also invoice previews), PUT returns assigned po_number

Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
  follows the table; order creation only from active offers) +
  valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
  issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
  linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
  DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
  dates, ints for positions); audit old/new values + koncept fallbacks;
  list id tiebreaks; stats include trimmed; NAS delete dedupe

Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
  document number (form field removed), one form layout/readonly convention,
  view-permission opens read-only everywhere (issued's editable-for-viewers
  hole closed), server-driven transition buttons, dirty guard + Enter submit
  on both, useApiMutation everywhere (new opt-in envelope mode), fixed
  infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
  numbers on offers, shared hardened per-row PDF flow (spinner, double-click
  guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
  states, query-lib cleanups (["offers","customers"] key, typed list rows,
  shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
  formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
  monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
  PDF language now comes from the document column

+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 16:22:39 +02:00
parent 1c1b9dca17
commit d1533ffc4d
35 changed files with 4762 additions and 2835 deletions

View File

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

View File

@@ -380,15 +380,34 @@ model issued_orders {
order_text String? @db.VarChar(500)
notes String? @db.Text
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

View File

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

View File

@@ -1,4 +1,12 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
@@ -19,7 +27,18 @@ import {
type IssuedOrderInput,
} from "../services/issued-orders.service";
import issuedOrdersRoutes from "../routes/admin/issued-orders";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
import issuedOrdersPdfRoutes, {
renderIssuedOrderHtml,
} from "../routes/admin/issued-orders-pdf";
import { nasOrdersManager } from "../services/nas-financials-manager";
import { htmlToPdf } from "../utils/html-to-pdf";
// Keep Puppeteer OUT of the tests: the /:id/file live-render fallback calls
// htmlToPdf — stub the whole module so no browser ever launches.
vi.mock("../utils/html-to-pdf", () => ({
htmlToPdf: vi.fn(async () => Buffer.from("%PDF-FAKE issued-orders-test")),
closeBrowser: vi.fn(async () => {}),
}));
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
@@ -186,15 +205,18 @@ describe("updateIssuedOrder status transitions", () => {
expect("error" in bad && bad.error).toBe("invalid_transition");
});
it("locks items once confirmed", async () => {
it("explicitly rejects a field edit once confirmed (not_editable), items unchanged", async () => {
const order = await mkIssued({
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, {
// The old behavior silently DROPPED the field edit; now it is an explicit
// not_editable rejection (offers parity) — and the items stay unchanged.
const res = await updateIssuedOrder(order.id, {
items: [{ description: "B", quantity: 9, unit_price: 99 }],
});
expect("error" in res && res.error).toBe("not_editable");
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
@@ -202,6 +224,18 @@ describe("updateIssuedOrder status transitions", () => {
expect(items[0].description).toBe("A");
});
it("status-only transition payloads still work when not editable", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await updateIssuedOrder(order.id, { status: "completed" });
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("completed");
});
it("rejects a nonexistent supplier_id on update", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
@@ -289,6 +323,10 @@ describe("listIssuedOrders month filter", () => {
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
let adminUserId = 0;
let adminFullName = "";
let lockerToken = "";
let lockerUserId = 0;
let noPermToken = "";
let noPermUserId = 0;
let noPermRoleId = 0;
@@ -310,6 +348,9 @@ beforeAll(async () => {
await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders",
});
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
@@ -320,6 +361,29 @@ beforeAll(async () => {
username: admin.username,
roleName: "admin",
});
adminUserId = admin.id;
adminFullName = `${admin.first_name} ${admin.last_name}`.trim();
// A SECOND user with edit rights (admin role → bypasses permission checks),
// to exercise the 423 lock conflict + the locked_by enrichment on GET /:id.
const locker = await prisma.users.create({
data: {
username: "io_test_locker",
email: "io_test_locker@test.local",
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Druhý",
last_name: "Editor",
is_active: true,
role_id: admin.role_id,
},
});
lockerUserId = locker.id;
lockerToken = generateToken({
id: locker.id,
username: locker.username,
roleName: "admin",
});
// A role with NO orders permissions, to prove the lookup endpoint is gated.
const noPermRole = await prisma.roles.create({
@@ -351,6 +415,8 @@ beforeAll(async () => {
});
afterAll(async () => {
if (lockerUserId)
await prisma.users.delete({ where: { id: lockerUserId } }).catch(() => {});
if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId)
@@ -415,6 +481,127 @@ describe("POST /api/admin/issued-orders supplier validation", () => {
});
});
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { notes: "pokus o úpravu" },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
});
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { status: "completed" },
});
expect(res.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("completed");
});
});
describe("issued-order audit old/new values", () => {
it("PUT writes an audit row carrying oldValues and newValues", async () => {
const order = await mkIssued({ notes: "před úpravou" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { notes: "po úpravě" },
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: {
entity_type: "issued_order",
entity_id: order.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!).notes).toBe("před úpravou");
expect(JSON.parse(audit!.new_values!).notes).toBe("po úpravě");
// Unnumbered drafts read "koncept", never "null".
expect(audit!.description).toContain("koncept");
expect(audit!.description).not.toContain("null");
});
it("DELETE writes an audit row carrying oldValues", async () => {
const order = await mkIssued({ notes: "ke smazání" });
const res = await app!.inject({
method: "DELETE",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: {
entity_type: "issued_order",
entity_id: order.id,
action: "delete",
},
orderBy: { id: "desc" },
});
expect(audit).not.toBeNull();
expect(JSON.parse(audit!.old_values!).notes).toBe("ke smazání");
expect(audit!.description).toContain("koncept");
});
});
describe("GET /api/admin/issued-orders-pdf/:id language default", () => {
it("defaults to the order's language column; ?lang= is an explicit override", async () => {
const order = await mkIssued({
language: "en",
items: [{ description: "Lang test", quantity: 1, unit_price: 10 }],
});
// Echo the rendered HTML through the htmlToPdf stub so the response body
// is assertable (still no Puppeteer). Once-scoped: the module-level fake
// ("%PDF-FAKE …") stays in place for the other tests.
vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) =>
Buffer.from(html),
);
const en = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders-pdf/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(en.statusCode).toBe(200);
expect(en.rawPayload.toString()).toContain("PURCHASE ORDER No.");
vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) =>
Buffer.from(html),
);
const cs = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders-pdf/${order.id}?lang=cs`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(cs.statusCode).toBe(200);
expect(cs.rawPayload.toString()).toContain("OBJEDNÁVKA č.");
});
});
/* -------------------------------------------------------------------------- */
/* PDF render */
/* -------------------------------------------------------------------------- */
@@ -537,8 +724,8 @@ describe("renderIssuedOrderHtml", () => {
// No VAT anywhere — the PO is not a tax document.
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// Line total = netto (2 × 100).
expect(html).toContain('<td class="right total-cell">200,00</td>');
// Line total = netto (2 × 100), formatted with the per-currency symbol.
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
// No Mezisoučet row (it would duplicate the grand total) and no
// prices-excl.-VAT notice (user removed it — the total label suffices).
@@ -553,6 +740,26 @@ describe("renderIssuedOrderHtml", () => {
expect(html).not.toContain("Prices are exclusive of VAT");
});
it("amounts carry the currency symbol; the 'Částky jsou uvedeny v' note is gone", () => {
const cz = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Unit price, line total and grand total all go through formatCurrency.
expect(cz).toContain("100,00 Kč");
expect(cz).toContain("200,00 Kč");
expect(cz).not.toContain("Částky jsou uvedeny v");
expect(cz).not.toContain("currency-note");
const en = renderIssuedOrderHtml(
{ ...order, currency: "EUR" },
items,
null,
null,
"en",
issuer,
);
expect(en).toContain("200,00 €");
expect(en).not.toContain("Amounts are in");
});
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line.
@@ -563,3 +770,448 @@ describe("renderIssuedOrderHtml", () => {
expect(html).not.toContain("Schválil:");
});
});
/* -------------------------------------------------------------------------- */
/* Sections (rich-text CZ/EN, mirrors offers' scope_sections) */
/* -------------------------------------------------------------------------- */
describe("CreateIssuedOrderSchema sections", () => {
it("accepts sections with DB-aligned 500-char titles and coerces position", () => {
const longTitle = "T".repeat(400); // >255 — the old offers cap under-shot
const ok = CreateIssuedOrderSchema.safeParse({
sections: [
{ title: longTitle, title_cz: "Rozsah", content: "<p>x</p>" },
{ title: null, position: "3" },
],
});
expect(ok.success).toBe(true);
if (ok.success) {
expect(ok.data.sections![0].title).toBe(longTitle);
expect(ok.data.sections![1].position).toBe(3);
}
const bad = CreateIssuedOrderSchema.safeParse({
sections: [{ title: "T".repeat(501) }],
});
expect(bad.success).toBe(false);
});
});
describe("issued-order sections (service)", () => {
it("creates sections ordered by position and maps them on getIssuedOrder", async () => {
const order = await mkIssued({
sections: [
{ title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>Obsah A</p>" },
{ title: "Second", content: "<p>Obsah B</p>" },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(2);
expect(detail!.sections[0].title).toBe("Scope EN");
expect(detail!.sections[0].title_cz).toBe("Rozsah CZ");
expect(detail!.sections[0].position).toBe(0);
expect(detail!.sections[1].title).toBe("Second");
expect(detail!.sections[1].position).toBe(1);
});
it("full-replaces sections on update and cascades them on delete", async () => {
const order = await mkIssued({
sections: [
{ title: "A", content: "<p>a</p>" },
{ title: "B", content: "<p>b</p>" },
],
});
await updateIssuedOrder(order.id, {
sections: [
{ title_cz: "Jediná sekce", content: "<p>Nové</p>", position: 5 },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(1);
expect(detail!.sections[0].title_cz).toBe("Jediná sekce");
expect(detail!.sections[0].position).toBe(5);
await deleteIssuedOrder(order.id);
expect(
(
await prisma.issued_order_sections.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
});
});
describe("updateIssuedOrder atomicity (single transaction)", () => {
it("rolls back the header write when the items replace fails", async () => {
const order = await mkIssued({
notes: "původní",
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
// issued_order_items.description is VarChar(500) — 600 chars makes the
// in-tx createMany fail at the DB, which must roll back the header update
// (the old two-transaction shape left the header committed: torn write).
const tooLong = "x".repeat(600);
await expect(
updateIssuedOrder(order.id, {
notes: "změněno",
items: [{ description: tooLong, quantity: 1, unit_price: 1 }],
}),
).rejects.toThrow();
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.notes).toBe("původní");
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
});
/* -------------------------------------------------------------------------- */
/* PO number uniqueness + preview collision-advance */
/* -------------------------------------------------------------------------- */
describe("po_number uniqueness", () => {
it("returns a clean 409 for an explicit duplicate po_number, not a 500", async () => {
await mkIssued({ po_number: "IODUP1" });
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { po_number: "IODUP1" },
});
expect(res.statusCode).toBe(409);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Číslo objednávky je již použito");
});
it("preview advances past a number already used by an existing order", async () => {
const p = await previewIssuedOrderNumber();
const order = await mkIssued({ po_number: p.number });
expect(order.po_number).toBe(p.number);
const p2 = await previewIssuedOrderNumber();
expect(p2.number).not.toBe(p.number);
});
});
/* -------------------------------------------------------------------------- */
/* Edit locking (mirrors offers/quotations.ts — 10s lock + heartbeat) */
/* -------------------------------------------------------------------------- */
describe("issued-order edit locking", () => {
const lockUrl = (id: number, action: string) =>
`/api/admin/issued-orders/${id}/${action}`;
it("locks for the first editor; a second user gets 423 with the holder's name", async () => {
const order = await mkIssued({});
const r1 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(r1.statusCode).toBe(200);
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(423);
expect(r2.json().error).toContain("Objednávku právě upravuje");
expect(r2.json().error).toContain(adminFullName);
});
it("re-locking by the SAME holder succeeds (refresh, not conflict)", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const again = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(again.statusCode).toBe(200);
});
it("heartbeat extends the lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// Age the lock close to expiry, then heartbeat — locked_at must be fresh.
const aged = new Date(Date.now() - 9000);
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: aged },
});
const hb = await app!.inject({
method: "POST",
url: lockUrl(order.id, "heartbeat"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(hb.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(Date.now() - new Date(row!.locked_at!).getTime()).toBeLessThan(5000);
});
it("unlock releases the lock so another user can take it", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const ul = await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(ul.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBeNull();
expect(row!.locked_at).toBeNull();
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
});
it("unlock by a non-holder does NOT release someone else's lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(adminUserId);
});
it("a stale lock (>30s without heartbeat) is takeable by another user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: new Date(Date.now() - 31000) },
});
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(lockerUserId);
});
it("GET /:id enriches locked_by ONLY for the other user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// The holder sees no lock banner (locked_by null).
const asHolder = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(asHolder.statusCode).toBe(200);
expect(asHolder.json().data.locked_by).toBeNull();
// The other user sees who holds the lock (offers' enrichment shape).
const asOther = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(asOther.statusCode).toBe(200);
const lockedBy = asOther.json().data.locked_by;
expect(lockedBy).not.toBeNull();
expect(lockedBy.user_id).toBe(adminUserId);
expect(lockedBy.full_name).toBe(adminFullName);
expect(typeof lockedBy.username).toBe("string");
});
});
/* -------------------------------------------------------------------------- */
/* GET /:id/file — archived PDF with live-render fallback */
/* -------------------------------------------------------------------------- */
describe("GET /api/admin/issued-orders/:id/file", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("404s for a draft without a po_number", async () => {
const order = await mkIssued({});
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("serves the archived PDF bytes inline on an archive hit", async () => {
const order = await mkIssued({ po_number: "IOTESTPOHIT" });
const bytes = Buffer.from("%PDF-ARCHIVED issued");
const readSpy = vi
.spyOn(nasOrdersManager, "readIssuedByNumber")
.mockReturnValue({ data: bytes, fileName: "IOTESTPOHIT.pdf" });
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
expect(res.headers["content-disposition"]).toContain("inline");
expect(res.headers["content-disposition"]).toContain(
"Objednavka-IOTESTPOHIT.pdf",
);
expect(res.rawPayload.equals(bytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith("IOTESTPOHIT");
});
it("falls back to a live render and re-archives on an archive miss", async () => {
const order = await mkIssued({
po_number: "IOTESTPOMISS",
order_date: "2026-03-15",
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
sections: [{ title_cz: "Rozsah", content: "<p>Obsah</p>" }],
});
vi.spyOn(nasOrdersManager, "readIssuedByNumber").mockReturnValue(null);
vi.spyOn(nasOrdersManager, "isConfigured").mockReturnValue(true);
const cleanSpy = vi
.spyOn(nasOrdersManager, "cleanIssued")
.mockImplementation(() => {});
const saveSpy = vi
.spyOn(nasOrdersManager, "saveIssuedPdf")
.mockReturnValue("Vydané/2026/03/IOTESTPOMISS.pdf");
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
// The stubbed htmlToPdf output is served (live render, no Puppeteer).
expect(res.rawPayload.toString()).toContain("%PDF-FAKE");
// The fresh PDF was archived to the NAS under the order_date year/month.
expect(cleanSpy).toHaveBeenCalledWith("IOTESTPOMISS");
expect(saveSpy).toHaveBeenCalledWith(
"IOTESTPOMISS",
2026,
3,
expect.any(Buffer),
);
});
});
/* -------------------------------------------------------------------------- */
/* PDF — sections scope page */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml sections (scope page)", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
notes: null,
delivery_terms: null,
payment_terms: null,
issued_by: null,
};
const items = [
{
description: "Materiál",
item_description: null,
quantity: 1,
unit: null,
unit_price: 100,
},
];
const issuer = { name: "Jan Novák" };
it("renders the scope page: cs prefers title_cz, sanitizes content, repeats the PO header", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
{
title: "Scope EN",
title_cz: "Rozsah CZ",
content: "<p>Obsah</p><script>alert(1)</script>",
},
{ title: "Only EN", title_cz: null, content: null },
]);
expect(html).toContain('class="scope-page"');
// cs → title_cz preferred when non-empty…
expect(html).toContain("Rozsah CZ");
expect(html).not.toContain("Scope EN");
// …and falls back to the EN title when title_cz is empty.
expect(html).toContain("Only EN");
expect(html).toContain("Obsah");
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
// The PO header block repeats on the scope page: <title> + main page
// header + scope page header = 3 occurrences (2 without the scope page).
expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(3);
});
it("uses the EN title for lang=en", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer, [
{ title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>x</p>" },
]);
expect(html).toContain("Scope EN");
expect(html).not.toContain("Rozsah CZ");
});
it("suppresses the whole page when every section is empty (tag-stripped)", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
{ title: "", title_cz: " ", content: "<p><br></p>" },
]);
expect(html).not.toContain('class="scope-page"');
});
it("omits the scope page when there are no sections at all", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).not.toContain('class="scope-page"');
});
});

View File

@@ -142,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)", () => {

View File

@@ -5,6 +5,8 @@ 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
@@ -73,6 +75,26 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
);
// 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");
});
});
@@ -84,10 +106,12 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
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" } },
@@ -103,6 +127,8 @@ beforeAll(async () => {
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();
});
@@ -144,4 +170,78 @@ describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
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ě");
});
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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 };
}

View 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 };
}

View File

@@ -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;
@@ -34,6 +40,15 @@ export interface IssuedOrderItem {
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 {
exchange_rate: number | string | null;
delivery_date: string | null;
@@ -45,8 +60,15 @@ export interface IssuedOrderDetail extends IssuedOrder {
notes: 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
@@ -65,6 +87,7 @@ export const issuedOrderListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
month?: number;
year?: number;
}) =>
@@ -78,6 +101,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();
@@ -87,14 +112,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;
}) =>
@@ -104,6 +125,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();
@@ -120,4 +143,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 || ""),
});

View File

@@ -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) {

View File

@@ -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}` : ""}`,
);
},
});
@@ -162,6 +181,8 @@ export interface OfferDetailData {
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) =>

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
/>
)
@@ -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

View File

@@ -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") && (
@@ -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}

View File

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

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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)}">

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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;
@@ -202,7 +160,6 @@ const translations: Record<Lang, Record<string, string>> = {
col_unit_price: "Jedn. cena",
col_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:",
@@ -224,7 +181,6 @@ const translations: Record<Lang, Record<string, string>> = {
col_unit_price: "Unit price",
col_total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
@@ -255,6 +211,17 @@ interface IssuedOrderPdfItem {
unit_price: 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();
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
@@ -262,6 +229,7 @@ export function renderIssuedOrderHtml(
settings: Record<string, unknown> | null,
lang: Lang,
issuer: { name: string },
sections: IssuedOrderPdfSection[] = [],
): string {
const t = translations[lang];
const currency = order.currency || "CZK";
@@ -311,8 +279,8 @@ export function renderIssuedOrderHtml(
<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 total-cell">${formatNum(lineTotal)}</td>
<td class="right">${formatCurrency(unitPrice, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
})
.join("");
@@ -344,12 +312,53 @@ export function renderIssuedOrderHtml(
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 (scope) page — mirrors offers-pdf's .scope-page, but repeats
// THIS template's red PO header (logo + heading + dates), not the offers'
// monochrome one. The whole page is suppressed 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-pdf).
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 a scope page holding nothing but the header.
const visibleSections = sections.filter(
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
);
const hasSectionContent = visibleSections.length > 0;
let scopeHtml = "";
if (hasSectionContent) {
const scopeDates = `<div class="scope-dates"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span>${
deliveryDateStr
? ` <span class="sep">·</span> <span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span>`
: ""
}</div>`;
scopeHtml += '<div class="scope-page">';
scopeHtml += `
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
${scopeDates}`;
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>
@@ -583,13 +592,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;
@@ -646,6 +648,49 @@ export function renderIssuedOrderHtml(
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page,
hlavicka se opakuje v cervenem stylu teto sablony */
.scope-page {
page-break-before: always;
}
.scope-dates {
font-size: 9pt;
color: #666;
margin: 1mm 0 4mm 0;
}
.scope-dates .val { font-weight: 600; color: #1a1a1a; }
.scope-dates .sep { color: #d0d0d0; }
.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;
}
.section-content {
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.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; }
.ql-align-center { text-align: center; }
@@ -666,10 +711,11 @@ ${indentCSS}
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
.invoice-page, .scope-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
@@ -733,14 +779,15 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty (jen souhrnny radek bez DPH - 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">
<div class="grand">
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
@@ -757,6 +804,8 @@ ${indentCSS}
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
${scopeHtml}
</body>
</html>`;
}
@@ -773,7 +822,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 } });
@@ -783,10 +831,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 },
@@ -811,6 +872,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
settings,
lang,
issuer,
sections,
);
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors

View File

@@ -18,6 +18,15 @@ import {
deleteIssuedOrder,
getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service";
import { renderIssuedOrderHtml } 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 +35,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 +65,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 +128,215 @@ 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);
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 +351,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 +385,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 +403,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 +433,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");
},

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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[];
@@ -210,120 +132,117 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
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(/&nbsp;/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,
);
// 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);
}
}
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>`;
});
});
// No Mezisoučet/VAT rows — they would only duplicate the net total.
const totalsHtml = `<div class="grand">
// 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>
@@ -334,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" />
@@ -732,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

View File

@@ -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";
@@ -32,55 +38,6 @@ const OrderPdfBodySchema = z.looseObject({
/* ── 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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[];
@@ -249,9 +206,8 @@ interface OrderConfirmationPdfData {
/**
* 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" and the totals box carries the fixed prices-excl.-VAT
* notice. Exported for tests.
* 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,
@@ -330,13 +286,7 @@ export function renderOrderConfirmationHtml(
: "";
// 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();
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
positiveNumberFromForm,
nullableIntIdFromForm,
isoDateString,
DocumentSectionSchema,
} from "./common";
export const IssuedOrderItemSchema = z.object({
@@ -41,6 +42,14 @@ export const CreateIssuedOrderSchema = z.object({
notes: z.string().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,
});

View File

@@ -1,52 +1,43 @@
import { z } from "zod";
import {
numberFromForm,
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"),
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(),
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
@@ -56,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>;

View File

@@ -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;
@@ -17,6 +19,13 @@ export interface IssuedOrderItemInput {
position?: number | null;
}
export interface IssuedOrderSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
position?: number | null;
}
export interface IssuedOrderInput {
po_number?: string | number | null;
supplier_id?: number | string | null;
@@ -33,6 +42,7 @@ export interface IssuedOrderInput {
notes?: string | null;
internal_notes?: string | null;
items?: IssuedOrderItemInput[];
sections?: IssuedOrderSectionInput[];
[key: string]: unknown;
}
@@ -100,9 +110,33 @@ const ALLOWED_SORT_FIELDS = [
"po_number",
"status",
"order_date",
"created_at",
"currency",
];
/**
* 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",
"delivery_terms",
"payment_terms",
"issued_by",
"order_text",
"notes",
"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.
@@ -211,13 +245,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] || [],
@@ -225,83 +264,117 @@ 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:0002: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,
order_text: body.order_text ? String(body.order_text) : null,
notes: body.notes ? String(body.notes) : 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",
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:0002: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,
order_text: body.order_text ? String(body.order_text) : 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,
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) {
@@ -318,7 +391,18 @@ 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) {
@@ -375,46 +459,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,
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) {
@@ -428,6 +533,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;
}

View File

@@ -119,6 +119,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;

View File

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

View File

@@ -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",
@@ -102,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 }),
@@ -152,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,
},
});
@@ -184,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;
@@ -207,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] || [],
};
}
@@ -219,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).
@@ -232,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,
@@ -291,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;
}
@@ -304,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
@@ -402,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()
@@ -422,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()) {
@@ -440,7 +545,7 @@ export async function deleteOffer(id: number) {
}
}
return existing;
return { data: existing };
}
export async function duplicateOffer(id: number) {
@@ -448,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;
@@ -503,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 };
}

View File

@@ -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.
@@ -301,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" }] },
},
});
@@ -312,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

116
src/utils/pdf-shared.ts Normal file
View 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)}`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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;
}