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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user