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