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
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: 2,
}}
>
@@ -1934,6 +1934,23 @@ export default function InvoiceDetail() {
]}
/>
</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">
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
<CheckboxField

View File

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

View File

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

View File

@@ -4,6 +4,10 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
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 { useAuth } from "../context/AuthContext";
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" />
</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() {
const alert = useAlert();
@@ -580,6 +670,16 @@ export default function Offers() {
const isInvalidated = q.status === "invalidated";
const isCompleted = !isInvalidated && q.order_status === "dokoncena";
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 (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
@@ -590,21 +690,6 @@ export default function Offers() {
>
{readOnly ? ViewIcon : EditIcon}
</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 ? (
<IconButton
size="small"
@@ -637,21 +722,6 @@ export default function Offers() {
</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") && (
<IconButton
size="small"
@@ -674,6 +744,15 @@ export default function Offers() {
{DeleteIcon}
</IconButton>
)}
<RowActionsMenu
canDuplicate={canDuplicate}
duplicating={duplicating === q.id}
onDuplicate={() => handleDuplicate(q)}
canInvalidate={canInvalidate}
onInvalidate={() =>
setInvalidateConfirm({ show: true, quotation: q })
}
/>
</Box>
);
},