diff --git a/src/admin/components/StatusChipMenu.tsx b/src/admin/components/StatusChipMenu.tsx new file mode 100644 index 0000000..11fc19f --- /dev/null +++ b/src/admin/components/StatusChipMenu.tsx @@ -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; +} + +interface StatusChipMenuProps { + /** Chip text (current status label). */ + label: string; + /** Chip color, same palette as {@link StatusChip}. */ + color: ComponentProps["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(null); + const [pending, setPending] = useState(null); + const [loading, setLoading] = useState(false); + + if (disabled || !actions || actions.length === 0) { + return ; + } + + 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 ( + <> + { + e.stopPropagation(); + setAnchorEl(e.currentTarget); + }} + /> + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + slotProps={{ list: { dense: true } }} + > + {actions.map((action) => ( + { + e.stopPropagation(); + handlePick(action); + }} + sx={action.danger ? { color: "error.main" } : undefined} + > + {action.label} + + ))} + + setPending(null)} + onConfirm={() => void handleConfirm()} + title={pending?.confirm?.title ?? ""} + message={pending?.confirm?.message ?? ""} + confirmText={pending?.confirm?.confirmText} + confirmVariant={pending?.danger ? "danger" : "primary"} + loading={loading} + /> + + ); +} diff --git a/src/admin/lib/queries/projects.ts b/src/admin/lib/queries/projects.ts index b9d7e97..8e96e74 100644 --- a/src/admin/lib/queries/projects.ts +++ b/src/admin/lib/queries/projects.ts @@ -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 { diff --git a/src/admin/pages/Offers.tsx b/src/admin/pages/Offers.tsx index 9988997..8109036 100644 --- a/src/admin/pages/Offers.tsx +++ b/src/admin/pages/Offers.tsx @@ -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 ; 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 ? ( - + ) : ( - ); }, diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index e162242..35409a5 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -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} ))} {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" /> diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index c1fc22c..7b1d4cd 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -40,6 +40,12 @@ import { const API_BASE = "/api/admin"; +const TRANSITION_LABELS: Record = { + 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({ name: "", - status: "aktivni", start_date: "", end_date: "", responsible_user_id: "", }); + const [statusChanging, setStatusChanging] = useState(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() { {canEdit && ( - + {(project.valid_transitions ?? []).map((status) => ( + + ))} {!project.order_id && (