From 237ebf3ef8f7e33446ea83795df894cb452a63fa Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 24 Jun 2026 21:36:35 +0200 Subject: [PATCH] feat(issued-orders): make line items optional (issue by sections alone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items are no longer required on issued orders — an order can be issued purely from its rich-text sections (Obsah). - DocumentItemsEditor: opt-in allowEmpty prop lets the list go to zero rows (default false keeps the offers/invoices at-least-one-item rule). - IssuedOrderDetail: allowEmpty on the editor; a saved order with no items reopens empty (not a blank starter row); submit guard now requires at least one item OR a non-empty section, not an item. - PDF: with no items the billing heading, items table and total row are omitted entirely — a sections-only order shows only its Obsah. - Service: finalize (draft->sent) and create-as-non-draft reject a completely blank order (no items AND no sections) with a Czech 400 (empty_document); drafts may still be saved empty as WIP. - Tests: sections-only finalize + blank-order rejection + sections-only PDF render; existing finalize fixtures given minimal content. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../drafts-deferred-numbering.test.ts | 8 +- src/__tests__/issued-orders.test.ts | 80 +++++++++++++++++-- .../document/DocumentItemsEditor.tsx | 16 +++- src/admin/pages/IssuedOrderDetail.tsx | 19 ++++- src/routes/admin/issued-orders-pdf.ts | 11 ++- src/routes/admin/issued-orders.ts | 4 + src/services/issued-orders.service.ts | 53 ++++++++++++ 7 files changed, 174 insertions(+), 17 deletions(-) diff --git a/src/__tests__/drafts-deferred-numbering.test.ts b/src/__tests__/drafts-deferred-numbering.test.ts index d260460..e909caa 100644 --- a/src/__tests__/drafts-deferred-numbering.test.ts +++ b/src/__tests__/drafts-deferred-numbering.test.ts @@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => { it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => { const expected = (await previewIssuedOrderNumber()).number; - const draft = await createIssuedOrder({}); + const draft = await createIssuedOrder({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); if ("error" in draft) throw new Error(`createIssuedOrder failed: ${draft.error}`); issuedOrderIds.push(draft.id); @@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => { }); it("finalizing is idempotent — re-finalizing does not re-number", async () => { - const draft = await createIssuedOrder({}); + const draft = await createIssuedOrder({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); if ("error" in draft) throw new Error(`createIssuedOrder failed: ${draft.error}`); issuedOrderIds.push(draft.id); diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index 831e6a6..8ce09bc 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -169,7 +169,10 @@ describe("createIssuedOrder", () => { it("numbers immediately when created already-finalized (status sent)", async () => { const before = (await previewIssuedOrderNumber()).number; - const order = await mkIssued({ status: "sent" }); + const order = await mkIssued({ + status: "sent", + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); expect(order.po_number).toBe(before); expect(order.status).toBe("sent"); }); @@ -199,7 +202,9 @@ describe("createIssuedOrder", () => { describe("updateIssuedOrder status transitions", () => { it("allows draft -> sent and rejects draft -> completed", async () => { - const order = await mkIssued({}); + const order = await mkIssued({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); const ok = await updateIssuedOrder(order.id, { status: "sent" }); expect("error" in ok).toBe(false); const bad = await updateIssuedOrder(order.id, { status: "completed" }); @@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => { }); it("status-only transition payloads still work when not editable", async () => { - const order = await mkIssued({}); + const order = await mkIssued({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); const res = await updateIssuedOrder(order.id, { status: "completed" }); @@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => { const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); expect("error" in res && res.error).toBe("supplier_not_found"); }); + + it("finalizes a sections-only order (no items) successfully", async () => { + const order = await mkIssued({}); + const res = await updateIssuedOrder(order.id, { + status: "sent", + items: [], + sections: [ + { title: "Scope", title_cz: "Rozsah prací", content: "

Detail

" }, + ], + }); + expect("error" in res).toBe(false); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.status).toBe("sent"); + expect(row!.po_number).toBeTruthy(); + }); + + it("rejects finalizing a completely blank order (no items, no sections)", async () => { + const order = await mkIssued({}); + const res = await updateIssuedOrder(order.id, { + status: "sent", + items: [], + sections: [], + }); + expect("error" in res && res.error).toBe("empty_document"); + // Stays a draft — the finalize was rejected before any write. + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.status).toBe("draft"); + expect(row!.po_number).toBeNull(); + }); + + it("rejects creating a non-draft order with no content", async () => { + const res = await createIssuedOrder({ status: "sent" }); + expect("error" in res && res.error).toBe("empty_document"); + }); }); describe("getIssuedOrder", () => { @@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => { 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({}); + const order = await mkIssued({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); @@ -543,7 +590,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { // The IssuedOrderDetail transition flushes unsaved edits by sending the // full payload + status in ONE PUT while the order is still editable // (sent). The server must apply the items AND the transition together. - const order = await mkIssued({}); + const order = await mkIssued({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); await updateIssuedOrder(order.id, { status: "sent" }); const res = await app!.inject({ @@ -574,7 +623,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { }); it("a status-only transition payload keeps working (confirmed -> completed)", async () => { - const order = await mkIssued({}); + const order = await mkIssued({ + items: [{ description: "Položka", quantity: 1, unit_price: 1 }], + }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); @@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => { expect(html).toContain("Odběratel"); }); + it("omits the items table and total when there are no items (sections-only order)", () => { + const html = renderIssuedOrderHtml( + order, + [], + supplier, + { company_name: "Naše firma" }, + "cs", + issuer, + [{ title: "Scope", title_cz: "Rozsah prací", content: "

Detail

" }], + ); + // The section renders… + expect(html).toContain("Rozsah prací"); + // …but the items table, the billing heading and the total row do not. + expect(html).not.toContain('class="items"'); + expect(html).not.toContain("Celkem bez DPH"); + }); + it("renders the structured supplier address plus IČO and DIČ", () => { const html = renderIssuedOrderHtml( order, diff --git a/src/admin/components/document/DocumentItemsEditor.tsx b/src/admin/components/document/DocumentItemsEditor.tsx index df1700c..f6d5217 100644 --- a/src/admin/components/document/DocumentItemsEditor.tsx +++ b/src/admin/components/document/DocumentItemsEditor.tsx @@ -492,6 +492,12 @@ interface DocumentItemsEditorProps { itemDescriptionMaxLength?: number; /** Optional page-specific control (e.g. item-template Select) next to the add button. */ templatesSlot?: ReactNode; + /** + * Allow removing every row so the list can be empty (issued orders may be + * issued by sections alone). Default false keeps the offers/invoices rule of + * at least one item. + */ + allowEmpty?: boolean; } /** @@ -509,6 +515,7 @@ export default function DocumentItemsEditor({ showDiscount = false, itemDescriptionMaxLength = 5000, templatesSlot, + allowEmpty = false, }: DocumentItemsEditorProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -538,10 +545,13 @@ export default function DocumentItemsEditor({ const addItem = () => onChange([...items, emptyDocumentItem()]); const removeItem = (index: number) => { - if (items.length <= 1) return; + if (!allowEmpty && items.length <= 1) return; onChange(items.filter((_, i) => i !== index)); }; + // When allowEmpty, even the last row is deletable (list may go to zero). + const canDeleteRow = allowEmpty || items.length > 1; + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; @@ -608,7 +618,7 @@ export default function DocumentItemsEditor({ index={index} currency={currency} readOnly={readOnly} - canDelete={items.length > 1} + canDelete={canDeleteRow} showIncludedInTotal={showIncludedInTotal} showDiscount={showDiscount} itemDescriptionMaxLength={itemDescriptionMaxLength} @@ -666,7 +676,7 @@ export default function DocumentItemsEditor({ index={index} currency={currency} readOnly={readOnly} - canDelete={items.length > 1} + canDelete={canDeleteRow} showIncludedInTotal={showIncludedInTotal} showDiscount={showDiscount} itemDescriptionMaxLength={itemDescriptionMaxLength} diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index 79c376c..b14122d 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -301,7 +301,9 @@ export default function IssuedOrderDetail() { // Issued orders have no Sleva column; keep the shared item shape happy. discount: 0, })) - : [emptyDocumentItem()]; + : // Issued orders may be issued by sections alone — a saved order with + // no items reopens with an empty list (not a blank starter row). + []; setItems(mappedItems); const mappedSections = @@ -401,8 +403,18 @@ export default function IssuedOrderDetail() { const newErrors: Record = {}; if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele"; if (!form.order_date) newErrors.order_date = "Zadejte datum"; - if (items.length === 0 || items.every((i) => !i.description.trim())) { - newErrors.items = "Přidejte alespoň jednu položku"; + // Items are optional (an order may be issued by sections alone), but a + // completely blank order must not be finalizable: require at least one + // item with a description OR one non-empty section (title or content). + const hasItem = items.some((i) => i.description.trim()); + const hasSection = sections.some( + (s) => + (s.title_cz || "").trim() || + (s.title || "").trim() || + (s.content || "").replace(/<[^>]*>/g, "").trim(), + ); + if (!hasItem && !hasSection) { + newErrors.items = "Přidejte alespoň jednu položku nebo obsah"; } setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; @@ -899,6 +911,7 @@ export default function IssuedOrderDetail() { currency={form.currency} readOnly={!editable} error={errors.items} + allowEmpty /> {/* Rich-text PDF sections — the free-form PDF content lives here */} diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index bc94e0d..623b1f2 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -752,8 +752,11 @@ ${indentCSS} - -
${escapeHtml(order.order_text || t.billing)}
+ + ${ + items.length > 0 + ? `
${escapeHtml(order.order_text || t.billing)}
@@ -779,7 +782,9 @@ ${indentCSS} ${formatCurrency(total, currency)} - + ` + : "" + } ${scopeHtml} diff --git a/src/routes/admin/issued-orders.ts b/src/routes/admin/issued-orders.ts index 4305596..e01e57f 100644 --- a/src/routes/admin/issued-orders.ts +++ b/src/routes/admin/issued-orders.ts @@ -366,6 +366,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { return error(reply, "Dodavatel nenalezen", 400); if (order.error === "po_number_taken") return error(reply, "Číslo objednávky je již použito", 409); + if (order.error === "empty_document") + return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400); return error(reply, "Neznámá chyba", 500); } await logAudit({ @@ -408,6 +410,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400, ); + if (result.error === "empty_document") + return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400); return error(reply, "Neznámá chyba", 500); } await logAudit({ diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts index 7925e2c..6e46eeb 100644 --- a/src/services/issued-orders.service.ts +++ b/src/services/issued-orders.service.ts @@ -148,6 +148,32 @@ export function computeIssuedOrderTotals( return { total: Math.round(total * 100) / 100 }; } +/** + * An issued order may be issued by sections alone (no line items), but a + * completely blank order must not be finalizable. Content = at least one item + * with a description OR one section with a title or stripped (non-tag) content. + * Mirrors the PDF visibility test and the frontend submit guard. + */ +function hasOrderContent( + items: Array<{ description?: string | null }> | undefined, + sections: + | Array<{ + title?: string | null; + title_cz?: string | null; + content?: string | null; + }> + | undefined, +): boolean { + const anyItem = (items || []).some((it) => (it.description || "").trim()); + const anySection = (sections || []).some( + (s) => + (s.title_cz || "").trim() || + (s.title || "").trim() || + (s.content || "").replace(/<[^>]*>/g, "").trim(), + ); + return anyItem || anySection; +} + export async function listIssuedOrders(params: ListIssuedOrdersParams) { const { page, limit, skip, sort, order } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; @@ -269,6 +295,12 @@ export async function createIssuedOrder(body: IssuedOrderInput) { return await prisma.$transaction(async (tx) => { const status = body.status ? String(body.status) : "draft"; + // A non-draft (created-as-finalized) order must not be blank — require an + // item or a non-empty section. Drafts may be saved empty (WIP). + if (status !== "draft" && !hasOrderContent(body.items, body.sections)) { + return { error: "empty_document" as const }; + } + // 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; @@ -452,6 +484,27 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { body.status !== undefined && String(body.status) === "sent"; + // Finalizing a blank order is rejected — require an item or a non-empty + // section. The finalize payload carries items/sections; fall back to the + // stored rows when a partial finalize omits them. + if (finalizing) { + const itemsForCheck = Array.isArray(body.items) + ? body.items + : await prisma.issued_order_items.findMany({ + where: { issued_order_id: id }, + select: { description: true }, + }); + const sectionsForCheck = Array.isArray(body.sections) + ? body.sections + : await prisma.issued_order_sections.findMany({ + where: { issued_order_id: id }, + select: { title: true, title_cz: true, content: true }, + }); + if (!hasOrderContent(itemsForCheck, sectionsForCheck)) { + return { error: "empty_document" as const }; + } + } + // 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