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 DOMPurify from "dompurify";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; 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 IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import Table from "@mui/material/Table"; import Table from "@mui/material/Table";
@@ -200,6 +202,8 @@ function SortableInvoiceRow({
transition, transition,
isDragging, isDragging,
} = useSortable({ id: item._key }); } = useSortable({ id: item._key });
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,
@@ -210,6 +214,132 @@ function SortableInvoiceRow({
}; };
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 }}>
<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 ( return (
<TableRow ref={setNodeRef} sx={style}> <TableRow ref={setNodeRef} sx={style}>
<TableCell sx={{ width: "2rem", px: 0.5 }}> <TableCell sx={{ width: "2rem", px: 0.5 }}>
@@ -319,6 +449,8 @@ function SortableInvoiceRow({
export default function InvoiceDetail() { export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id); const isEdit = Boolean(id);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const keyCounterRef = useRef(1); const keyCounterRef = useRef(1);
const emptyItem = useCallback( const emptyItem = useCallback(
@@ -1797,70 +1929,90 @@ export default function InvoiceDetail() {
</Button> </Button>
</Box> </Box>
<TableContainer sx={{ overflowX: "auto" }}> <DndContext
<DndContext sensors={dndSensors}
sensors={dndSensors} collisionDetection={closestCenter}
collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={handleCreateDragEnd}
onDragEnd={handleCreateDragEnd} >
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
> >
<SortableContext {isMobile ? (
items={items.map((i) => i._key)} <Box
strategy={verticalListSortingStrategy} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
> >
<TableHead> {items.map((item, index) => (
<TableRow> <SortableInvoiceRow
<TableCell sx={{ width: "2rem" }} /> key={item._key}
<TableCell sx={{ width: "2rem" }} align="center"> item={item}
# index={index}
</TableCell> currency={form.currency}
<TableCell>Popis</TableCell> apply_vat={!!form.apply_vat}
<TableCell sx={{ width: "5.5rem" }} align="center"> vatOptions={vatOptions}
Množství onUpdate={updateItem}
</TableCell> onRemove={removeItem}
<TableCell sx={{ width: "5.5rem" }} align="center"> canDelete={items.length > 1}
Jednotka />
</TableCell> ))}
<TableCell sx={{ width: "5.5rem" }} align="center"> </Box>
Jedn. cena ) : (
</TableCell> <TableContainer sx={{ overflowX: "auto" }}>
{form.apply_vat ? ( <Table
<TableCell sx={{ width: "5rem" }} align="center"> size="small"
DPH sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell> </TableCell>
) : null} <TableCell>Popis</TableCell>
<TableCell sx={{ width: "8rem" }} align="right"> <TableCell sx={{ width: "5.5rem" }} align="center">
Celkem Množství
</TableCell> </TableCell>
<TableCell sx={{ width: "2.5rem" }} /> <TableCell sx={{ width: "5.5rem" }} align="center">
</TableRow> Jednotka
</TableHead> </TableCell>
<TableBody> <TableCell sx={{ width: "5.5rem" }} align="center">
{items.map((item, index) => ( Jedn. cena
<SortableInvoiceRow </TableCell>
key={item._key} {form.apply_vat ? (
item={item} <TableCell sx={{ width: "5rem" }} align="center">
index={index} DPH
currency={form.currency} </TableCell>
apply_vat={!!form.apply_vat} ) : null}
vatOptions={vatOptions} <TableCell sx={{ width: "8rem" }} align="right">
onUpdate={updateItem} Celkem
onRemove={removeItem} </TableCell>
canDelete={items.length > 1} <TableCell sx={{ width: "2.5rem" }} />
/> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {items.map((item, index) => (
</SortableContext> <SortableInvoiceRow
</DndContext> key={item._key}
</TableContainer> 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 */} {/* Totals */}
<Box <Box