feat(invoice-detail): mobile card layout for the editable line-items grid

Same treatment as OfferDetail: on phones SortableInvoiceRow renders a stacked card (labeled Popis/Množství/Jednotka/Jedn.cena inputs + DPH select when apply_vat, Celkem footer, drag+remove header) and the wrapper renders a card stack instead of the table (DndContext/SortableContext kept). Verified at 430px on invoices/new: no inner table, 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:40:47 +02:00
parent aeec0b703c
commit b168c68264

View File

@@ -9,6 +9,8 @@ import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import Table from "@mui/material/Table";
@@ -200,6 +202,8 @@ function SortableInvoiceRow({
transition,
isDragging,
} = useSortable({ id: item._key });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const style = {
transform: CSS.Transform.toString(transform),
transition,
@@ -210,6 +214,132 @@ function SortableInvoiceRow({
};
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 }}>
<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 }} />
{canDelete && (
<IconButton
color="error"
size="small"
onClick={() => onRemove(index)}
title="Odebrat"
aria-label="Odebrat"
>
{RemoveIcon}
</IconButton>
)}
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Popis položky..."
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný"
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity}
onChange={(e) =>
onUpdate(index, "quantity", Number(e.target.value))
}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate(index, "unit", e.target.value)}
placeholder="ks"
/>
<TextField
label="Jedn. cena"
type="number"
value={item.unit_price}
onChange={(e) =>
onUpdate(index, "unit_price", Number(e.target.value))
}
slotProps={{ htmlInput: { step: "any" } }}
/>
{apply_vat && (
<Select
label="DPH"
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
options={vatOptions.map((o) => ({
value: String(o.value),
label: o.label,
}))}
/>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
alignItems: "baseline",
gap: 1,
pt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Celkem
</Typography>
<Typography
sx={{ fontFamily: "'DM Mono', Menlo, monospace", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
);
}
return (
<TableRow ref={setNodeRef} sx={style}>
<TableCell sx={{ width: "2rem", px: 0.5 }}>
@@ -319,6 +449,8 @@ function SortableInvoiceRow({
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const keyCounterRef = useRef(1);
const emptyItem = useCallback(
@@ -1797,70 +1929,90 @@ export default function InvoiceDetail() {
</Button>
</Box>
<TableContainer sx={{ overflowX: "auto" }}>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={handleCreateDragEnd}
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={handleCreateDragEnd}
>
<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" },
}}
{isMobile ? (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jedn. cena
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
{items.map((item, index) => (
<SortableInvoiceRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
<TableCell sx={{ width: "2.5rem" }} />
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableInvoiceRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</TableBody>
</Table>
</SortableContext>
</DndContext>
</TableContainer>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jedn. cena
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
<TableCell sx={{ width: "2.5rem" }} />
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableInvoiceRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
</SortableContext>
</DndContext>
{/* Totals */}
<Box