- 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>
149 lines
4.8 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|