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:
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user