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>
734 lines
22 KiB
TypeScript
734 lines
22 KiB
TypeScript
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 (0–100). 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>
|
||
);
|
||
}
|