diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx
index 1b9888e..24b2e5b 100644
--- a/src/admin/pages/InvoiceDetail.tsx
+++ b/src/admin/pages/InvoiceDetail.tsx
@@ -1889,7 +1889,7 @@ export default function InvoiceDetail() {
@@ -1934,6 +1934,23 @@ export default function InvoiceDetail() {
]}
/>
+ {/* 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. */}
+
+
-
+
diff --git a/src/admin/pages/Offers.tsx b/src/admin/pages/Offers.tsx
index 0eb6c53..98f9247 100644
--- a/src/admin/pages/Offers.tsx
+++ b/src/admin/pages/Offers.tsx
@@ -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 = (
);
+const MoreIcon = (
+
+);
+
+/**
+ * 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(null);
+ if (!canDuplicate && !canInvalidate) return null;
+ const close = () => setAnchorEl(null);
+ return (
+ <>
+ setAnchorEl(e.currentTarget)}
+ aria-label="Další akce"
+ title="Další akce"
+ >
+ {MoreIcon}
+
+
+ >
+ );
+}
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 (
{readOnly ? ViewIcon : EditIcon}
- {!readOnly && hasPermission("offers.create") && (
- handleDuplicate(q)}
- aria-label="Duplikovat"
- title="Duplikovat"
- disabled={duplicating === q.id}
- >
- {duplicating === q.id ? (
-
- ) : (
- DuplicateIcon
- )}
-
- )}
{!readOnly && q.order_id ? (
)
)}
- {!isInvalidated &&
- !isCompleted &&
- !q.order_id &&
- hasPermission("offers.edit") && (
-
- setInvalidateConfirm({ show: true, quotation: q })
- }
- aria-label="Zneplatnit"
- title="Zneplatnit"
- >
- {InvalidateIcon}
-
- )}
{hasPermission("offers.export") && (
)}
+ handleDuplicate(q)}
+ canInvalidate={canInvalidate}
+ onInvalidate={() =>
+ setInvalidateConfirm({ show: true, quotation: q })
+ }
+ />
);
},