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