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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export interface ProjectData {
|
|||||||
quotation_number?: string;
|
quotation_number?: string;
|
||||||
has_nas_folder?: boolean;
|
has_nas_folder?: boolean;
|
||||||
project_notes?: ProjectNote[];
|
project_notes?: ProjectNote[];
|
||||||
|
/** Valid status-machine targets from the current status (server-computed). */
|
||||||
|
valid_transitions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import ListItemText from "@mui/material/ListItemText";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +44,6 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
StatusChip,
|
|
||||||
FileUpload,
|
FileUpload,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FilterBar,
|
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 />;
|
if (!hasPermission("offers.view")) return <Forbidden />;
|
||||||
|
|
||||||
const handleDuplicate = async (quotation: Quotation) => {
|
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
|
// Only show the full-page skeleton on the very first load; on subsequent
|
||||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||||
// Card dims via isFetching) so it doesn't flash.
|
// 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
|
// The offer's own status is `active`/`ordered`/`invalidated`. When its
|
||||||
// linked order is completed, surface that as "Dokončená" (success) —
|
// linked order is completed, surface that as "Dokončená" (success) —
|
||||||
// matching the OfferDetail header chip and the completed row tint.
|
// 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 =
|
const completed =
|
||||||
q.status !== "invalidated" && q.order_status === "dokoncena";
|
q.status !== "invalidated" && q.order_status === "dokoncena";
|
||||||
return completed ? (
|
return completed ? (
|
||||||
<StatusChip label="Dokončená" color="success" />
|
<StatusChipMenu label="Dokončená" color="success" />
|
||||||
) : (
|
) : (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={statusLabel(OFFER_STATUS, q.status)}
|
label={statusLabel(OFFER_STATUS, q.status)}
|
||||||
color={statusColor(OFFER_STATUS, q.status)}
|
color={statusColor(OFFER_STATUS, q.status)}
|
||||||
|
actions={
|
||||||
|
hasPermission("offers.edit") ? statusQuickActions(q) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
if (!order) return null;
|
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) => ({
|
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
_index: index,
|
_index: index,
|
||||||
@@ -475,7 +480,9 @@ export default function OrderDetail() {
|
|||||||
>
|
>
|
||||||
{statusChanging === status
|
{statusChanging === status
|
||||||
? "Zpracovávám…"
|
? "Zpracovávám…"
|
||||||
: TRANSITION_LABELS[status] || status}
|
: isReopen && status === "v_realizaci"
|
||||||
|
? "Obnovit"
|
||||||
|
: TRANSITION_LABELS[status] || status}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{hasPermission("orders.delete") && (
|
{hasPermission("orders.delete") && (
|
||||||
@@ -782,9 +789,15 @@ export default function OrderDetail() {
|
|||||||
onClose={() => setStatusConfirm({ show: false, status: null })}
|
onClose={() => setStatusConfirm({ show: false, status: null })}
|
||||||
onConfirm={handleStatusChange}
|
onConfirm={handleStatusChange}
|
||||||
title="Změnit stav objednávky"
|
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={
|
confirmText={
|
||||||
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
isReopen && statusConfirm.status === "v_realizaci"
|
||||||
|
? "Obnovit"
|
||||||
|
: TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
||||||
}
|
}
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ import {
|
|||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
|
const TRANSITION_LABELS: Record<string, string> = {
|
||||||
|
dokonceny: "Dokončit projekt",
|
||||||
|
zruseny: "Zrušit projekt",
|
||||||
|
aktivni: "Obnovit projekt",
|
||||||
|
};
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,7 +53,6 @@ interface User {
|
|||||||
|
|
||||||
interface ProjectForm {
|
interface ProjectForm {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
responsible_user_id: string;
|
responsible_user_id: string;
|
||||||
@@ -76,11 +81,15 @@ export default function ProjectDetail() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState<ProjectForm>({
|
const [form, setForm] = useState<ProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
status: "aktivni",
|
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
responsible_user_id: "",
|
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 [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -121,7 +130,6 @@ export default function ProjectDetail() {
|
|||||||
if (project && !formInitialized.current) {
|
if (project && !formInitialized.current) {
|
||||||
setForm({
|
setForm({
|
||||||
name: project.name || "",
|
name: project.name || "",
|
||||||
status: project.status || "aktivni",
|
|
||||||
start_date: (project.start_date || "").substring(0, 10),
|
start_date: (project.start_date || "").substring(0, 10),
|
||||||
end_date: (project.end_date || "").substring(0, 10),
|
end_date: (project.end_date || "").substring(0, 10),
|
||||||
responsible_user_id: project.responsible_user_id || "",
|
responsible_user_id: project.responsible_user_id || "",
|
||||||
@@ -141,7 +149,6 @@ export default function ProjectDetail() {
|
|||||||
const projectSaveMutation = useApiMutation<
|
const projectSaveMutation = useApiMutation<
|
||||||
{
|
{
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
responsible_user_id: string | null;
|
responsible_user_id: string | null;
|
||||||
@@ -153,6 +160,14 @@ export default function ProjectDetail() {
|
|||||||
invalidate: ["projects", "warehouse"],
|
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<
|
const projectDeleteMutation = useApiMutation<
|
||||||
{ delete_files: boolean },
|
{ delete_files: boolean },
|
||||||
unknown
|
unknown
|
||||||
@@ -189,7 +204,6 @@ export default function ProjectDetail() {
|
|||||||
try {
|
try {
|
||||||
await projectSaveMutation.mutateAsync({
|
await projectSaveMutation.mutateAsync({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
status: form.status,
|
|
||||||
start_date: form.start_date || null,
|
start_date: form.start_date || null,
|
||||||
end_date: form.end_date || null,
|
end_date: form.end_date || null,
|
||||||
responsible_user_id: form.responsible_user_id || 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 () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
@@ -333,9 +392,24 @@ export default function ProjectDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Box sx={headerActionsSx}>
|
<Box sx={headerActionsSx}>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || statusChanging !== null}
|
||||||
|
>
|
||||||
{saving ? "Ukládání..." : "Uložit"}
|
{saving ? "Ukládání..." : "Uložit"}
|
||||||
</Button>
|
</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 && (
|
{!project.order_id && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -399,23 +473,10 @@ export default function ProjectDetail() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
gap: 2,
|
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í">
|
<Field label="Datum zahájení">
|
||||||
<DateField
|
<DateField
|
||||||
value={form.start_date}
|
value={form.start_date}
|
||||||
@@ -609,6 +670,22 @@ export default function ProjectDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</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
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm}
|
isOpen={deleteConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
@@ -30,7 +33,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
DateField,
|
DateField,
|
||||||
StatusChip,
|
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
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 [showCreate, setShowCreate] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -259,6 +272,78 @@ export default function Projects() {
|
|||||||
return <LoadingState />;
|
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>[] = [
|
const columns: DataColumn<Project>[] = [
|
||||||
{
|
{
|
||||||
key: "project_number",
|
key: "project_number",
|
||||||
@@ -306,9 +391,11 @@ export default function Projects() {
|
|||||||
width: "10%",
|
width: "10%",
|
||||||
sortKey: "status",
|
sortKey: "status",
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={statusLabel(PROJECT_STATUS, p.status)}
|
label={statusLabel(PROJECT_STATUS, p.status)}
|
||||||
color={statusColor(PROJECT_STATUS, p.status)}
|
color={statusColor(PROJECT_STATUS, p.status)}
|
||||||
|
actions={statusActions(p)}
|
||||||
|
disabled={!canEditStatus}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -32,7 +35,6 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
StatusChip,
|
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
FileUpload,
|
FileUpload,
|
||||||
FilterBar,
|
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({
|
const [createForm, setCreateForm] = useState({
|
||||||
customer_id: "",
|
customer_id: "",
|
||||||
customer_order_number: "",
|
customer_order_number: "",
|
||||||
@@ -352,6 +365,79 @@ export default function OrdersReceived({
|
|||||||
return <LoadingState />;
|
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>[] = [
|
const columns: DataColumn<Order>[] = [
|
||||||
{
|
{
|
||||||
key: "order_number",
|
key: "order_number",
|
||||||
@@ -403,9 +489,11 @@ export default function OrdersReceived({
|
|||||||
width: "13%",
|
width: "13%",
|
||||||
sortKey: "status",
|
sortKey: "status",
|
||||||
render: (o) => (
|
render: (o) => (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={statusLabel(ORDER_STATUS, o.status)}
|
label={statusLabel(ORDER_STATUS, o.status)}
|
||||||
color={statusColor(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"
|
title="Smazat objednávku"
|
||||||
message={
|
message={
|
||||||
deleteConfirm.order
|
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"
|
confirmText="Smazat"
|
||||||
|
|||||||
Reference in New Issue
Block a user