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