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;
|
||||
has_nas_folder?: boolean;
|
||||
project_notes?: ProjectNote[];
|
||||
/** Valid status-machine targets from the current status (server-computed). */
|
||||
valid_transitions?: string[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user