import { useState, useEffect, useMemo, useRef, type ChangeEvent } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import { offerDetailOptions, offerCustomersOptions, scopeTemplatesOptions, offerNextNumberOptions, itemTemplatesOptions, type ItemTemplate, type OfferOrderInfo, type Customer, } from "../lib/queries/offers"; import { useApiMutation, apiErrorMessage, type ApiEnvelope, } from "../lib/queries/mutations"; import Forbidden from "../components/Forbidden"; 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 { useDocumentLock } from "../hooks/useDocumentLock"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { numberOr, todayLocalStr } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { companySettingsOptions } from "../lib/queries/settings"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, Modal, ConfirmDialog, LoadingState, PageEnter, CustomerPicker, headerActionsSx, } from "../ui"; import { OFFER_STATUS, statusLabel, statusColor, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // The live (finalized) status for an offer — assigning it (on create or on // finalizing a draft) makes the backend consume the official quotation number. const LIVE_STATUS = "active"; interface OfferForm { project_code: string; customer_id: number | null; customer_name: string; created_at: string; valid_until: string; currency: string; language: string; } const emptyForm: OfferForm = { project_code: "", customer_id: null, customer_name: "", created_at: todayLocalStr(), valid_until: "", currency: "CZK", language: "EN", }; interface OfferItemPayload { description: string; item_description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; position: number; } interface OfferSectionPayload { title: string; title_cz: string; content: string; position: number; } interface OfferSavePayload { project_code: string; customer_id: number | null; created_at: string; valid_until: string; currency: string; language: string; items: OfferItemPayload[]; sections: OfferSectionPayload[]; status?: string; } const BackIcon = ( ); const FileIcon = ( ); export default function OfferDetail() { const { id } = useParams(); const isEdit = Boolean(id); const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient(); // ---- TanStack Query hooks ---- const offerQuery = useQuery(offerDetailOptions(id)); // Load customers in both modes: the shared CustomerPicker needs the full list // (even in edit/read-only mode) to resolve the current customer id → its row. const { data: customersData } = useQuery(offerCustomersOptions()); const { data: templatesData } = useQuery(scopeTemplatesOptions()); const { data: itemTemplates } = useQuery(itemTemplatesOptions()); const { data: nextNumberData } = useQuery({ ...offerNextNumberOptions(), enabled: !isEdit, }); const scopeTemplates = templatesData ?? []; const { data: companySettings } = useQuery(companySettingsOptions()); const loading = isEdit && offerQuery.isLoading; const [saving, setSaving] = useState(false); // Which save action is in flight ("draft" | 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 [errors, setErrors] = useState>({}); const [form, setForm] = useState(emptyForm); const [items, setItems] = useState(() => [ emptyDocumentItem(), ]); const [sections, setSections] = useState([]); const [orderInfo, setOrderInfo] = useState(null); const [offerStatus, setOfferStatus] = useState(""); const [quotationNumber, setQuotationNumber] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const [creatingOrder, setCreatingOrder] = useState(false); const [showOrderModal, setShowOrderModal] = useState(false); const [invalidateConfirm, setInvalidateConfirm] = useState(false); const [invalidatingOffer, setInvalidatingOffer] = useState(false); const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [orderAttachment, setOrderAttachment] = useState(null); useEffect(() => { if (companySettings && !isEdit) { setForm((prev) => ({ ...prev, currency: prev.currency === "CZK" ? companySettings.default_currency || "CZK" : prev.currency, })); } }, [companySettings, isEdit]); // Set to true right after a successful delete so the detail-page error // effect doesn't fire "Nepodařilo se načíst nabídku" 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); // The customers lookup may not contain a since-deleted/stale customer a // saved offer still references — synthesize a fallback option from the // hydrated form state so the picker keeps showing the name and a re-save // keeps the same id (same approach as the issued-orders supplier picker). const customers = useMemo(() => { const activeCustomers: Customer[] = Array.isArray(customersData) ? customersData : []; if ( form.customer_id != null && !activeCustomers.some((c) => c.id === form.customer_id) ) { return [ ...activeCustomers, { id: form.customer_id, name: form.customer_name || `Zákazník #${form.customer_id}`, quotation_count: 0, }, ]; } return activeCustomers; }, [customersData, form.customer_id, form.customer_name]); const isInvalidated = offerStatus === "invalidated"; const isCompleted = orderInfo?.status === "dokoncena"; const validTransitions = offerQuery.data?.valid_transitions ?? []; // ─── Editability + shared document hooks ─── // Fields are editable only in create mode or while draft/active (the // backend now 400s field edits on ordered/invalidated offers); an // offers.view-only holder opens ANY offer read-only, and a fresh lock held // by another user forces read-only too. const statusEditable = !isEdit || offerStatus === "draft" || offerStatus === "active"; const { lockedBy, isLockedByOther, setLockedBy, acquire: acquireLock, } = useDocumentLock({ baseUrl: isEdit && id ? `${API_BASE}/offers/${id}` : null, enabled: isEdit && statusEditable && hasPermission("offers.edit"), }); const readOnly = isEdit && (!statusEditable || isLockedByOther || !hasPermission("offers.edit")); const canInvalidate = isEdit && validTransitions.includes("invalidated") && !isCompleted && !orderInfo && !isLockedByOther; // An offer is deletable only when it has NOT spawned an order (orderInfo — // deleting would orphan the linked order), is NOT invalidated, and is NOT // locked by another user. (Backend rejection of deleting an ordered offer is // a separate hardening, out of scope here.) const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther; const { markClean } = useUnsavedChangesGuard( { form, items, sections }, !loading, ); const { openPdf, pdfLoading } = useDocumentPdf( isEdit && id ? `${API_BASE}/offers/${id}/file` : null, ); // Sync offer detail data to form state on first load (edit mode) const formInitializedRef = useRef(false); useEffect(() => { if (!offerQuery.data || formInitializedRef.current) return; const d = offerQuery.data; const formData: OfferForm = { project_code: d.project_code || "", customer_id: d.customer_id ?? null, customer_name: d.customer_name || "", created_at: normalizeDateStr(d.created_at), valid_until: normalizeDateStr(d.valid_until), currency: d.currency || companySettings?.default_currency || "CZK", language: d.language || "EN", }; setForm(formData); setQuotationNumber(d.quotation_number || ""); const mappedItems = Array.isArray(d.items) && d.items.length ? 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, is_included_in_total: it.is_included_in_total !== false, })) : [emptyDocumentItem()]; setItems(mappedItems); const mappedSections = Array.isArray(d.sections) && d.sections.length ? d.sections.map((s) => ({ title: s.title || "", title_cz: s.title_cz || "", content: s.content || "", })) : []; setSections(mappedSections); markClean({ form: formData, items: mappedItems, sections: mappedSections }); setOfferStatus(d.status || ""); setOrderInfo(d.order ?? null); setLockedBy(d.locked_by ?? null); // Acquire the edit lock when nobody holds it, the offer is still // editable (draft/active) and the user is allowed to edit. if ( !d.locked_by && (d.status === "draft" || d.status === "active") && d.order?.status !== "dokoncena" && hasPermission("offers.edit") ) { acquireLock(); } formInitializedRef.current = true; }, [ offerQuery.data, companySettings, hasPermission, setLockedBy, acquireLock, markClean, ]); // Redirect on offer fetch error (edit mode). // Suppress when 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 && offerQuery.isError && !deletingRef.current) { alert.error("Nepodařilo se načíst nabídku"); navigate("/offers"); } }, [isEdit, offerQuery.isError, alert, navigate]); // Sync next-number data to the headline preview (create mode) useEffect(() => { if (isEdit || !nextNumberData) return; const num = nextNumberData.next_number || nextNumberData.number || ""; if (num) setQuotationNumber(num); }, [isEdit, nextNumberData]); // ─── Mutations (transport via useApiMutation; invalidation is handled // manually below to preserve the awaited-invalidate ordering) ─── const saveMutation = useApiMutation< OfferSavePayload, ApiEnvelope<{ id: number }> >({ url: () => (isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`), method: () => (isEdit ? "PUT" : "POST"), envelope: true, }); const invalidateMutation = useApiMutation>({ url: () => `${API_BASE}/offers/${id}/invalidate`, method: () => "POST", envelope: true, }); const deleteMutation = useApiMutation>({ url: () => `${API_BASE}/offers/${id}`, method: () => "DELETE", envelope: true, }); const updateForm = (field: keyof OfferForm, value: unknown) => { setForm((prev) => ({ ...prev, [field]: value })); setErrors((prev) => ({ ...prev, [field]: "" })); }; const selectCustomer = (customerId: number | null) => { const c = customerId != null ? customers.find((x) => x.id === customerId) : null; setForm((prev) => ({ ...prev, customer_id: customerId, customer_name: c?.name ?? "", })); setErrors((prev) => ({ ...prev, customer_id: "" })); }; const invalidateOfferDomains = () => { queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); }; const handleSave = async (targetStatus?: string) => { if (readOnly || saving) return; const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka"; if (!form.created_at) newErrors.created_at = "Zadejte datum"; if (!form.valid_until) newErrors.valid_until = "Zadejte datum platnosti"; if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku"; setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); setSavingAction(targetStatus ?? "save"); try { const payload: OfferSavePayload = { project_code: form.project_code, customer_id: form.customer_id, created_at: form.created_at, valid_until: form.valid_until, currency: form.currency, language: form.language, items: items.map((item, i) => ({ description: item.description, item_description: item.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(item.quantity) || 0, unit: item.unit, unit_price: Number(item.unit_price) || 0, is_included_in_total: item.is_included_in_total !== false, position: i, })), sections: sections.map((s, i) => ({ title: s.title, title_cz: s.title_cz, content: s.content, position: i, })), }; // Only set status when a target is given (create-as-draft / create-as-live // / finalize). Editing a live offer sends no status — the backend keeps // its current status. The quotation number is never sent (drafts have // none; it is assigned by the sequence on finalize and is immutable). if (targetStatus) payload.status = targetStatus; const result = await saveMutation.mutateAsync(payload); const offerId = isEdit ? Number(id) : result.data?.id; // A draft has no number → skip the PDF save. Only generate/archive the // PDF on a live create or a finalize (targetStatus !== "draft"). // Fire-and-forget: never block the success toast or navigation — but a // failure is surfaced, not silently swallowed. if (offerId && targetStatus !== "draft") { apiFetch(`${API_BASE}/offers-pdf/${offerId}?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"), ); } alert.success( result.message || (isEdit ? "Nabídka byla uložena" : "Nabídka byla vytvořena"), ); if (!isEdit && result.data?.id) { invalidateOfferDomains(); navigate(`/offers/${result.data.id}`); } if (isEdit) { markClean({ form, items, sections }); // Finalizing a draft (draft → active) assigns the official number // server-side. Reflect the new status immediately and re-hydrate the // form from the refetched detail so the now-assigned number renders // (the local state already equals what was just persisted, so the // re-hydration is non-destructive). if (targetStatus && targetStatus !== "draft") { setOfferStatus(targetStatus); formInitializedRef.current = false; await queryClient.invalidateQueries({ queryKey: ["offers", id] }); } invalidateOfferDomains(); } } catch (err) { alert.error( apiErrorMessage( err, isEdit ? "Nepodařilo se uložit nabídku" : "Nepodařilo se vytvořit nabídku", ), ); } 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 offer → plain save (no status change). const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (readOnly) return; if (!isEdit) void handleSave(LIVE_STATUS); else if (offerStatus === "draft") void handleSave("draft"); else void handleSave(); }; const handleCreateOrder = async () => { if (!customerOrderNumber.trim()) { alert.error("Číslo objednávky zákazníka je povinné"); return; } setCreatingOrder(true); try { let fetchOptions: RequestInit; if (orderAttachment) { // With attachment: send as multipart/form-data const formData = new FormData(); formData.append("quotationId", String(id)); formData.append("customerOrderNumber", customerOrderNumber.trim()); formData.append("attachment", orderAttachment); fetchOptions = { method: "POST", body: formData }; } else { // Without attachment: send as JSON fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quotationId: id, customerOrderNumber: customerOrderNumber.trim(), }), }; } const response = await apiFetch(`${API_BASE}/orders`, fetchOptions); const result = await response.json(); if (result.success) { setShowOrderModal(false); alert.success(result.message || "Objednávka byla vytvořena"); await queryClient.invalidateQueries({ queryKey: ["offers"] }); await queryClient.invalidateQueries({ queryKey: ["orders"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["invoices"] }); navigate(`/orders/${result.data.order_id}`); } else { alert.error(result.error || "Nepodařilo se vytvořit objednávku"); } } catch { alert.error("Chyba připojení"); } finally { setCreatingOrder(false); } }; const handleInvalidateOffer = async () => { setInvalidatingOffer(true); try { const result = await invalidateMutation.mutateAsync(undefined); setInvalidateConfirm(false); setOfferStatus("invalidated"); alert.success(result.message || "Nabídka byla zneplatněna"); invalidateOfferDomains(); } catch (err) { alert.error(apiErrorMessage(err, "Nepodařilo se zneplatnit nabídku")); } finally { setInvalidatingOffer(false); } }; 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: ["offers", id] }); alert.success(result.message || "Nabídka byla smazána"); await queryClient.invalidateQueries({ queryKey: ["offers"] }); await queryClient.invalidateQueries({ queryKey: ["orders"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["invoices"] }); navigate("/offers"); } catch (err) { alert.error(apiErrorMessage(err, "Nepodařilo se smazat nabídku")); } finally { setDeleting(false); setDeleteConfirm(false); } }; // ─── Permission guards (AFTER all hooks) ─── // A view-only (offers.view) holder can OPEN any offer read-only; create // mode requires offers.create. if (!hasPermission(isEdit ? "offers.view" : "offers.create")) { return ; } if (loading) { return ; } return ( {/* Header */} {/* flexWrap: long document titles must drop below the Zpět button on phones instead of overflowing the viewport. */} {isEdit ? "Nabídka" : "Nová nabídka"} {/* Edit mode: a draft has no number yet → show "Koncept". Create mode: show the previewed next-number when available. */} {(isEdit || quotationNumber) && ( {isEdit ? documentNumberLabel(quotationNumber) : quotationNumber} )} {isEdit && (isCompleted ? ( // Completed is derived from the linked order, not the offer's // own status — surface it (success) over the base chip. ) : ( ))} {/* Drafts have no number yet — /file 404s for them. */} {isEdit && hasPermission("offers.view") && offerStatus !== "draft" && ( )} {isEdit && !readOnly && offerStatus === "active" && hasPermission("orders.create") && !orderInfo && ( )} {isEdit && orderInfo && ( )} {canInvalidate && hasPermission("offers.edit") && ( )} {/* ── Create mode: two-button save ── */} {!isEdit && !readOnly && ( <> )} {/* ── Edit mode, draft: save-draft + finalize (Aktivovat) ── */} {isEdit && !readOnly && offerStatus === "draft" && ( <> )} {/* ── Edit mode, live (non-draft) offer — unchanged plain save ── */} {isEdit && !readOnly && offerStatus !== "draft" && ( )} {canDelete && hasPermission("offers.delete") && ( )} {/* Lock banner */}
{/* Quotation Form */} Základní údaje updateForm("project_code", e.target.value)} placeholder="Volitelný kód projektu" InputProps={{ readOnly }} slotProps={{ htmlInput: { maxLength: 100 } }} /> updateForm("created_at", val)} /> updateForm("valid_until", val)} /> updateForm("language", val)} disabled={readOnly} options={[ { value: "EN", label: "English" }, { value: "CZ", label: "Čeština" }, ]} /> {/* Items (drag-and-drop, offers carry the "V ceně" column) */} 0 ? ( { const templateId = Number(val); if (!templateId) return; const template = scopeTemplates.find( (t) => t.id === templateId, ); if (template?.scope_template_sections?.length) { const newSections = template.scope_template_sections.map( (s) => ({ title: s.title || "", title_cz: s.title_cz || "", content: s.content || "", }), ); setSections((prev) => [...prev, ...newSections]); alert.success(`Načtena šablona "${template.name}"`); } }} sx={{ minWidth: 160 }} options={[ { value: "", label: "Ze šablony..." }, ...scopeTemplates.map((t) => ({ value: String(t.id), label: t.name, })), ]} /> ) : undefined } /> {/* Order modal */} !creatingOrder && setShowOrderModal(false)} onSubmit={handleCreateOrder} title="Vytvořit objednávku" submitText={creatingOrder ? "Vytváření..." : "Vytvořit"} loading={creatingOrder} maxWidth="sm" > ) => setCustomerOrderNumber(e.target.value) } onKeyDown={(e) => e.key === "Enter" && !creatingOrder && handleCreateOrder() } placeholder="Např. PO-2026-001" autoFocus /> {orderAttachment ? ( {orderAttachment.name}{" "} ({(orderAttachment.size / 1024).toFixed(0)} KB) setOrderAttachment(null)} title="Odebrat" aria-label="Odebrat" sx={{ ml: "auto" }} > ) : ( )} setInvalidateConfirm(false)} onConfirm={handleInvalidateOffer} title="Zneplatnit nabídku" message={`Opravdu chcete zneplatnit nabídku "${documentNumberLabel( quotationNumber, )}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`} confirmText="Zneplatnit" cancelText="Zrušit" confirmVariant="danger" loading={invalidatingOffer} /> setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat nabídku" message={`Opravdu chcete smazat nabídku "${documentNumberLabel( quotationNumber, )}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" confirmVariant="danger" loading={deleting} />
); }