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>
This commit is contained in:
148
src/admin/components/StatusChipMenu.tsx
Normal file
148
src/admin/components/StatusChipMenu.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user