import { useState, useEffect, useRef } from "react"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { Link, useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import ConfirmModal from "../components/ConfirmModal"; import FormModal from "../components/FormModal"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import SortIcon from "../components/SortIcon"; import useTableSort from "../hooks/useTableSort"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers"; import Pagination from "../components/Pagination"; import FormField from "../components/FormField"; import { useApiMutation } from "../lib/queries/mutations"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; const STATUS_FILTERS = [ { value: "", label: "Vše" }, { value: "active", label: "Aktivní" }, { value: "ordered", label: "Objednaná" }, { value: "invalidated", label: "Zneplatněná" }, ]; interface Quotation { id: number; quotation_number: string; project_code: string; customer_name: string; created_at: string; valid_until: string; currency: string; total: number; status: string; order_id?: number; order_status?: string; } interface Draft { form: { project_code: string; customer_name: string; created_at: string; valid_until: string; currency: string; }; items: unknown[]; savedAt?: string; } export default function Offers() { const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const { sort, order, handleSort, activeSort } = useTableSort("quotation_number"); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); const [customerFilter, setCustomerFilter] = useState(""); const { data: customers } = useQuery(offerCustomersOptions()); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; quotation: Quotation | null; }>({ show: false, quotation: null }); const [deleting, setDeleting] = useState(false); const [invalidateConfirm, setInvalidateConfirm] = useState<{ show: boolean; quotation: Quotation | null; }>({ show: false, quotation: null }); const [invalidating, setInvalidating] = useState(false); const blobUrlRef = useRef(null); useEffect(() => { return () => { if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; }, []); const [duplicating, setDuplicating] = useState(null); const [pdfLoading, setPdfLoading] = useState(null); const [creatingOrder, setCreatingOrder] = useState(null); const [orderModal, setOrderModal] = useState<{ show: boolean; quotation: Quotation | null; }>({ show: false, quotation: null }); const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [orderAttachment, setOrderAttachment] = useState(null); const [draft, setDraft] = useState(() => { try { const raw = localStorage.getItem(DRAFT_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed; } catch { /* ignore corrupt data */ } return null; }); const queryClient = useQueryClient(); const { items: quotations, pagination, isPending, isFetching, } = usePaginatedQuery( offerListOptions({ search, sort, order, page, status: statusFilter || undefined, customer_id: customerFilter || undefined, }), ); const duplicateMutation = useApiMutation< number, { message?: string; error?: string } >({ url: (id) => `${API_BASE}/offers/${id}/duplicate`, method: () => "POST", invalidate: ["offers", "orders", "projects", "invoices"], onSuccess: (data) => { alert.success(data?.message || "Nabídka byla duplikována"); }, }); const deleteOfferMutation = useApiMutation< number, { message?: string; error?: string } >({ url: (id) => `${API_BASE}/offers/${id}`, method: () => "DELETE", invalidate: ["offers", "orders", "projects", "invoices"], onSuccess: (data) => { setDeleteConfirm({ show: false, quotation: null }); alert.success(data?.message || "Nabídka byla smazána"); }, }); const invalidateMutation = useApiMutation< number, { message?: string; error?: string } >({ url: (id) => `${API_BASE}/offers/${id}/invalidate`, method: () => "POST", invalidate: ["offers", "orders", "projects", "invoices"], onSuccess: (data) => { setInvalidateConfirm({ show: false, quotation: null }); alert.success(data?.message || "Nabídka byla zneplatněna"); }, }); const discardDraft = () => { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } setDraft(null); }; const getRowClass = ( invalidated: boolean, expired: boolean, completed: boolean, ) => { if (invalidated) return "offers-invalidated-row"; if (completed) return "offers-completed-row"; if (expired) return "offers-expired-row"; return ""; }; if (!hasPermission("offers.view")) return ; const handleDuplicate = async (quotation: Quotation) => { setDuplicating(quotation.id); try { await duplicateMutation.mutateAsync(quotation.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setDuplicating(null); } }; const handleCreateOrder = async () => { if (!customerOrderNumber.trim() || !orderModal.quotation) return; setCreatingOrder(orderModal.quotation.id); try { let fetchOptions: RequestInit; if (orderAttachment) { // With attachment: send as multipart/form-data const formData = new FormData(); formData.append("quotationId", String(orderModal.quotation.id)); formData.append("customerOrderNumber", customerOrderNumber.trim()); formData.append("attachment", orderAttachment); fetchOptions = { method: "POST", body: formData }; } else { fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quotationId: orderModal.quotation.id, customerOrderNumber: customerOrderNumber.trim(), }), }; } const response = await apiFetch(`${API_BASE}/orders`, fetchOptions); const result = await response.json(); if (result.success) { setOrderModal({ show: false, quotation: null }); alert.success(result.message || "Objednávka byla vytvořena"); queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); 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(null); } }; const handleDelete = async () => { if (!deleteConfirm.quotation) return; setDeleting(true); try { await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setDeleting(false); } }; const handleInvalidate = async () => { if (!invalidateConfirm.quotation) return; setInvalidating(true); try { await invalidateMutation.mutateAsync(invalidateConfirm.quotation.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setInvalidating(false); } }; const handlePdf = async (quotation: Quotation) => { if (pdfLoading) return; const newWindow = window.open("", "_blank"); setPdfLoading(quotation.id); try { const response = await apiFetch( `${API_BASE}/offers/${quotation.id}/file`, ); if (response.status === 401) { newWindow?.close(); return; } if (!response.ok) { newWindow?.close(); alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji"); return; } const blob = await response.blob(); if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); } blobUrlRef.current = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = blobUrlRef.current; } catch { newWindow?.close(); alert.error("Chyba připojení"); } finally { setPdfLoading(null); } }; if (isPending) { return (
); } return (

Nabídky

{pagination?.total ?? quotations.length}{" "} {czechPlural( pagination?.total ?? quotations.length, "nabídka", "nabídky", "nabídek", )}

{hasPermission("settings.templates") && ( Šablony )} {hasPermission("offers.create") && ( Nová nabídka )}
{STATUS_FILTERS.map((f) => ( ))}
{ setSearch(e.target.value); setPage(1); }} className="admin-form-input" placeholder="Hledat podle čísla, projektu nebo zákazníka..." />
{quotations.length === 0 && !draft ? (

Zatím nejsou žádné nabídky.

{hasPermission("offers.create") && ( Vytvořit první nabídku )}
) : (
{draft && !search && ( )} {(quotations as Quotation[]).map((q) => { const isInvalidated = q.status === "invalidated"; const isCompleted = !isInvalidated && q.order_status === "dokoncena"; const isExpired = !isInvalidated && !isCompleted && !q.order_id && q.valid_until && new Date(q.valid_until) < new Date(new Date().toDateString()); const readOnly = isInvalidated || isCompleted; return ( ); })} {quotations.length === 0 && draft && search && ( )}
handleSort("quotation_number")} > Číslo{" "} handleSort("project_code")} > Projekt{" "} Zákazník handleSort("created_at")} > Datum{" "} handleSort("valid_until")} > Platnost{" "} handleSort("currency")} > Měna{" "} Celkem Akce
Koncept {draft.savedAt && ( {" · "} {new Date(draft.savedAt).toLocaleTimeString( "cs-CZ", { hour: "2-digit", minute: "2-digit" }, )} )} {draft.form.project_code || "—"} {draft.form.customer_name || "—"} {draft.form.created_at ? formatDate(draft.form.created_at) : "—"} {draft.form.valid_until ? formatDate(draft.form.valid_until) : "—"} {draft.form.currency || "—"}
{q.quotation_number} {q.project_code || "—"} {q.customer_name || "—"} {formatDate(q.created_at)} {formatDate(q.valid_until)} {q.currency} {formatCurrency(q.total, q.currency)}
{readOnly ? ( ) : ( )} {!readOnly && hasPermission("offers.create") && ( )} {!readOnly && q.order_id ? ( O ) : ( !readOnly && hasPermission("orders.create") && ( ) )} {!isInvalidated && !isCompleted && !q.order_id && hasPermission("offers.edit") && ( )} {hasPermission("offers.export") && ( )} {hasPermission("offers.delete") && ( )}
Žádné nabídky odpovídající hledání.
)}
setDeleteConfirm({ show: false, quotation: null })} onConfirm={handleDelete} title="Smazat nabídku" message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.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} /> setInvalidateConfirm({ show: false, quotation: null })} onConfirm={handleInvalidate} title="Zneplatnit nabídku" message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`} confirmText="Zneplatnit" cancelText="Zrušit" type="danger" loading={invalidating} /> setOrderModal({ show: false, quotation: null })} onSubmit={handleCreateOrder} title="Vytvořit objednávku" submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"} loading={!!creatingOrder} >

Nabídka: {orderModal.quotation?.quotation_number}

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
); }