feat(ui): unify VAT rate+toggle layout (offers/invoices/orders); move offers secondary row actions into an overflow menu

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 17:04:44 +02:00
parent c2d7a96718
commit 53c8359acc
4 changed files with 130 additions and 34 deletions

View File

@@ -1889,7 +1889,7 @@ export default function InvoiceDetail() {
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" }, gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: 2, gap: 2,
}} }}
> >
@@ -1934,6 +1934,23 @@ export default function InvoiceDetail() {
]} ]}
/> />
</Field> </Field>
{/* Document-default VAT rate. Presentational only — it writes the
already-existing `form.vat_rate` (used to seed new line rows /
as the per-line fallback). Totals are computed per-line from
`item.vat_rate`, so changing this does NOT alter computed VAT. */}
<Field label="Sazba DPH">
<Select
value={String(form.vat_rate)}
disabled={!form.apply_vat}
onChange={(val) =>
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
}
options={vatOptions.map((o) => ({
value: String(o.value),
label: o.label,
}))}
/>
</Field>
<Field label="DPH"> <Field label="DPH">
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}> <Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
<CheckboxField <CheckboxField

View File

@@ -1022,7 +1022,7 @@ export default function IssuedOrderDetail() {
]} ]}
/> />
</Field> </Field>
<Field label="Výchozí DPH"> <Field label="Sazba DPH">
<Select <Select
value={String(form.vat_rate)} value={String(form.vat_rate)}
disabled={!editable || !form.apply_vat} disabled={!editable || !form.apply_vat}

View File

@@ -1363,7 +1363,7 @@ export default function OfferDetail() {
gap: 2, gap: 2,
}} }}
> >
<Field label="Sazba DPH (%)"> <Field label="Sazba DPH">
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Select <Select
value={String(form.vat_rate)} value={String(form.vat_rate)}
@@ -1388,7 +1388,7 @@ export default function OfferDetail() {
onChange={(e) => updateForm("apply_vat", e.target.checked)} onChange={(e) => updateForm("apply_vat", e.target.checked)}
disabled={readOnly} disabled={readOnly}
/> />
<Box component="span">Účtovat DPH</Box> <Box component="span">Uplatnit DPH</Box>
</Box> </Box>
</Box> </Box>
</Field> </Field>

View File

@@ -4,6 +4,10 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
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 Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -228,6 +232,92 @@ const DeleteIcon = (
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg> </svg>
); );
const MoreIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
);
/**
* Per-row overflow menu for the secondary offer actions (Duplicate +
* Zneplatnit). Hook state (anchorEl) lives HERE, in a child component — never
* in the page-level `.map`/render callback — so each row owns its own menu
* (no shared-open bug) and Rules of Hooks stay satisfied. The page passes in
* the same handlers + permission/disabled flags the inline icons used, so
* behavior/permissions/confirms are preserved verbatim; the items are merely
* relocated. The menu renders nothing (returns null) when no secondary action
* is available for the row.
*/
function RowActionsMenu({
canDuplicate,
duplicating,
onDuplicate,
canInvalidate,
onInvalidate,
}: {
canDuplicate: boolean;
duplicating: boolean;
onDuplicate: () => void;
canInvalidate: boolean;
onInvalidate: () => void;
}) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (!canDuplicate && !canInvalidate) return null;
const close = () => setAnchorEl(null);
return (
<>
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
aria-label="Další akce"
title="Další akce"
>
{MoreIcon}
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{canDuplicate && (
<MenuItem
disabled={duplicating}
onClick={() => {
close();
onDuplicate();
}}
>
<ListItemIcon>
{duplicating ? <CircularProgress size={16} /> : DuplicateIcon}
</ListItemIcon>
<ListItemText>Duplikovat</ListItemText>
</MenuItem>
)}
{canInvalidate && (
<MenuItem
onClick={() => {
close();
onInvalidate();
}}
>
<ListItemIcon>{InvalidateIcon}</ListItemIcon>
<ListItemText>Zneplatnit</ListItemText>
</MenuItem>
)}
</Menu>
</>
);
}
export default function Offers() { export default function Offers() {
const alert = useAlert(); const alert = useAlert();
@@ -580,6 +670,16 @@ export default function Offers() {
const isInvalidated = q.status === "invalidated"; const isInvalidated = q.status === "invalidated";
const isCompleted = !isInvalidated && q.order_status === "dokoncena"; const isCompleted = !isInvalidated && q.order_status === "dokoncena";
const readOnly = isInvalidated || isCompleted; const readOnly = isInvalidated || isCompleted;
// Secondary actions (relocated into the overflow menu). Same gates as
// before: Duplicate needs offers.create and an editable (not
// read-only) row; Zneplatnit needs offers.edit and a row that is not
// invalidated/completed/ordered.
const canDuplicate = !readOnly && hasPermission("offers.create");
const canInvalidate =
!isInvalidated &&
!isCompleted &&
!q.order_id &&
hasPermission("offers.edit");
return ( return (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}> <Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton <IconButton
@@ -590,21 +690,6 @@ export default function Offers() {
> >
{readOnly ? ViewIcon : EditIcon} {readOnly ? ViewIcon : EditIcon}
</IconButton> </IconButton>
{!readOnly && hasPermission("offers.create") && (
<IconButton
size="small"
onClick={() => handleDuplicate(q)}
aria-label="Duplikovat"
title="Duplikovat"
disabled={duplicating === q.id}
>
{duplicating === q.id ? (
<CircularProgress size={16} />
) : (
DuplicateIcon
)}
</IconButton>
)}
{!readOnly && q.order_id ? ( {!readOnly && q.order_id ? (
<IconButton <IconButton
size="small" size="small"
@@ -637,21 +722,6 @@ export default function Offers() {
</IconButton> </IconButton>
) )
)} )}
{!isInvalidated &&
!isCompleted &&
!q.order_id &&
hasPermission("offers.edit") && (
<IconButton
size="small"
onClick={() =>
setInvalidateConfirm({ show: true, quotation: q })
}
aria-label="Zneplatnit"
title="Zneplatnit"
>
{InvalidateIcon}
</IconButton>
)}
{hasPermission("offers.export") && ( {hasPermission("offers.export") && (
<IconButton <IconButton
size="small" size="small"
@@ -674,6 +744,15 @@ export default function Offers() {
{DeleteIcon} {DeleteIcon}
</IconButton> </IconButton>
)} )}
<RowActionsMenu
canDuplicate={canDuplicate}
duplicating={duplicating === q.id}
onDuplicate={() => handleDuplicate(q)}
canInvalidate={canInvalidate}
onInvalidate={() =>
setInvalidateConfirm({ show: true, quotation: q })
}
/>
</Box> </Box>
); );
}, },