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