import { useState, useEffect, useMemo, useRef } from "react"; import { useNavigate, useParams, Link as RouterLink } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import CircularProgress from "@mui/material/CircularProgress"; import { useApiMutation, apiErrorMessage, type ApiEnvelope, } from "../lib/queries/mutations"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; import apiFetch from "../utils/api"; import DocumentItemsEditor, { emptyDocumentItem, nextDocumentItemKey, type DocumentItem, } from "../components/document/DocumentItemsEditor"; import SectionsEditor, { type DocumentSection, } from "../components/document/SectionsEditor"; import LockBanner from "../components/document/LockBanner"; import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker"; import { useDocumentLock } from "../hooks/useDocumentLock"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { issuedOrderDetailOptions, issuedOrderSuppliersOptions, issuedOrderNextNumberOptions, type Supplier, } from "../lib/queries/issued-orders"; import { companySettingsOptions } from "../lib/queries/settings"; import { numberOr, todayLocalStr } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, ConfirmDialog, LoadingState, PageEnter, SupplierPicker, headerActionsSx, } from "../ui"; import { ISSUED_ORDER_STATUS, statusLabel, statusColor, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // The live (finalized) status for an issued order — assigning it (on create or // on finalizing a draft) makes the backend consume the official PO number. const LIVE_STATUS = "sent"; // Labels for the buttons that trigger a status transition. const TRANSITION_LABELS: Record = { sent: "Odeslat", confirmed: "Potvrdit", completed: "Dokončit", cancelled: "Stornovat", }; const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"]; const BackIcon = ( ); const FileIcon = ( ); interface OrderForm { supplier_id: number | null; supplier_name: string; currency: string; order_date: string; delivery_date: string; language: string; order_text: string; internal_notes: string; status: string; } interface IssuedOrderItemPayload { description: string; item_description: string; quantity: number; unit: string; unit_price: number; position: number; } interface IssuedOrderSectionPayload { title: string; title_cz: string; content: string; position: number; } interface IssuedOrderSavePayload { supplier_id: number | null; currency: string; order_date: string; delivery_date: string | null; language: string; order_text: string | null; internal_notes: string; items: IssuedOrderItemPayload[]; sections: IssuedOrderSectionPayload[]; selected_custom_fields: number[]; status?: string; } export default function IssuedOrderDetail() { const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id); const navigate = useNavigate(); const alert = useAlert(); const { hasPermission } = useAuth(); const queryClient = useQueryClient(); const [form, setForm] = useState({ supplier_id: null, supplier_name: "", currency: "CZK", order_date: todayLocalStr(), delivery_date: "", language: "cs", order_text: "", internal_notes: "", status: "draft", }); const [items, setItems] = useState(() => [ emptyDocumentItem(), ]); const [sections, setSections] = useState([]); const [selectedCustomFields, setSelectedCustomFields] = useState( [], ); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); // Which save action is in flight ("draft" | the live status | "save"), so // only the clicked button shows its spinner — `saving` still disables all of // them to prevent a concurrent double-submit. const [savingAction, setSavingAction] = useState(null); const [dataReady, setDataReady] = useState(false); const [poNumber, setPoNumber] = useState(""); 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); // Set to true right after a successful delete so the detail-page error // effect doesn't fire "Nepodařilo se načíst objednávku" on top of the // success toast (the 404 from a refetched-already-deleted row is expected // here, not a real error). const deletingRef = useRef(false); // ─── Queries ─── const suppliersQuery = useQuery(issuedOrderSuppliersOptions()); // The lookup returns ACTIVE suppliers only, but a saved order may reference // a since-deactivated one — without a fallback option the picker would // resolve to nothing and render the counterparty blank. The fallback is // built from the hydrated form state (supplier_name comes from the detail // response), so the name stays visible and a re-save keeps the same id. const suppliers = useMemo(() => { const activeSuppliers = suppliersQuery.data ?? []; if ( form.supplier_id != null && !activeSuppliers.some((s: Supplier) => s.id === form.supplier_id) ) { return [ ...activeSuppliers, { id: form.supplier_id, name: form.supplier_name || `Dodavatel #${form.supplier_id}`, ico: null, dic: null, street: null, city: null, postal_code: null, country: null, }, ]; } return activeSuppliers; }, [suppliersQuery.data, form.supplier_id, form.supplier_name]); const companySettings = useQuery(companySettingsOptions()).data; // Configurable currency list from company settings (falls back to the // built-in list when settings are empty) — matches Offers/Invoices. const currencyOptions = ( companySettings?.available_currencies || CURRENCY_FALLBACK ).map((c) => ({ value: c, label: c })); const detailQuery = useQuery(issuedOrderDetailOptions(id)); const detail = detailQuery.data ?? null; const nextNumberQuery = useQuery({ ...issuedOrderNextNumberOptions(), enabled: !isEdit, }); // ─── Editability + shared document hooks ─── // Fields are editable only in create mode or while draft/sent; an // orders.view-only holder opens everything read-only (copyable), and a // fresh lock held by another user forces read-only too. const statusEditable = !isEdit || form.status === "draft" || form.status === "sent"; const { lockedBy, isLockedByOther, setLockedBy, acquire: acquireLock, } = useDocumentLock({ baseUrl: isEdit && id ? `${API_BASE}/issued-orders/${id}` : null, enabled: isEdit && dataReady && statusEditable && hasPermission("orders.edit"), }); const readOnly = isEdit && (!statusEditable || isLockedByOther || !hasPermission("orders.edit")); const editable = !readOnly; const canExport = hasPermission("orders.view"); const { isDirty, markClean } = useUnsavedChangesGuard( { form, items, sections, selectedCustomFields }, !isEdit || dataReady, ); const { openPdf, pdfLoading } = useDocumentPdf( isEdit && id ? `${API_BASE}/issued-orders/${id}/file` : null, ); // ─── Edit mode: hydrate form from detail (once) ─── useEffect(() => { if (!isEdit || dataReady) return; const d = detailQuery.data; if (!d) return; const formData: OrderForm = { supplier_id: d.supplier_id ?? null, supplier_name: d.supplier_name ?? "", currency: d.currency || "CZK", order_date: normalizeDateStr(d.order_date), delivery_date: normalizeDateStr(d.delivery_date), language: d.language || "cs", order_text: d.order_text || "", internal_notes: d.internal_notes || "", status: d.status, }; setForm(formData); setPoNumber(d.po_number || ""); const mappedItems = d.items && d.items.length > 0 ? d.items.map((it) => ({ _key: nextDocumentItemKey(), id: it.id, description: it.description || "", item_description: it.item_description || "", quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, // Issued orders have no Sleva column; keep the shared item shape happy. discount: 0, })) : // Issued orders may be issued by sections alone — a saved order with // no items reopens with an empty list (not a blank starter row). []; setItems(mappedItems); const mappedSections = Array.isArray(d.sections) && d.sections.length > 0 ? d.sections.map((s) => ({ title: s.title || "", title_cz: s.title_cz || "", content: s.content || "", })) : []; setSections(mappedSections); setSelectedCustomFields(d.selected_custom_fields ?? []); setLockedBy(d.locked_by ?? null); // Acquire the edit lock when nobody holds it, the order is still // editable and the user is allowed to edit (mirrors offers). if ( !d.locked_by && (d.status === "draft" || d.status === "sent") && hasPermission("orders.edit") ) { acquireLock(); } markClean({ form: formData, items: mappedItems, sections: mappedSections }); setDataReady(true); }, [ isEdit, dataReady, detailQuery.data, hasPermission, setLockedBy, acquireLock, markClean, ]); // ─── Create mode: previewed PO number (page renders immediately) ─── useEffect(() => { if (isEdit || !nextNumberQuery.data) return; setPoNumber(nextNumberQuery.data); }, [isEdit, nextNumberQuery.data]); // Redirect on detail fetch error (edit mode). Suppressed while deletingRef // is set — the in-flight refetch of a row we just deleted will 404, and we // don't want a "load failed" toast on top of the success toast. useEffect(() => { if (isEdit && detailQuery.isError && !deletingRef.current) { alert.error("Nepodařilo se načíst objednávku"); navigate("/orders?tab=vydane"); } }, [isEdit, detailQuery.isError, alert, navigate]); // ─── Mutations ─── const saveMutation = useApiMutation< IssuedOrderSavePayload, ApiEnvelope<{ id: number; po_number: string | null }> >({ url: () => isEdit ? `${API_BASE}/issued-orders/${id}` : `${API_BASE}/issued-orders`, method: () => (isEdit ? "PUT" : "POST"), invalidate: ["issued-orders"], envelope: true, }); const statusMutation = useApiMutation< { status: string }, ApiEnvelope<{ id: number; po_number: string | null }> >({ url: () => `${API_BASE}/issued-orders/${id}`, method: () => "PUT", invalidate: ["issued-orders"], envelope: true, }); const deleteMutation = useApiMutation>({ url: () => `${API_BASE}/issued-orders/${id}`, method: () => "DELETE", envelope: true, }); const selectSupplier = (supplierId: number | null) => { const s = supplierId != null ? suppliers.find((x: Supplier) => x.id === supplierId) : null; setForm((prev) => ({ ...prev, supplier_id: supplierId, supplier_name: s?.name || "", })); setErrors((prev) => ({ ...prev, supplier_id: "" })); }; // ─── Submit (create + edit) ─── const handleSubmit = async (targetStatus?: string) => { if (!editable || saving) return; const newErrors: Record = {}; if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele"; if (!form.order_date) newErrors.order_date = "Zadejte datum"; // Items are optional (an order may be issued by sections alone), but a // completely blank order must not be finalizable: require at least one // item with a description OR one non-empty section (title or content). const hasItem = items.some((i) => i.description.trim()); const hasSection = sections.some( (s) => (s.title_cz || "").trim() || (s.title || "").trim() || (s.content || "").replace(/<[^>]*>/g, "").trim(), ); if (!hasItem && !hasSection) { newErrors.items = "Přidejte alespoň jednu položku nebo obsah"; } setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); setSavingAction(targetStatus ?? "save"); try { const payload: IssuedOrderSavePayload = { supplier_id: form.supplier_id, currency: form.currency, order_date: form.order_date, delivery_date: form.delivery_date || null, language: form.language, order_text: form.order_text || null, internal_notes: form.internal_notes, items: items .filter((i) => i.description.trim()) .map((it, i) => ({ description: it.description, item_description: it.item_description, // Coerce the raw typed strings back to numbers so an empty/partial // field never reaches the server as NaN (mirrors the totals math). quantity: Number(it.quantity) || 0, unit: it.unit, unit_price: Number(it.unit_price) || 0, position: i, })), sections: sections.map((s, i) => ({ title: s.title, title_cz: s.title_cz, content: s.content, position: i, })), selected_custom_fields: selectedCustomFields, }; // Only set status when a target is given (create-as-draft / create-as-live // / finalize). Editing a live order sends no status — the backend keeps // its current status. The PO number is never user-editable. if (targetStatus) payload.status = targetStatus; const result = await saveMutation.mutateAsync(payload); const created = result.data ?? null; alert.success( result.message || (isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena"), ); // PUT/POST return po_number, so a draft→sent finalize shows the // assigned number immediately (no extra refetch needed). if (created?.po_number) setPoNumber(created.po_number); // Archive the PDF to NAS for any non-draft order (create-as-sent, // finalize draft→sent, or editing an already-live order). A draft has no // PO number, so it is skipped (the backend also guards on po_number). // Fire-and-forget: never block the success toast or navigation — but a // failure is surfaced, not silently swallowed. const recordId = isEdit ? Number(id) : created?.id; const effectiveStatus = targetStatus ?? form.status; if (recordId && effectiveStatus !== "draft") { apiFetch(`${API_BASE}/issued-orders-pdf/${recordId}?save=1`) .then((res) => { if (!res.ok) alert.error("Nepodařilo se archivovat PDF objednávky na NAS"); }) .catch(() => alert.error("Nepodařilo se archivovat PDF objednávky na NAS"), ); } // Reflect the just-saved status locally — on finalize (edit // draft→live) it flips the form out of the draft button branch without // waiting for a refetch (the one-shot hydration won't re-run, dataReady // stays true in edit mode). if (targetStatus) { setForm((prev) => ({ ...prev, status: targetStatus })); } markClean({ form: targetStatus ? { ...form, status: targetStatus } : form, items, sections, }); // On CREATE, redirect to the new order's detail. dataReady is still // false (it is only set by the edit-mode hydration), so the hydration // effect runs against the fresh detail fetch — same as offers — and // acquires the edit lock there. if (!isEdit && created?.id) { navigate(`/orders/issued/${created.id}`); } } catch (err) { alert.error( apiErrorMessage( err, isEdit ? "Nepodařilo se uložit objednávku" : "Nepodařilo se vytvořit objednávku", ), ); } finally { setSaving(false); setSavingAction(null); } }; // Native form submit (e.g. Enter): route to the right save path. Create → // finalize live; editing a draft → save draft (the visible primary button); // editing a live order → plain save (no status change). const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!editable) return; if (!isEdit) void handleSubmit(LIVE_STATUS); else if (form.status === "draft") void handleSubmit("draft"); else void handleSubmit(); }; // ─── Status transition. With unsaved edits on an editable (sent) order // the transition routes through handleSubmit so the edits are PERSISTED // with the status — a status-only payload would silently drop them, and // the markClean below would re-baseline the guard so even the beforeunload // warning disappears (the order is then read-only: edits unrecoverable). // Clean (or non-editable) orders keep the status-only payload — a // full-form resubmit on a non-editable order 400s server-side. ─── const handleStatusChange = async () => { if (!statusConfirm.status || statusChanging) return; const newStatus = statusConfirm.status; if (editable && isDirty) { setStatusConfirm({ show: false, status: null }); // handleSubmit validates, sends the full payload + status, archives // the PDF and re-baselines the guard with what was actually persisted. await handleSubmit(newStatus); return; } setStatusChanging(newStatus); try { const result = await statusMutation.mutateAsync({ status: newStatus }); alert.success(result.message || "Stav byl změněn"); if (result.data?.po_number) setPoNumber(result.data.po_number); // Mirror the new status into the form (same as handleSubmit's success // path): the one-shot hydration won't re-run (dataReady stays true), so // without this the StatusChip, the `editable` flag and the delete-button // gate would keep reading the stale status until a full remount. setForm((prev) => ({ ...prev, status: newStatus })); markClean({ form: { ...form, status: newStatus }, items, sections }); } catch (err) { alert.error(apiErrorMessage(err, "Nepodařilo se změnit stav objednávky")); } finally { setStatusChanging(null); setStatusConfirm({ show: false, status: null }); } }; // ─── Delete ─── const handleDelete = async () => { setDeleting(true); try { const result = await deleteMutation.mutateAsync(undefined); // Mark the row as gone before the list invalidation refetches the // detail key (which would 404 and trigger the error-toast effect). deletingRef.current = true; queryClient.removeQueries({ queryKey: ["issued-orders", id] }); alert.success(result.message || "Objednávka byla smazána"); await queryClient.invalidateQueries({ queryKey: ["issued-orders"] }); navigate("/orders?tab=vydane"); } catch (err) { alert.error(apiErrorMessage(err, "Nepodařilo se smazat objednávku")); } finally { setDeleting(false); setDeleteConfirm(false); } }; // ─── Permission guards (AFTER all hooks) ─── if (!isEdit && !hasPermission("orders.create")) return ; if (isEdit && !hasPermission("orders.view")) return ; // Create mode renders immediately (no full-page spinner waiting on // lookups); edit mode waits for the one-shot hydration. if (isEdit && !dataReady) { return ; } return ( {/* Header */} {/* flexWrap: long document titles must drop below the Zpět button on phones instead of overflowing the viewport. */} {isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"} {/* Edit mode: a draft has no PO number yet → show "Koncept". Create mode: show the previewed next-number when available. */} {(isEdit || poNumber) && ( {isEdit ? documentNumberLabel(poNumber) : poNumber} )} {isEdit && ( )} {/* Drafts have no number yet — /file 404s for them. */} {isEdit && canExport && form.status !== "draft" && ( )} {/* ── Create mode: two-button save ── */} {!isEdit && ( <> )} {/* ── Edit mode, draft: save-draft + finalize (Odeslat). A draft is deleted rather than cancelled, so the generic transition buttons (sent/cancelled) are suppressed — the finalize button IS the draft → sent transition. ── */} {isEdit && editable && form.status === "draft" && ( <> )} {/* ── Edit mode, live (non-draft) order — save + transitions ── */} {isEdit && form.status !== "draft" && ( <> {editable && ( )} {hasPermission("orders.edit") && !isLockedByOther && detail?.valid_transitions?.map((status) => ( ))} )} {/* A completed order is terminal/finalized — never deletable from the UI. draft/sent/confirmed/cancelled may be deleted. (Backend rejection of deleting an order is a separate hardening, out of scope here.) */} {isEdit && hasPermission("orders.delete") && form.status !== "completed" && !isLockedByOther && ( )} {/* Lock banner */}
{/* Basic info */} Základní údaje { setForm((prev) => ({ ...prev, order_date: val })); setErrors((prev) => ({ ...prev, order_date: "" })); }} /> setForm((prev) => ({ ...prev, delivery_date: val })) } /> setForm((prev) => ({ ...prev, language: val })) } options={[ { value: "cs", label: "Čeština" }, { value: "en", label: "English" }, ]} /> setForm((prev) => ({ ...prev, order_text: e.target.value })) } placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)" /> {/* Items */} {/* Rich-text PDF sections — the free-form PDF content lives here */} {/* Internal notes */} setForm((prev) => ({ ...prev, internal_notes: val })) } placeholder="Interní poznámky..." /> {/* Status change confirm — stays open with loading during the request */} {isEdit && ( setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav objednávky" message={`Opravdu chcete změnit stav objednávky "${documentNumberLabel( poNumber, )}" na "${statusLabel(ISSUED_ORDER_STATUS, statusConfirm.status)}"?`} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } cancelText="Zrušit" confirmVariant={ statusConfirm.status === "cancelled" ? "danger" : "primary" } loading={statusChanging !== null} /> )} {/* Delete confirm */} {isEdit && ( setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat objednávku?" message={`Opravdu chcete smazat objednávku "${documentNumberLabel( poNumber, )}"? Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" confirmVariant="danger" loading={deleting} /> )}
); }