import { useState, useEffect, useRef } from "react"; import { Link as RouterLink, useNavigate } 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 Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, formatMultiCurrency, czechPlural, } from "../utils/formatters"; import useTableSort from "../hooks/useTableSort"; import useDebounce from "../hooks/useDebounce"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { offerListOptions, offerStatsOptions, offerCustomersOptions, } from "../lib/queries/offers"; import { useApiMutation } from "../lib/queries/mutations"; import { Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, StatusChip, FileUpload, EmptyState, FilterBar, Tabs, PageHeader, PageEnter, LoadingState, type DataColumn, type TabDef, } from "../ui"; import { OFFER_STATUS, statusLabel, statusColor, statusOptions, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // Filter tabs derive from OFFER_STATUS so `draft` ("Koncept") flows in // automatically; the leading "Všechny" tab is the all-statuses filter. The // `expired` entry in OFFER_STATUS is a front-end-derived state (not a stored // value), so it is excluded from the filter tabs. const STATUS_FILTERS = statusOptions(OFFER_STATUS, { value: "", label: "Všechny", }).filter((o) => o.value !== "expired"); 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; } const TemplatesIcon = ( ); const PlusIcon = ( ); const ViewIcon = ( ); const EditIcon = ( ); const DuplicateIcon = ( ); const OrderViewIcon = ( O ); const OrderCreateIcon = ( ); const InvalidateIcon = ( ); const PdfIcon = ( ); const DeleteIcon = ( ); const MoreIcon = ( ); /** * Per-row overflow menu for the secondary offer actions (Duplicate + * Zneplatnit). Hook state (anchorEl) lives HERE, in a child component — never * in the page-level `.map`/render callback — so each row owns its own menu * (no shared-open bug) and Rules of Hooks stay satisfied. The page passes in * the same handlers + permission/disabled flags the inline icons used, so * behavior/permissions/confirms are preserved verbatim; the items are merely * relocated. The menu renders nothing (returns null) when no secondary action * is available for the row. */ function RowActionsMenu({ canDuplicate, duplicating, onDuplicate, canInvalidate, onInvalidate, }: { canDuplicate: boolean; duplicating: boolean; onDuplicate: () => void; canInvalidate: boolean; onInvalidate: () => void; }) { const [anchorEl, setAnchorEl] = useState(null); if (!canDuplicate && !canInvalidate) return null; const close = () => setAnchorEl(null); return ( <> setAnchorEl(e.currentTarget)} aria-label="Další akce" title="Další akce" > {MoreIcon} {canDuplicate && ( { close(); onDuplicate(); }} > {duplicating ? : DuplicateIcon} Duplikovat )} {canInvalidate && ( { close(); onInvalidate(); }} > {InvalidateIcon} Zneplatnit )} ); } export default function Offers() { const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const { sort, order, handleSort } = useTableSort("quotation_number"); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 300); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); const [customerFilter, setCustomerFilter] = useState(""); // Track first successful load so later refetches (filter/customer/tab/page // change) keep the table visible instead of flashing the full skeleton. const hasLoadedOnce = useRef(false); 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 queryClient = useQueryClient(); const { items: quotations, pagination, isPending, isFetching, } = usePaginatedQuery( offerListOptions({ search: debouncedSearch, sort, order, page, status: statusFilter || undefined, customer_id: customerFilter || undefined, }), ); // Per-currency total over the WHOLE filtered set (not one page). Uses the // SAME filters as the list query so the summary matches what's shown. const statsQuery = useQuery( offerStatsOptions({ search: debouncedSearch, status: statusFilter || undefined, customer_id: customerFilter || undefined, }), ); // Mark first load done in an effect (not during render) to avoid a // render-phase ref mutation. useEffect(() => { if (!isPending) hasLoadedOnce.current = true; }, [isPending]); 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"); }, }); 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); } }; // Only show the full-page skeleton on the very first load; on subsequent // refetches (filter/customer/tab/page change) keep the table visible (the // Card dims via isFetching) so it doesn't flash. if (isPending && !hasLoadedOnce.current) { return ; } const total = pagination?.total ?? quotations.length; const subtitle = `${total} ${czechPlural( total, "nabídka", "nabídky", "nabídek", )}`; const tabs: TabDef[] = STATUS_FILTERS.map((f) => ({ value: f.value, label: f.label, })); // Search/status filter is active → an empty list means "nothing matches" // (no create CTA); a genuinely empty list keeps the create-CTA empty state. const isFiltered = !!debouncedSearch || !!statusFilter; const columns: DataColumn[] = [ { key: "quotation_number", header: "Číslo", width: "13%", sortKey: "quotation_number", render: (q) => ( {documentNumberLabel(q.quotation_number)} ), }, { key: "project_code", header: "Projekt", width: "13%", sortKey: "project_code", render: (q) => q.project_code || "—", }, { key: "customer_name", header: "Zákazník", width: "13%", render: (q) => q.customer_name || "—", }, { key: "created_at", header: "Datum", width: "11%", sortKey: "created_at", mono: true, render: (q) => formatDate(q.created_at), }, { key: "valid_until", header: "Platnost", width: "10%", sortKey: "valid_until", mono: true, render: (q) => formatDate(q.valid_until), }, { key: "status", header: "Stav", width: "10%", sortKey: "status", render: (q) => { // The offer's own status is `active`/`ordered`/`invalidated`. When its // linked order is completed, surface that as "Dokončená" (success) — // matching the OfferDetail header chip and the completed row tint. const completed = q.status !== "invalidated" && q.order_status === "dokoncena"; return completed ? ( ) : ( ); }, }, { key: "currency", header: "Měna", width: "7%", sortKey: "currency", render: (q) => , }, { key: "total", header: "Celkem", width: "11%", align: "right", mono: true, bold: true, render: (q) => formatCurrency(q.total, q.currency), }, { key: "actions", header: "Akce", width: "15%", align: "right", render: (q) => { const isInvalidated = q.status === "invalidated"; const isCompleted = !isInvalidated && q.order_status === "dokoncena"; const readOnly = isInvalidated || isCompleted; // Secondary actions (relocated into the overflow menu). Same gates as // before: Duplicate needs offers.create and an editable (not // read-only) row; Zneplatnit needs offers.edit and a row that is not // invalidated/completed/ordered. const canDuplicate = !readOnly && hasPermission("offers.create"); const canInvalidate = !isInvalidated && !isCompleted && !q.order_id && hasPermission("offers.edit"); return ( navigate(`/offers/${q.id}`)} aria-label={readOnly ? "Zobrazit" : "Upravit"} title={readOnly ? "Zobrazit" : "Upravit"} > {readOnly ? ViewIcon : EditIcon} {!readOnly && q.order_id ? ( navigate(`/orders/${q.order_id}`)} aria-label="Zobrazit objednávku" title="Zobrazit objednávku" > {OrderViewIcon} ) : ( !readOnly && hasPermission("orders.create") && ( { setCustomerOrderNumber(""); setOrderAttachment(null); setOrderModal({ show: true, quotation: q }); }} aria-label="Vytvořit objednávku" title="Vytvořit objednávku" disabled={creatingOrder === q.id} > {creatingOrder === q.id ? ( ) : ( OrderCreateIcon )} ) )} {hasPermission("offers.view") && ( handlePdf(q)} aria-label="Zobrazit nabídku" title="Zobrazit nabídku" disabled={pdfLoading === q.id} > {pdfLoading === q.id ? : PdfIcon} )} {hasPermission("offers.delete") && ( setDeleteConfirm({ show: true, quotation: q })} aria-label="Smazat" title="Smazat" > {DeleteIcon} )} handleDuplicate(q)} canInvalidate={canInvalidate} onInvalidate={() => setInvalidateConfirm({ show: true, quotation: q }) } /> ); }, }, ]; // Per-status row tints (replaces offers-invalidated-row / offers-completed-row // / offers-expired-row). These are LOW-ALPHA washes via the palette channel // vars (not solid `.light` fills — those turn the white dark-mode row text // invisible and read garish in light mode). Invalidated = faded/muted, matching // the original "voided" look. Readable in BOTH schemes because the underlying // row text keeps its normal theme color. const rowSx = (q: Quotation) => { 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()); if (isInvalidated) { return { opacity: 0.6, "& td": { color: "var(--mui-palette-text-secondary)" }, }; } if (isCompleted) { return { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)", "&:hover": { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.18)", }, }; } if (isExpired) { return { backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)", "&:hover": { backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.18)", }, }; } return {}; }; return ( {hasPermission("settings.templates") && ( )} {hasPermission("offers.create") && ( )} } /> { setStatusFilter(v); setPage(1); }} tabs={tabs} /> { setSearch(e.target.value); setPage(1); }} placeholder="Hledat podle čísla, projektu nebo zákazníka..." fullWidth />