One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
654 lines
20 KiB
TypeScript
654 lines
20 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,
|
|
PointerSensor,
|
|
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;
|
|
/** Offers only — undefined (issued orders) counts as included. */
|
|
is_included_in_total?: boolean;
|
|
}
|
|
|
|
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,
|
|
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 + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
|
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,
|
|
itemDescriptionMaxLength,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
item: DocumentItem;
|
|
index: number;
|
|
currency: string;
|
|
readOnly: boolean;
|
|
canDelete: boolean;
|
|
showIncludedInTotal: 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 =
|
|
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
|
|
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" }}
|
|
>
|
|
{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
|
|
rows={2}
|
|
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" } }}
|
|
/>
|
|
</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" }}
|
|
>
|
|
{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
|
|
rows={2}
|
|
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>
|
|
{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;
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
itemDescriptionMaxLength = 5000,
|
|
templatesSlot,
|
|
}: DocumentItemsEditorProps) {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
|
const dndSensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 200, tolerance: 5 },
|
|
}),
|
|
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 (items.length <= 1) return;
|
|
onChange(items.filter((_, i) => i !== index));
|
|
};
|
|
|
|
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={items.length > 1}
|
|
showIncludedInTotal={showIncludedInTotal}
|
|
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>
|
|
{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={items.length > 1}
|
|
showIncludedInTotal={showIncludedInTotal}
|
|
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>
|
|
);
|
|
}
|