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; /** 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 = ( ); const RemoveIcon = ( ); 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 ( {!readOnly && ( {DragIcon} )} Položka {index + 1} {!readOnly && ( {RemoveIcon} )} onUpdate("description", e.target.value)} placeholder="Popis položky…" InputProps={{ readOnly }} slotProps={{ htmlInput: { maxLength: 500 } }} /> onUpdate("item_description", e.target.value)} placeholder="Volitelný" InputProps={{ readOnly }} multiline minRows={2} maxRows={16} slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }} sx={{ "& textarea": { resize: "vertical" } }} /> onUpdate("quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "center" } }} /> onUpdate("unit", e.target.value)} placeholder="ks" InputProps={{ readOnly }} slotProps={{ htmlInput: { maxLength: 20 } }} sx={{ "& input": { textAlign: "center" } }} /> onUpdate("unit_price", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "right" } }} /> {showIncludedInTotal && ( onUpdate("is_included_in_total", e.target.checked) } disabled={readOnly} /> V ceně )} Celkem {formatCurrency(lineTotal, currency)} ); } return ( {/* Stable column layout: every cell renders in read-only mode too — only the controls inside are hidden. */} {!readOnly && ( {DragIcon} )} {index + 1} onUpdate("description", e.target.value)} placeholder="Popis položky…" InputProps={{ readOnly }} slotProps={{ htmlInput: { maxLength: 500 } }} sx={{ "& input": { fontWeight: 500 } }} /> 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", }, }} /> onUpdate("quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "center" } }} /> onUpdate("unit", e.target.value)} placeholder="ks" InputProps={{ readOnly }} slotProps={{ htmlInput: { maxLength: 20 } }} sx={{ "& input": { textAlign: "center" } }} /> onUpdate("unit_price", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} sx={{ "& input": { textAlign: "right" } }} /> {showIncludedInTotal && ( onUpdate("is_included_in_total", e.target.checked)} disabled={readOnly} /> )} {formatCurrency(lineTotal, currency)} {!readOnly && ( {RemoveIcon} )} ); } 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")); // 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 (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 ( Položky {error && ( {error} )} {!readOnly && ( {templatesSlot} )} i._key)} strategy={verticalListSortingStrategy} > {isMobile ? ( {items.map((item, index) => ( 1} showIncludedInTotal={showIncludedInTotal} itemDescriptionMaxLength={itemDescriptionMaxLength} onUpdate={(field, value) => updateItem(index, field, value)} onRemove={() => removeItem(index)} /> ))} ) : ( # Popis Množství Jednotka Jedn. cena {showIncludedInTotal && ( V ceně )} Celkem {items.map((item, index) => ( 1} showIncludedInTotal={showIncludedInTotal} itemDescriptionMaxLength={itemDescriptionMaxLength} onUpdate={(field, value) => updateItem(index, field, value) } onRemove={() => removeItem(index)} /> ))}
)}
{/* Totals (NET only — offers/issued orders are not tax documents) */} Celkem bez DPH: {formatCurrency(total, currency)}
); }