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 TableRow from "@mui/material/TableRow";
import Checkbox from "@mui/material/Checkbox";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import {
offerDetailOptions,
offerCustomersOptions,
@@ -229,6 +231,8 @@ function SortableItemRow({
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,
@@ -239,6 +243,146 @@ function SortableItemRow({
};
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="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 (
<TableRow ref={setNodeRef} sx={style}>
{!readOnly && (
@@ -352,6 +496,8 @@ export default function OfferDetail() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
@@ -1392,76 +1538,95 @@ export default function OfferDetail() {
</Typography>
)}
<TableContainer sx={{ overflowX: "auto" }}>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex(
(i) => i._key === String(active.id),
);
const newIndex = prev.findIndex(
(i) => i._key === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}}
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex(
(i) => i._key === String(active.id),
);
const newIndex = prev.findIndex(
(i) => i._key === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
{!readOnly && <TableCell sx={{ width: "2rem" }} />}
<TableCell sx={{ width: "2.5rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5rem" }}>Množství</TableCell>
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>
<TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell>
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
</TableCell>
<TableCell sx={{ width: "7rem" }} align="right">
Celkem
</TableCell>
{!readOnly && <TableCell sx={{ width: "3rem" }} />}
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
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>
</SortableContext>
</DndContext>
</TableContainer>
{isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
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)}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
{!readOnly && <TableCell sx={{ width: "2rem" }} />}
<TableCell sx={{ width: "2.5rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5rem" }}>Množství</TableCell>
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>
<TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell>
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
</TableCell>
<TableCell sx={{ width: "7rem" }} align="right">
Celkem
</TableCell>
{!readOnly && <TableCell sx={{ width: "3rem" }} />}
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
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 */}
<Box