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 { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, 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, offerCustomersOptions } from "../lib/queries/offers"; import { useApiMutation } from "../lib/queries/mutations"; import { Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, StatusChip, FileUpload, Alert, EmptyState, FilterBar, Tabs, PageHeader, PageEnter, LoadingState, type DataColumn, type TabDef, } from "../ui"; 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; } const TemplatesIcon = ( ); const PlusIcon = ( ); const ViewIcon = ( ); const EditIcon = ( ); const DuplicateIcon = ( ); const OrderViewIcon = ( O ); const OrderCreateIcon = ( ); const InvalidateIcon = ( ); const PdfIcon = ( ); const DeleteIcon = ( ); 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(""); 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: debouncedSearch, 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); }; 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 ; } 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, })); const columns: DataColumn[] = [ { key: "quotation_number", header: "Číslo", width: "13%", sortKey: "quotation_number", render: (q) => ( {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: "18%", 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: "11%", sortKey: "valid_until", mono: true, render: (q) => formatDate(q.valid_until), }, { key: "currency", header: "Měna", width: "8%", sortKey: "currency", render: (q) => , }, { key: "total", header: "Celkem", width: "12%", align: "right", mono: true, bold: true, render: (q) => formatCurrency(q.total, q.currency), }, { key: "actions", header: "Akce", width: "16%", align: "right", render: (q) => { const isInvalidated = q.status === "invalidated"; const isCompleted = !isInvalidated && q.order_status === "dokoncena"; const readOnly = isInvalidated || isCompleted; return ( navigate(`/offers/${q.id}`)} aria-label={readOnly ? "Zobrazit" : "Upravit"} title={readOnly ? "Zobrazit" : "Upravit"} > {readOnly ? ViewIcon : EditIcon} {!readOnly && hasPermission("offers.create") && ( handleDuplicate(q)} aria-label="Duplikovat" title="Duplikovat" disabled={duplicating === q.id} > {duplicating === q.id ? ( ) : ( DuplicateIcon )} )} {!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 )} ) )} {!isInvalidated && !isCompleted && !q.order_id && hasPermission("offers.edit") && ( setInvalidateConfirm({ show: true, quotation: q }) } aria-label="Zneplatnit" title="Zneplatnit" > {InvalidateIcon} )} {hasPermission("offers.export") && ( 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} )} ); }, }, ]; // 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 />