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 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 Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Checkbox from "@mui/material/Checkbox"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import { offerDetailOptions, offerCustomersOptions, scopeTemplatesOptions, offerNextNumberOptions, itemTemplatesOptions, type ItemTemplate, type OfferOrderInfo, 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 Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; import useDebounce from "../hooks/useDebounce"; import apiFetch from "../utils/api"; import { formatCurrency, todayLocalStr } from "../utils/formatters"; import { companySettingsOptions } from "../lib/queries/settings"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, Modal, ConfirmDialog, EmptyState, LoadingState, PageEnter, headerActionsSx, } from "../ui"; 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: "", }); const BackIcon = ( ); const FileIcon = ( ); const DragIcon = ( ); const RemoveIcon = ( ); const UpIcon = ( ); const DownIcon = ( ); 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 theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--mui-palette-action-hover)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); if (isMobile) { return ( {!readOnly && ( {DragIcon} )} Položka {index + 1} {!readOnly && ( {RemoveIcon} )} onUpdate("description", e.target.value)} placeholder="Název položky" InputProps={{ readOnly }} /> onUpdate("item_description", e.target.value)} placeholder="Volitelný" InputProps={{ readOnly }} /> onUpdate("quantity", parseInt(e.target.value, 10))} slotProps={{ htmlInput: { step: "1" } }} InputProps={{ readOnly }} /> onUpdate("unit", e.target.value)} InputProps={{ readOnly }} /> onUpdate("unit_price", parseFloat(e.target.value))} slotProps={{ htmlInput: { step: "0.01" } }} InputProps={{ readOnly }} /> onUpdate("is_included_in_total", e.target.checked) } disabled={readOnly} /> V ceně Celkem {formatCurrency(lineTotal, currency)} ); } return ( {!readOnly && ( {DragIcon} )} {index + 1} onUpdate("description", e.target.value)} placeholder="Název položky" InputProps={{ readOnly }} sx={{ "& input": { fontWeight: 500 } }} /> onUpdate("item_description", e.target.value)} placeholder="Podrobný popis (volitelný)" InputProps={{ readOnly }} sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }} /> onUpdate("quantity", parseInt(e.target.value, 10))} slotProps={{ htmlInput: { step: "1" } }} InputProps={{ readOnly }} /> onUpdate("unit", e.target.value)} InputProps={{ readOnly }} /> onUpdate("unit_price", parseFloat(e.target.value))} slotProps={{ htmlInput: { step: "0.01" } }} InputProps={{ readOnly }} /> onUpdate("is_included_in_total", e.target.checked)} disabled={readOnly} /> {formatCurrency(lineTotal, currency)} {!readOnly && ( {RemoveIcon} )} ); } 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 theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 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(() => { // The draft is the unsaved-NEW-offer buffer — never restore it over an // existing offer in edit mode (it would also leak its stale item keys). const draft = isEdit ? null : 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 = isEdit ? null : loadOfferDraft(); if (Array.isArray(draft?.items) && draft.items.length > 0) { // Re-key restored items: their persisted _key values are stale and the // counter restarts at 0 each load, so reusing them would collide with // newly-added rows (duplicate React key / dnd-kit id → the warning). return (draft.items as OfferItem[]).map((it) => ({ ...it, _key: `item-${++itemKeyCounter.current}`, })); } return [emptyItem()]; }); const [sections, setSections] = useState(() => { const draft = isEdit ? null : 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: Modal applies useDialogScrollLock 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" }) .then((res) => { // 401 here means the access token expired AND the refresh failed // (session truly gone — apiFetch retries with a refreshed token on a // recoverable 401 and would return 200). Stop the interval so we don't // spam a 401 every 10s while the user sits idle on a dead session; // apiFetch has already flagged session-expired for the next action. if (res.status === 401 && heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; } }) .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, isCompleted]); // 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 && ( )} {isCompleted && } {isEdit && hasPermission("offers.export") && ( )} {isEdit && !readOnly && hasPermission("orders.create") && !orderInfo && ( )} {isEdit && orderInfo && ( )} {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)} placeholder="Volitelný kód projektu" InputProps={{ readOnly }} /> {form.customer_id ? ( {form.customer_name} {!readOnly && ( )} ) : ( e.stopPropagation()} > { setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} placeholder="Hledat zákazníka..." InputProps={{ readOnly }} /> {showCustomerDropdown && !isInvalidated && ( {filteredCustomers.length === 0 ? ( Žádní zákazníci ) : ( filteredCustomers.slice(0, 20).map((c) => ( selectCustomer(c)} sx={{ px: 1.5, py: 1, cursor: "pointer", "&:hover": { bgcolor: "action.hover" }, }} > {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, })); }} /> )} updateForm("language", val)} disabled={readOnly} options={[ { value: "EN", label: "English" }, { value: "CZ", label: "Čeština" }, ]} /> { const templateId = Number(val); if (!templateId) return; const template = itemTemplates.find( (t: ItemTemplate) => t.id === templateId, ); if (template) { setItems((prev) => [ ...prev, { _key: `item-${++itemKeyCounter.current}`, description: template.name || "", item_description: template.description || "", quantity: 1, unit: "ks", unit_price: template.default_price || 0, is_included_in_total: true, }, ]); alert.success( `Načtena šablona položky "${template.name}"`, ); } }} sx={{ minWidth: 160 }} options={[ { value: "", label: "Ze šablony..." }, ...itemTemplates.map((t: ItemTemplate) => ({ value: String(t.id), label: t.name, })), ]} /> )} )} {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} > {isMobile ? ( {items.map((item, index) => ( 1} onUpdate={(field, value) => updateItem(index, field as keyof OfferItem, value) } onRemove={() => removeItem(index)} /> ))} ) : ( {!readOnly && } # Popis Množství Jednotka Cena/ks V ceně Celkem {!readOnly && } {items.map((item, index) => ( 1} onUpdate={(field, value) => updateItem(index, field as keyof OfferItem, value) } onRemove={() => removeItem(index)} /> ))}
)}
{/* 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 && ( ) => setOrderAttachment(e.target.files?.[0] || null) } style={{ display: "none" }} /> )} 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" confirmVariant="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" confirmVariant="danger" loading={deleting} />
); }