feat(offer-detail): mobile card layout for the line-items editor

On phones (< sm) the editable items grid was a wide horizontally-scrolling table of inputs — unusable. SortableItemRow now renders each item as a stacked card (labeled Popis / Množství / Jednotka / Cena/ks inputs, V ceně checkbox + Celkem, drag handle + remove in the header), and the wrapper renders a card stack instead of TableContainer/Table on mobile (DndContext/SortableContext preserved so drag still works via TouchSensor). Desktop keeps the table. Verified: no inner table on mobile, no page horizontal scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 16:32:08 +02:00
parent f0b5fb8a21
commit 2ff26241d7

View File

@@ -21,6 +21,8 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import Checkbox from "@mui/material/Checkbox"; import Checkbox from "@mui/material/Checkbox";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { import {
offerDetailOptions, offerDetailOptions,
offerCustomersOptions, offerCustomersOptions,
@@ -229,6 +231,8 @@ function SortableItemRow({
transition, transition,
isDragging, isDragging,
} = useSortable({ id: item._key, disabled: readOnly }); } = useSortable({ id: item._key, disabled: readOnly });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -239,6 +243,146 @@ function SortableItemRow({
}; };
const lineTotal = const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0); (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="Název položky"
InputProps={{ readOnly }}
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
placeholder="Volitelný"
InputProps={{ readOnly }}
/>
<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", parseInt(e.target.value, 10))}
slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
InputProps={{ readOnly }}
/>
<TextField
label="Cena/ks"
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
cursor: readOnly ? "default" : "pointer",
}}
>
<Checkbox
checked={item.is_included_in_total}
onChange={(e) =>
onUpdate("is_included_in_total", e.target.checked)
}
disabled={readOnly}
/>
<Typography variant="body2">V ceně</Typography>
</Box>
<Box sx={{ textAlign: "right" }}>
<Typography
variant="caption"
sx={{ color: "text.secondary", display: "block" }}
>
Celkem
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
</Box>
);
}
return ( return (
<TableRow ref={setNodeRef} sx={style}> <TableRow ref={setNodeRef} sx={style}>
{!readOnly && ( {!readOnly && (
@@ -352,6 +496,8 @@ export default function OfferDetail() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dndSensors = useSensors( const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { useSensor(TouchSensor, {
@@ -1392,76 +1538,95 @@ export default function OfferDetail() {
</Typography> </Typography>
)} )}
<TableContainer sx={{ overflowX: "auto" }}> <DndContext
<DndContext sensors={dndSensors}
sensors={dndSensors} collisionDetection={closestCenter}
collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={(event: DragEndEvent) => {
onDragEnd={(event: DragEndEvent) => { const { active, over } = event;
const { active, over } = event; if (!over || active.id === over.id) return;
if (!over || active.id === over.id) return; setItems((prev) => {
setItems((prev) => { const oldIndex = prev.findIndex(
const oldIndex = prev.findIndex( (i) => i._key === String(active.id),
(i) => i._key === String(active.id), );
); const newIndex = prev.findIndex(
const newIndex = prev.findIndex( (i) => i._key === String(over.id),
(i) => i._key === String(over.id), );
); if (oldIndex === -1 || newIndex === -1) return prev;
if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex);
return arrayMove(prev, oldIndex, newIndex); });
}); }}
}} >
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
> >
<SortableContext {isMobile ? (
items={items.map((i) => i._key)} <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
strategy={verticalListSortingStrategy} {items.map((item, index) => (
> <SortableItemRow
<Table key={item._key}
size="small" item={item}
sx={{ index={index}
minWidth: 720, currency={form.currency}
"& td, & th": { borderColor: "divider" }, readOnly={readOnly}
}} canDelete={items.length > 1}
> onUpdate={(field, value) =>
<TableHead> updateItem(index, field as keyof OfferItem, value)
<TableRow> }
{!readOnly && <TableCell sx={{ width: "2rem" }} />} onRemove={() => removeItem(index)}
<TableCell sx={{ width: "2.5rem" }} align="center"> />
# ))}
</TableCell> </Box>
<TableCell>Popis</TableCell> ) : (
<TableCell sx={{ width: "5rem" }}>Množství</TableCell> <TableContainer sx={{ overflowX: "auto" }}>
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell> <Table
<TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell> size="small"
<TableCell sx={{ width: "4rem" }} align="center"> sx={{
V ceně minWidth: 720,
</TableCell> "& td, & th": { borderColor: "divider" },
<TableCell sx={{ width: "7rem" }} align="right"> }}
Celkem >
</TableCell> <TableHead>
{!readOnly && <TableCell sx={{ width: "3rem" }} />} <TableRow>
</TableRow> {!readOnly && <TableCell sx={{ width: "2rem" }} />}
</TableHead> <TableCell sx={{ width: "2.5rem" }} align="center">
<TableBody> #
{items.map((item, index) => ( </TableCell>
<SortableItemRow <TableCell>Popis</TableCell>
key={item._key} <TableCell sx={{ width: "5rem" }}>Množství</TableCell>
item={item} <TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>
index={index} <TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell>
currency={form.currency} <TableCell sx={{ width: "4rem" }} align="center">
readOnly={readOnly} V ceně
canDelete={items.length > 1} </TableCell>
onUpdate={(field, value) => <TableCell sx={{ width: "7rem" }} align="right">
updateItem(index, field as keyof OfferItem, value) Celkem
} </TableCell>
onRemove={() => removeItem(index)} {!readOnly && <TableCell sx={{ width: "3rem" }} />}
/> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {items.map((item, index) => (
</SortableContext> <SortableItemRow
</DndContext> key={item._key}
</TableContainer> item={item}
index={index}
currency={form.currency}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
</SortableContext>
</DndContext>
{/* Totals */} {/* Totals */}
<Box <Box