feat(issued-orders): make line items optional (issue by sections alone)

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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-24 21:36:35 +02:00
parent 983a1408f1
commit 237ebf3ef8
7 changed files with 174 additions and 17 deletions

View File

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

View File

@@ -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<string, string> = {};
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 */}