Files
app/src/admin/components/document/DocumentItemsEditor.tsx
BOHA 237ebf3ef8 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>
2026-06-24 21:36:35 +02:00

734 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
MouseSensor,
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;
/** Per-item discount as a percentage (0100). Offers only; 0 elsewhere. */
discount: string | number;
/** Offers only — undefined (issued orders) counts as included. */
is_included_in_total?: boolean;
}
/**
* Discount input styling: centered, and with the native number spinners hidden
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one line after its per-item percentage discount. */
const discountedLineNet = (item: DocumentItem): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
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,
discount: 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 + discountedLineNet(it),
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,
showDiscount,
itemDescriptionMaxLength,
onUpdate,
onRemove,
}: {
item: DocumentItem;
index: number;
currency: string;
readOnly: boolean;
canDelete: boolean;
showIncludedInTotal: boolean;
showDiscount: 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 = discountedLineNet(item);
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",
touchAction: "none",
}}
>
{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
minRows={2}
maxRows={16}
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" } }}
/>
{showDiscount && (
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
)}
</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",
touchAction: "none",
}}
>
{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
minRows={2}
maxRows={16}
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>
{showDiscount && (
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
</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;
/** Render the per-item "Sleva %" discount column (offers only). */
showDiscount?: 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;
/**
* 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;
}
/**
* 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,
showDiscount = false,
itemDescriptionMaxLength = 5000,
templatesSlot,
allowEmpty = false,
}: DocumentItemsEditorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
// pointer events and its 5px distance constraint fires before the
// TouchSensor's long-press delay — the browser then claims the gesture for
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
// TouchSensor handles touch via long-press; the drag handles additionally
// carry touch-action: none so the browser never starts a scroll from them.
const dndSensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 300, tolerance: 8 },
}),
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 (!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;
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={canDeleteRow}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
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>
{showDiscount && (
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</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={canDeleteRow}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
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>
);
}