Files
app/src/admin/components/StatusChipMenu.tsx
BOHA 5683912b76 feat(ui): quick status-action chip menus on offers/orders/projects; ProjectDetail transition buttons
- NEW shared StatusChipMenu: clickable status chip opens a dense menu of valid
  next states; picks confirm via the shared ConfirmDialog (danger variant,
  loading, stays open on failure); plain chip without edit permission;
  row-click-safe (stopPropagation both directions).
- Offers list: draft -> Aktivovat (number assignment noted, PDF archived after
  finalize like the detail); active -> 'Vytvořit objednávku…' (opens the
  existing create-order modal — 'ordered' stays owned by that flow) +
  Zneplatnit; ordered -> Zneplatnit.
- ReceivedOrders list: Zahájit realizaci / Dokončit / Stornovat / Obnovit with
  cascade notes; Czech quote pairs fixed („…“); OrderDetail transition button
  says 'Obnovit' when reopening.
- Projects list + ProjectDetail: status combobox replaced by transition
  buttons (Dokončit/Zrušit/Obnovit projekt) rendered from valid_transitions;
  cascade notes only when a linked order will actually change; save no longer
  carries status; busy states symmetric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:33:58 +02:00

149 lines
4.8 KiB
TypeScript

import { useState, type ComponentProps } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { StatusChip, ConfirmDialog } from "../ui";
/** One entry in a {@link StatusChipMenu}. */
export interface StatusChipAction {
/** Stable identifier, e.g. the target status. */
key: string;
/** Menu item text, e.g. "Dokončit". */
label: string;
/** Red menu-item text + danger ConfirmDialog variant. */
danger?: boolean;
/**
* Confirmation dialog content. When omitted the action fires directly from
* the menu without confirmation (used by "Vytvořit objednávku…", which
* opens its own modal).
*/
confirm?: { title: string; message: string; confirmText: string };
/**
* Executed on pick (after confirmation when `confirm` is set). Awaited:
* while pending the ConfirmDialog shows its loading state and cannot be
* closed; on resolve the dialog closes; on rejection it stays open (the
* caller is responsible for toasting the error).
*/
onAction: () => void | Promise<void>;
}
interface StatusChipMenuProps {
/** Chip text (current status label). */
label: string;
/** Chip color, same palette as {@link StatusChip}. */
color: ComponentProps<typeof StatusChip>["color"];
/** Quick actions. Empty/undefined renders a plain non-clickable chip. */
actions?: StatusChipAction[];
/** Force a plain chip (e.g. the user lacks the edit permission). */
disabled?: boolean;
/** Tooltip on the clickable chip. */
title?: string;
}
/**
* Status chip with a quick-action menu, shared by the list pages
* (offers / received orders / projects).
*
* Clicking the chip (stopPropagation, so row navigation never fires) opens a
* dense MUI Menu of the valid next actions. Picking one closes the menu and
* either fires `onAction` directly (no `confirm` given) or opens the single
* shared ConfirmDialog instance — danger variant for destructive actions,
* `loading` while the awaited `onAction` is pending, staying open when it
* rejects per app convention (the caller toasts errors).
*
* With no actions — or with `disabled` — it renders a plain non-clickable
* StatusChip without a tooltip.
*/
export default function StatusChipMenu({
label,
color,
actions,
disabled = false,
title = "Změnit stav",
}: StatusChipMenuProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [pending, setPending] = useState<StatusChipAction | null>(null);
const [loading, setLoading] = useState(false);
if (disabled || !actions || actions.length === 0) {
return <StatusChip label={label} color={color} />;
}
const handlePick = (action: StatusChipAction) => {
setAnchorEl(null);
if (action.confirm) {
setPending(action);
} else {
void (async () => {
try {
await action.onAction();
} catch {
// Expected: the caller toasts its own errors; nothing to close here.
}
})();
}
};
const handleConfirm = async () => {
if (!pending) return;
setLoading(true);
try {
await pending.onAction();
// Success → close; ConfirmDialog freezes its content through the fade.
setPending(null);
} catch {
// Expected: rejection keeps the dialog open; the caller toasts the error.
} finally {
setLoading(false);
}
};
return (
<>
<StatusChip
label={label}
color={color}
title={title}
aria-haspopup="menu"
aria-expanded={Boolean(anchorEl)}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{ list: { dense: true } }}
>
{actions.map((action) => (
<MenuItem
key={action.key}
// stopPropagation: MUI portals re-bubble synthetic events to
// React-tree ancestors — a future onRowClick row must not fire.
onClick={(e) => {
e.stopPropagation();
handlePick(action);
}}
sx={action.danger ? { color: "error.main" } : undefined}
>
{action.label}
</MenuItem>
))}
</Menu>
<ConfirmDialog
isOpen={pending !== null}
onClose={() => setPending(null)}
onConfirm={() => void handleConfirm()}
title={pending?.confirm?.title ?? ""}
message={pending?.confirm?.message ?? ""}
confirmText={pending?.confirm?.confirmText}
confirmVariant={pending?.danger ? "danger" : "primary"}
loading={loading}
/>
</>
);
}