import { useState, useEffect, useCallback, 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 } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { offerDetailOptions, offerCustomersOptions, scopeTemplatesOptions, offerNextNumberOptions, itemTemplatesOptions, type ItemTemplate, type OfferDetailData, type OfferItemData, type OfferSectionData, type OfferLockInfo, type OfferOrderInfo, type ScopeTemplate, type Customer, } from "../lib/queries/offers"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { restrictToVerticalAxis, restrictToParentElement, } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import ConfirmModal from "../components/ConfirmModal"; import FormModal from "../components/FormModal"; import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; import AdminDatePicker from "../components/AdminDatePicker"; import RichEditor from "../components/RichEditor"; import useDebounce from "../hooks/useDebounce"; import apiFetch from "../utils/api"; import { formatCurrency, todayLocalStr } from "../utils/formatters"; import { companySettingsOptions, type CompanySettingsData, } from "../lib/queries/settings"; import { useApiMutation } from "../lib/queries/mutations"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; interface OfferItem { _key: string; id?: number; description: string; item_description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; } interface ScopeSection { title: string; title_cz: string; content: string; } interface OfferForm { quotation_number: string; project_code: string; customer_id: number | null; customer_name: string; created_at: string; valid_until: string; currency: string; language: string; vat_rate: number; apply_vat: boolean; } const emptyForm: OfferForm = { quotation_number: "", project_code: "", customer_id: null, customer_name: "", created_at: todayLocalStr(), valid_until: "", currency: "CZK", language: "EN", vat_rate: 21, apply_vat: false, }; const emptyScopeSection = (): ScopeSection => ({ title: "", title_cz: "", content: "", }); function SortableItemRow({ item, index, currency, readOnly, canDelete, onUpdate, onRemove, }: { item: OfferItem; index: number; currency: string; readOnly: boolean; canDelete: boolean; onUpdate: (field: string, value: unknown) => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item._key, disabled: readOnly }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--bg-secondary)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); return ( {!readOnly && ( )} {index + 1}
onUpdate("description", e.target.value)} className="admin-form-input fw-500" placeholder="Název položky" readOnly={readOnly} /> onUpdate("item_description", e.target.value)} className="admin-form-input" placeholder="Podrobný popis (volitelný)" readOnly={readOnly} style={{ fontSize: "0.8rem", opacity: 0.8 }} />
onUpdate("quantity", parseInt(e.target.value, 10))} className="admin-form-input" step="1" readOnly={readOnly} /> onUpdate("unit", e.target.value)} className="admin-form-input" readOnly={readOnly} /> onUpdate("unit_price", parseFloat(e.target.value))} className="admin-form-input" step="0.01" readOnly={readOnly} /> onUpdate("is_included_in_total", e.target.checked)} disabled={readOnly} /> {formatCurrency(lineTotal, currency)} {!readOnly && ( )} ); } function loadOfferDraft(): { form?: Record; items?: unknown[]; sections?: unknown[]; } | null { try { const raw = localStorage.getItem(DRAFT_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { console.error("Failed to load offer draft:", e); return null; } } export default function OfferDetail() { const { id } = useParams(); const isEdit = Boolean(id); const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 }, }), useSensor(KeyboardSensor), ); const itemKeyCounter = useRef(0); const emptyItem = useCallback( (): OfferItem => ({ _key: `item-${++itemKeyCounter.current}`, description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, is_included_in_total: true, }), [], ); const queryClient = useQueryClient(); // ---- TanStack Query hooks ---- const offerQuery = useQuery(offerDetailOptions(id)); const { data: customersData } = useQuery({ ...offerCustomersOptions(), enabled: !isEdit, }); const { data: templatesData } = useQuery(scopeTemplatesOptions()); const { data: itemTemplates } = useQuery(itemTemplatesOptions()); const { data: nextNumberData } = useQuery({ ...offerNextNumberOptions(), enabled: !isEdit, }); const customers: Customer[] = Array.isArray(customersData) ? customersData : []; const scopeTemplates = templatesData ?? []; const loading = isEdit && offerQuery.isLoading; const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); const [form, setForm] = useState(() => { const draft = loadOfferDraft(); if (draft?.form) { return { quotation_number: (draft.form.quotation_number as string) || emptyForm.quotation_number, project_code: (draft.form.project_code as string) || emptyForm.project_code, customer_id: (draft.form.customer_id as number | null) ?? emptyForm.customer_id, customer_name: (draft.form.customer_name as string) || emptyForm.customer_name, created_at: (draft.form.created_at as string) || emptyForm.created_at, valid_until: (draft.form.valid_until as string) || emptyForm.valid_until, currency: (draft.form.currency as string) || emptyForm.currency, language: (draft.form.language as string) || emptyForm.language, vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate, apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat, }; } return emptyForm; }); const [items, setItems] = useState(() => { const draft = loadOfferDraft(); if (Array.isArray(draft?.items) && draft.items.length > 0) { return draft.items as OfferItem[]; } return [emptyItem()]; }); const [sections, setSections] = useState(() => { const draft = loadOfferDraft(); if (Array.isArray(draft?.sections) && draft.sections.length > 0) { return draft.sections as ScopeSection[]; } return []; }); const [customerSearch, setCustomerSearch] = useState(""); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const [orderInfo, setOrderInfo] = useState(null); const [offerStatus, setOfferStatus] = 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); const [pdfLoading, setPdfLoading] = useState(false); const blobTimeoutsRef = useRef[]>([]); const { data: companySettings } = useQuery(companySettingsOptions()); useEffect(() => { if (companySettings && !isEdit) { setForm((prev) => ({ ...prev, currency: prev.currency === "CZK" ? companySettings.default_currency || "CZK" : prev.currency, vat_rate: prev.vat_rate === 21 ? (companySettings.default_vat_rate ?? 21) : prev.vat_rate, })); } }, [companySettings, isEdit]); const [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string; } | null>(null); const heartbeatRef = useRef | null>(null); const unlockAbortRef = useRef(null); const initialSnapshotRef = useRef(null); // 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); // Note: FormModal applies useModalLock internally. useEffect(() => { return () => { blobTimeoutsRef.current.forEach(clearTimeout); }; }, []); const isInvalidated = offerStatus === "invalidated"; const isCompleted = orderInfo?.status === "dokoncena"; const isLockedByOther = !!lockedBy; const readOnly = isInvalidated || isLockedByOther || isCompleted; const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo; // 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 = { quotation_number: d.quotation_number || "", project_code: d.project_code || "", customer_id: d.customer_id ?? null, customer_name: d.customer_name || "", created_at: d.created_at ? d.created_at.substring(0, 10) : "", valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "", currency: d.currency || companySettings?.default_currency || "CZK", language: d.language || "EN", vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21, apply_vat: !!d.apply_vat, }; setForm(formData); const mappedItems = Array.isArray(d.items) && d.items.length ? d.items.map((it) => ({ ...it, _key: `item-${++itemKeyCounter.current}`, })) : [emptyItem()]; 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); initialSnapshotRef.current = JSON.stringify({ form: formData, items: mappedItems, sections: mappedSections, }); setOfferStatus(d.status || ""); setOrderInfo(d.order ?? null); setLockedBy(d.locked_by ?? null); // Try to acquire lock if not locked by someone else, not invalidated, and not completed if ( !d.locked_by && d.status !== "invalidated" && d.order?.status !== "dokoncena" && hasPermission("offers.edit") ) { apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch( () => {}, ); } formInitializedRef.current = true; }, [offerQuery.data, companySettings, hasPermission, id, emptyItem]); // 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 form (create mode) useEffect(() => { if (isEdit || !nextNumberData) return; const num = nextNumberData.next_number || nextNumberData.number || ""; if (num) { setForm((prev) => ({ ...prev, quotation_number: num })); } }, [isEdit, nextNumberData]); // Heartbeat to keep lock alive + cleanup on unmount useEffect(() => { if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted) return; heartbeatRef.current = setInterval(() => { apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch( () => {}, ); }, 10 * 1000); // every 10 seconds return () => { if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (unlockAbortRef.current) unlockAbortRef.current.abort(); // Release lock on unmount const controller = new AbortController(); unlockAbortRef.current = controller; apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST", signal: controller.signal, }).catch(() => {}); }; }, [isEdit, id, isLockedByOther, isInvalidated]); // Capture initial snapshot after loading completes (create mode) if (!loading && !initialSnapshotRef.current) { initialSnapshotRef.current = JSON.stringify({ form, items, sections }); } const isDirty = useMemo(() => { if (!initialSnapshotRef.current) return false; return ( JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current ); }, [form, items, sections]); useEffect(() => { if (!isDirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [isDirty]); // Close dropdown on outside click useEffect(() => { const handleClickOutside = () => setShowCustomerDropdown(false); if (showCustomerDropdown) { document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); } }, [showCustomerDropdown]); // Auto-save draft to localStorage (create mode only) const draftPayload = JSON.stringify({ form, items, sections }); const debouncedDraft = useDebounce(draftPayload, 1500); useEffect(() => { if (isEdit) return; try { const data = JSON.parse(debouncedDraft); const draft = { form: { project_code: data.form.project_code ?? "", customer_id: data.form.customer_id ?? null, customer_name: data.form.customer_name ?? "", created_at: data.form.created_at ?? "", valid_until: data.form.valid_until ?? "", currency: data.form.currency ?? "CZK", language: data.form.language ?? "EN", vat_rate: data.form.vat_rate ?? 21, apply_vat: data.form.apply_vat ?? false, }, items: data.items, sections: data.sections, savedAt: new Date().toISOString(), }; localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch (e) { console.error("Failed to save offer draft:", e); } }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps const updateForm = (field: keyof OfferForm, value: unknown) => { setForm((prev) => ({ ...prev, [field]: value })); setErrors((prev) => ({ ...prev, [field]: undefined })); }; const selectCustomer = (c: Customer) => { setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name })); setErrors((prev) => ({ ...prev, customer_id: undefined })); setCustomerSearch(""); setShowCustomerDropdown(false); }; const clearCustomer = () => { setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" })); }; const updateItem = ( index: number, field: keyof OfferItem, value: unknown, ) => { setItems((prev) => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), ); }; const addItem = () => setItems((prev) => [...prev, emptyItem()]); const removeItem = (index: number) => { setItems((prev) => prev.filter((_, i) => i !== index)); }; const subtotal = items.reduce((sum, item) => { if (item.is_included_in_total) { return ( sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) ); } return sum; }, 0); const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0; const total = subtotal + vatAmount; const filteredCustomers = customerSearch ? customers.filter((c) => c.name.toLowerCase().includes(customerSearch.toLowerCase()), ) : customers; const handleSave = async () => { const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný"; if (!form.created_at) newErrors.created_at = "Datum je povinné"; if (!form.valid_until) newErrors.valid_until = "Platnost je povinná"; if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku"; setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); try { const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const payload: any = { ...form, items: items.map((item, i) => ({ ...item, position: i })), sections: sections.map((s, i) => ({ ...s, position: i })), }; if (!isEdit) delete payload.quotation_number; const response = await apiFetch(url, { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json(); if (result.success) { const offerId = isEdit ? id : result.data?.id; if (offerId) { await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch( () => {}, ); } alert.success( result.message || (isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"), ); if (!isEdit) { try { localStorage.removeItem(DRAFT_KEY); } catch (e) { console.error("Failed to remove offer draft:", e); } } if (!isEdit && result.data?.id) { queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); navigate(`/offers/${result.data.id}`); } if (isEdit) { initialSnapshotRef.current = JSON.stringify({ form, items, sections, }); queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); } } else { alert.error(result.error || "Nepodařilo se uložit nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; 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 response = await apiFetch(`${API_BASE}/offers/${id}/invalidate`, { method: "POST", }); const result = await response.json(); if (result.success) { setInvalidateConfirm(false); setOfferStatus("invalidated"); alert.success(result.message || "Nabídka byla zneplatněna"); queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); } else { alert.error(result.error || "Nepodařilo se zneplatnit nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setInvalidatingOffer(false); } }; const handleDelete = async () => { setDeleting(true); try { const response = await apiFetch(`${API_BASE}/offers/${id}`, { method: "DELETE", }); const result = await response.json(); if (result.success) { // 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"); } else { alert.error(result.error || "Nepodařilo se smazat nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; const handlePdf = async () => { if (!isEdit || pdfLoading) return; const newWindow = window.open("", "_blank"); setPdfLoading(true); try { const response = await apiFetch(`${API_BASE}/offers/${id}/file`); if (response.status === 401) { newWindow?.close(); return; } if (!response.ok) { newWindow?.close(); alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba při generování PDF"); } finally { setPdfLoading(false); } }; const getRequiredPerm = () => { if (!isEdit) return "offers.create"; return isInvalidated || isCompleted ? "offers.view" : "offers.edit"; }; const requiredPerm = getRequiredPerm(); if (!hasPermission(requiredPerm)) return ; if (loading) { return (
); } return (
{/* Header */}

{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} {isInvalidated && ( Zneplatněna )} {isCompleted && ( Dokončeno )}

{isEdit && hasPermission("offers.export") && ( )} {isEdit && !readOnly && hasPermission("orders.create") && !orderInfo && ( )} {isEdit && orderInfo && ( Objednávka {orderInfo.order_number} )} {canInvalidate && hasPermission("offers.edit") && ( )} {!readOnly && ( )} {isEdit && hasPermission("offers.delete") && ( )}
{/* Lock banner */} {isLockedByOther && (
Nabídku právě upravuje {lockedBy!.full_name}. Můžete ji pouze prohlížet.
)} {/* Quotation Form */}

Základní údaje

updateForm("project_code", e.target.value)} className="admin-form-input" placeholder="Volitelný kód projektu" readOnly={readOnly} /> {form.customer_id ? (
{form.customer_name} {!readOnly && ( )}
) : (
e.stopPropagation()} > { setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} className="admin-form-input" placeholder="Hledat zákazníka..." readOnly={readOnly} /> {showCustomerDropdown && !isInvalidated && (
{filteredCustomers.length === 0 ? (
Žádní zákazníci
) : ( filteredCustomers.slice(0, 20).map((c) => (
selectCustomer(c)} >
{c.name}
{c.city &&
{c.city}
}
)) )}
)}
)}
{readOnly ? ( ) : ( { updateForm("created_at", val); setErrors((prev) => ({ ...prev, created_at: undefined })); }} /> )} {readOnly ? ( ) : ( { updateForm("valid_until", val); setErrors((prev) => ({ ...prev, valid_until: undefined, })); }} /> )}
{/* Items Section with drag-and-drop */}

Položky

{!readOnly && (
{itemTemplates && itemTemplates.length > 0 && ( )}
)}
{errors.items && (

{errors.items}

)}
{ const { active, over } = event; if (!over || active.id === over.id) return; setItems((prev) => { const oldIndex = prev.findIndex( (i) => i._key === String(active.id), ); const newIndex = prev.findIndex( (i) => i._key === String(over.id), ); if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex); }); }} > i._key)} strategy={verticalListSortingStrategy} > {!readOnly && {!readOnly && ( {items.map((item, index) => ( 1} onUpdate={(field, value) => updateItem(index, field as keyof OfferItem, value) } onRemove={() => removeItem(index)} /> ))}
} # Popis Množství Jednotka Cena/ks V ceně Celkem )}
{/* Totals */}
Mezisoučet: {formatCurrency(subtotal, form.currency)}
{form.apply_vat && (
DPH ({form.vat_rate}%): {formatCurrency(vatAmount, form.currency)}
)}
Celkem: {formatCurrency(total, form.currency)}
{/* Scope/Range Section */}

Rozsah projektu

{!readOnly && (
{scopeTemplates.length > 0 && ( )}
)}
{sections.length === 0 ? (

Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.

) : (
{sections.map((section, idx) => (
Sekce {idx + 1} {(form.language === "CZ" ? section.title_cz : section.title) && ( —{" "} {form.language === "CZ" ? section.title_cz || section.title : section.title} )} {!readOnly && (
{idx > 0 && ( )} {idx < sections.length - 1 && ( )}
)}
ENNázev sekce } > setSections((prev) => prev.map((s, i) => i === idx ? { ...s, title: e.target.value } : s, ), ) } className="admin-form-input" placeholder="Název sekce (anglicky)" readOnly={readOnly} /> CZ Název sekce } > setSections((prev) => prev.map((s, i) => i === idx ? { ...s, title_cz: e.target.value } : s, ), ) } className="admin-form-input" placeholder="Název sekce (česky)" readOnly={readOnly} />
setSections((prev) => prev.map((s, i) => i === idx ? { ...s, content: val } : s, ), ) } placeholder="Obsah sekce..." minHeight="120px" readOnly={readOnly} />
))}
)}
{/* Order modal */} !creatingOrder && setShowOrderModal(false)} onSubmit={handleCreateOrder} title="Vytvořit objednávku" submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"} loading={creatingOrder} >
) => setCustomerOrderNumber(e.target.value) } onKeyDown={(e) => e.key === "Enter" && !creatingOrder && handleCreateOrder() } className="admin-form-input" placeholder="Např. PO-2026-001" autoFocus /> {orderAttachment ? (
{orderAttachment.name}{" "} ({(orderAttachment.size / 1024).toFixed(0)} KB)
) : ( )} Max 10 MB
setInvalidateConfirm(false)} onConfirm={handleInvalidateOffer} title="Zneplatnit nabídku" message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`} confirmText="Zneplatnit" cancelText="Zrušit" type="danger" loading={invalidatingOffer} /> setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat nabídku" message={`Opravdu chcete smazat nabídku "${form.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" type="danger" loading={deleting} />
); }