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