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:
BOHA
2026-07-04 04:33:58 +02:00
parent 3ec512faf1
commit 5683912b76
7 changed files with 551 additions and 31 deletions

View 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}
/>
</>
);
}

View File

@@ -28,6 +28,8 @@ export interface ProjectData {
quotation_number?: string;
has_nas_folder?: boolean;
project_notes?: ProjectNote[];
/** Valid status-machine targets from the current status (server-computed). */
valid_transitions?: string[];
}
export interface Project {

View File

@@ -11,6 +11,9 @@ import ListItemText from "@mui/material/ListItemText";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api";
import {
@@ -41,7 +44,6 @@ import {
Field,
TextField,
Select,
StatusChip,
FileUpload,
EmptyState,
FilterBar,
@@ -413,6 +415,21 @@ export default function Offers() {
},
});
// Quick-action finalize (draft → active) from the status chip menu. A
// status-only PUT deliberately passes the editable-state guard; the number
// is assigned in-transaction server-side (same semantics as the detail).
const activateMutation = useApiMutation<
{ id: number; status: "active" },
{ id: number }
>({
url: (input) => `${API_BASE}/offers/${input.id}`,
method: () => "PUT",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: () => {
alert.success("Nabídka byla aktivována");
},
});
if (!hasPermission("offers.view")) return <Forbidden />;
const handleDuplicate = async (quotation: Quotation) => {
@@ -489,6 +506,89 @@ export default function Offers() {
}
};
const handleActivate = async (q: Quotation) => {
try {
await activateMutation.mutateAsync({ id: q.id, status: "active" });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
// A finalized offer now has its official number — archive the PDF on the
// NAS, exactly like OfferDetail after finalize. Fire-and-forget: never
// block the toast/refresh — but a failure is surfaced, not swallowed.
apiFetch(`${API_BASE}/offers-pdf/${q.id}?save=1`)
.then((res) => {
if (!res.ok) alert.error("Nepodařilo se archivovat PDF nabídky na NAS");
})
.catch(() => alert.error("Nepodařilo se archivovat PDF nabídky na NAS"));
};
// "Zneplatnit" chip-menu entry — same wording as the page's existing
// invalidate ConfirmDialog and the same row mutation underneath.
const invalidateChipAction = (q: Quotation): StatusChipAction => ({
key: "invalidated",
label: "Zneplatnit",
danger: true,
confirm: {
title: "Zneplatnit nabídku",
message: `Opravdu chcete zneplatnit nabídku „${documentNumberLabel(q.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`,
confirmText: "Zneplatnit",
},
onAction: async () => {
try {
await invalidateMutation.mutateAsync(q.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
},
});
// Quick actions for the status chip menu, by offer status. Only rendered
// when the user holds offers.edit (the page's mutating-action permission);
// "Vytvořit objednávku…" additionally mirrors the create-order icon's gates
// (active + no existing order + orders.create) and opens the same modal.
const statusQuickActions = (q: Quotation): StatusChipAction[] => {
switch (q.status) {
case "draft":
return [
{
key: "active",
label: "Aktivovat",
confirm: {
title: "Aktivovat nabídku",
message: `Nabídce „${documentNumberLabel(q.quotation_number)}“ bude přiděleno oficiální číslo. Aktivovat?`,
confirmText: "Aktivovat",
},
onAction: () => handleActivate(q),
},
];
case "active": {
const actions: StatusChipAction[] = [];
if (!q.order_id && hasPermission("orders.create")) {
actions.push({
key: "create-order",
label: "Vytvořit objednávku…",
onAction: () => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setOrderModal({ show: true, quotation: q });
},
});
}
actions.push(invalidateChipAction(q));
return actions;
}
case "ordered":
return [invalidateChipAction(q)];
default:
// `invalidated` (and any unknown status): plain chip, no actions.
return [];
}
};
// Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash.
@@ -572,14 +672,19 @@ export default function Offers() {
// The offer's own status is `active`/`ordered`/`invalidated`. When its
// linked order is completed, surface that as "Dokončená" (success) —
// matching the OfferDetail header chip and the completed row tint.
// Completed rows are read-only everywhere on this page, so the chip
// stays plain (no quick actions) in that derived state.
const completed =
q.status !== "invalidated" && q.order_status === "dokoncena";
return completed ? (
<StatusChip label="Dokončená" color="success" />
<StatusChipMenu label="Dokončená" color="success" />
) : (
<StatusChip
<StatusChipMenu
label={statusLabel(OFFER_STATUS, q.status)}
color={statusColor(OFFER_STATUS, q.status)}
actions={
hasPermission("offers.edit") ? statusQuickActions(q) : undefined
}
/>
);
},

View File

@@ -292,6 +292,11 @@ export default function OrderDetail() {
if (!order) return null;
// From a terminal state the backend offers v_realizaci as a REOPEN — label
// it "Obnovit" (not "Zahájit realizaci") and note the no-cascade semantics.
const isReopen =
order.status === "dokoncena" || order.status === "stornovana";
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
...item,
_index: index,
@@ -475,7 +480,9 @@ export default function OrderDetail() {
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
: isReopen && status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{hasPermission("orders.delete") && (
@@ -782,9 +789,15 @@ export default function OrderDetail() {
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`}
message={
isReopen && statusConfirm.status === "v_realizaci"
? `Opravdu chcete obnovit objednávku "${order.order_number}"? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`
: `Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`
}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
isReopen && statusConfirm.status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
cancelText="Zrušit"
/>

View File

@@ -40,6 +40,12 @@ import {
const API_BASE = "/api/admin";
const TRANSITION_LABELS: Record<string, string> = {
dokonceny: "Dokončit projekt",
zruseny: "Zrušit projekt",
aktivni: "Obnovit projekt",
};
interface User {
id: number;
name: string;
@@ -47,7 +53,6 @@ interface User {
interface ProjectForm {
name: string;
status: string;
start_date: string;
end_date: string;
responsible_user_id: string;
@@ -76,11 +81,15 @@ export default function ProjectDetail() {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -121,7 +130,6 @@ export default function ProjectDetail() {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
@@ -141,7 +149,6 @@ export default function ProjectDetail() {
const projectSaveMutation = useApiMutation<
{
name: string;
status: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
@@ -153,6 +160,14 @@ export default function ProjectDetail() {
invalidate: ["projects", "warehouse"],
});
// Status transitions (header buttons). A project transition may cascade to
// the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const projectDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
@@ -189,7 +204,6 @@ export default function ProjectDetail() {
try {
await projectSaveMutation.mutateAsync({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
@@ -202,6 +216,51 @@ export default function ProjectDetail() {
}
};
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
};
// Confirm message with the cascade note — the linked-order cascade only
// exists when the project actually has a linked order; reopening never
// cascades.
const statusConfirmMessage = (status: string | null): string => {
if (!status || !project) return "";
if (status === "aktivni") {
return (
'Projekt se vrátí do stavu "Aktivní".' +
(project.order_id ? " Propojená objednávka zůstane beze změny." : "")
);
}
const base = `Označit projekt "${project.project_number} ${project.name}" jako ${
status === "zruseny" ? "zrušený" : "dokončený"
}?`;
// The cascade only fires while the order is still open (prijata /
// v_realizaci) — a terminal order (e.g. after a project reopen) stays
// untouched, so don't promise a change that won't happen.
const orderCascades =
project.order_id &&
(project.order_status === "prijata" ||
project.order_status === "v_realizaci");
if (!orderCascades) return base;
return (
base +
(status === "zruseny"
? " Propojená objednávka bude automaticky stornována."
: " Propojená objednávka bude automaticky dokončena.")
);
};
const handleDelete = async () => {
setDeleting(true);
try {
@@ -333,9 +392,24 @@ export default function ProjectDetail() {
</Box>
{canEdit && (
<Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}>
<Button
onClick={handleSave}
disabled={saving || statusChanging !== null}
>
{saving ? "Ukládání..." : "Uložit"}
</Button>
{(project.valid_transitions ?? []).map((status) => (
<Button
key={status}
color={status === "zruseny" ? "error" : "primary"}
onClick={() => setStatusConfirm({ show: true, status })}
disabled={saving || statusChanging !== null}
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{!project.order_id && (
<Button
variant="outlined"
@@ -399,23 +473,10 @@ export default function ProjectDetail() {
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Stav">
<Select
value={form.status}
onChange={(v) => updateForm("status", v)}
disabled={!canEdit}
>
{Object.entries(PROJECT_STATUS).map(([value, s]) => (
<MenuItem key={value} value={value}>
{s.label}
</MenuItem>
))}
</Select>
</Field>
<Field label="Datum zahájení">
<DateField
value={form.start_date}
@@ -609,6 +670,22 @@ export default function ProjectDetail() {
</Box>
</Card>
{/* Status change confirmation */}
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav projektu"
message={statusConfirmMessage(statusConfirm.status)}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
confirmVariant={
statusConfirm.status === "zruseny" ? "danger" : "primary"
}
cancelText="Zrušit"
/>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {

View File

@@ -7,6 +7,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
@@ -30,7 +33,6 @@ import {
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
LoadingState,
PageEnter,
@@ -144,6 +146,17 @@ export default function Projects() {
},
});
// Quick status change from the list chip menu. A project transition may
// cascade to the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
@@ -259,6 +272,78 @@ export default function Projects() {
return <LoadingState />;
}
const canEditStatus = hasPermission("projects.edit");
// "Číslo Název" reference for confirm messages (name may be empty on
// legacy rows).
const projectRef = (p: Project) =>
p.name ? `${p.project_number} ${p.name}` : p.project_number;
const changeStatus = async (p: Project, status: string) => {
try {
await statusMutation.mutateAsync({ id: p.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e; // rejection keeps the ConfirmDialog open (app convention)
}
};
// Quick actions per current status; unknown/legacy statuses get none
// (plain chip) — those are resolved on the detail page.
const statusActions = (p: Project): StatusChipAction[] => {
if (p.status === "aktivni") {
return [
{
key: "dokonceny",
label: "Dokončit",
confirm: {
title: "Dokončit projekt",
message:
`Označit projekt "${projectRef(p)}" jako dokončený?` +
(p.order_id
? " Propojená objednávka bude automaticky dokončena."
: ""),
confirmText: "Dokončit",
},
onAction: () => changeStatus(p, "dokonceny"),
},
{
key: "zruseny",
label: "Zrušit",
danger: true,
confirm: {
title: "Zrušit projekt",
message:
`Označit projekt "${projectRef(p)}" jako zrušený?` +
(p.order_id
? " Propojená objednávka bude automaticky stornována."
: ""),
confirmText: "Zrušit projekt",
},
onAction: () => changeStatus(p, "zruseny"),
},
];
}
if (p.status === "dokonceny" || p.status === "zruseny") {
return [
{
key: "aktivni",
label: "Obnovit",
confirm: {
title: "Obnovit projekt",
message:
'Projekt se vrátí do stavu "Aktivní".' +
(p.order_id ? " Propojená objednávka zůstane beze změny." : ""),
confirmText: "Obnovit",
},
onAction: () => changeStatus(p, "aktivni"),
},
];
}
return [];
};
const columns: DataColumn<Project>[] = [
{
key: "project_number",
@@ -306,9 +391,11 @@ export default function Projects() {
width: "10%",
sortKey: "status",
render: (p) => (
<StatusChip
<StatusChipMenu
label={statusLabel(PROJECT_STATUS, p.status)}
color={statusColor(PROJECT_STATUS, p.status)}
actions={statusActions(p)}
disabled={!canEditStatus}
/>
),
},

View File

@@ -6,6 +6,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api";
import {
formatCurrency,
@@ -32,7 +35,6 @@ import {
Field,
TextField,
Select,
StatusChip,
CheckboxField,
FileUpload,
FilterBar,
@@ -180,6 +182,17 @@ export default function OrdersReceived({
},
});
// Quick status change from the table chip (StatusChipMenu). The `id` rides
// along in the input only to build the URL; UpdateOrderSchema strips it.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "offers", "projects", "invoices"],
});
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
@@ -352,6 +365,79 @@ export default function OrdersReceived({
return <LoadingState />;
}
// Quick actions for the status chip menu, mirroring the order status
// machine (VALID_TRANSITIONS incl. the reopen edges). Rejections propagate
// so the ConfirmDialog stays open per app convention; we toast here.
const statusActions = (o: Order): StatusChipAction[] => {
const changeStatus = (status: string) => async () => {
try {
await statusMutation.mutateAsync({ id: o.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e;
}
};
const stornovat: StatusChipAction = {
key: "stornovana",
label: "Stornovat",
danger: true,
confirm: {
title: "Stornovat objednávku",
message: `Opravdu chcete stornovat objednávku „${o.order_number}“? Propojený projekt bude automaticky zrušen.`,
confirmText: "Stornovat",
},
onAction: changeStatus("stornovana"),
};
switch (o.status) {
case "prijata":
return [
{
key: "v_realizaci",
label: "Zahájit realizaci",
confirm: {
title: "Zahájit realizaci",
message: `Opravdu chcete zahájit realizaci objednávky „${o.order_number}“?`,
confirmText: "Zahájit realizaci",
},
onAction: changeStatus("v_realizaci"),
},
stornovat,
];
case "v_realizaci":
return [
{
key: "dokoncena",
label: "Dokončit",
confirm: {
title: "Dokončit objednávku",
message: `Opravdu chcete dokončit objednávku „${o.order_number}“? Propojený projekt bude automaticky dokončen.`,
confirmText: "Dokončit",
},
onAction: changeStatus("dokoncena"),
},
stornovat,
];
case "dokoncena":
case "stornovana":
// Reopen — deliberately no cascade to the linked project.
return [
{
key: "v_realizaci",
label: "Obnovit",
confirm: {
title: "Obnovit objednávku",
message: `Opravdu chcete obnovit objednávku „${o.order_number}“? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`,
confirmText: "Obnovit",
},
onAction: changeStatus("v_realizaci"),
},
];
default:
return [];
}
};
const columns: DataColumn<Order>[] = [
{
key: "order_number",
@@ -403,9 +489,11 @@ export default function OrdersReceived({
width: "13%",
sortKey: "status",
render: (o) => (
<StatusChip
<StatusChipMenu
label={statusLabel(ORDER_STATUS, o.status)}
color={statusColor(ORDER_STATUS, o.status)}
actions={statusActions(o)}
disabled={!hasPermission("orders.edit")}
/>
),
},
@@ -589,7 +677,7 @@ export default function OrdersReceived({
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"