feat(documents)!: unify offers and issued orders end to end
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>
This commit is contained in:
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user